[
  {
    "path": ".gitattributes",
    "content": "# Normalize EOL for all files that Git considers text files.\n* text=auto eol=lf\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @Nutomic @dessalines @phiresky @dullbananas\ncrates/apub/ @Nutomic\nmigrations/ @dessalines @phiresky @dullbananas\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\npatreon: dessalines\nliberapay: Lemmy\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.yml",
    "content": "name: \"\\U0001F41E Bug Report\"\ndescription: Create a report to help us improve lemmy\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Found a bug? Please fill out the sections below. 👍\n        Thanks for taking the time to fill out this bug report!\n        For front end issues, use [lemmy](https://github.com/LemmyNet/lemmy-ui)\n  - type: checkboxes\n    attributes:\n      label: Requirements\n      description: Before you create a bug report please do the following.\n      options:\n        - label: Is this a bug report? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).\n          required: true\n        - label: Did you check to see if this issue already exists?\n          required: true\n        - label: Is this only a single bug? Do not put multiple bugs in one issue.\n          required: true\n        - label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?\n          required: true\n        - label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.\n          required: true\n  - type: textarea\n    id: summary\n    attributes:\n      label: Summary\n      description: A summary of the bug.\n    validations:\n      required: true\n  - type: textarea\n    id: reproduce\n    attributes:\n      label: Steps to Reproduce\n      description: |\n        Describe the steps to reproduce the bug.\n        The better your description is _(go 'here', click 'there'...)_ the fastest you'll get an _(accurate)_ resolution.\n      value: |\n        1.\n        2.\n        3.\n    validations:\n      required: true\n  - type: textarea\n    id: technical\n    attributes:\n      label: Technical Details\n      description: |\n        - Please post your log: `sudo docker-compose logs > lemmy_log.out`.\n        - What OS are you trying to install lemmy on?\n        - Any browser console errors?\n    validations:\n      required: true\n  - type: input\n    id: lemmy-backend-version\n    attributes:\n      label: Version\n      description: Which Lemmy backend version do you use? Displayed in the footer.\n      placeholder: ex. BE 0.17.4\n    validations:\n      required: true\n  - type: input\n    id: lemmy-instance\n    attributes:\n      label: Lemmy Instance URL\n      description: Which Lemmy instance do you use? The address\n      placeholder: lemmy.ml, lemmy.world, etc\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml",
    "content": "name: \"\\U0001F680 Feature request\"\ndescription: Suggest an idea for improving Lemmy\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Have a suggestion about Lemmy's UI?\n        For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)\n  - type: checkboxes\n    attributes:\n      label: Requirements\n      description: Before you create a bug report please do the following.\n      options:\n        - label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).\n          required: true\n        - label: Did you check to see if this issue already exists?\n          required: true\n        - label: Is this only a feature request? Do not put multiple feature requests in one issue.\n          required: true\n        - label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.\n          required: true\n        - label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?\n          required: true\n  - type: textarea\n    id: problem\n    attributes:\n      label: Is your proposal related to a problem?\n      description: |\n        Provide a clear and concise description of what the problem is.\n        For example, \"I'm always frustrated when...\"\n    validations:\n      required: true\n  - type: textarea\n    id: solution\n    attributes:\n      label: Describe the solution you'd like.\n      description: |\n        Provide a clear and concise description of what you want to happen.\n    validations:\n      required: true\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Describe alternatives you've considered.\n      description: |\n        Let us know about other solutions you've tried or researched.\n    validations:\n      required: true\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional context\n      description: |\n        Is there anything else you can add about the proposal?\n        You might want to link to related issues here, if you haven't already.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/QUESTION.yml",
    "content": "name: \"? Question\"\ndescription: General questions about Lemmy\ntitle: \"Question: \"\nlabels: [\"question\", \"triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).\n\n        Have a question about how Lemmy works?\n        Please check the docs first: https://join-lemmy.org/docs/en/index.html\n  - type: textarea\n    id: question\n    attributes:\n      label: Question\n      description: What's the question you have about Lemmy?\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nUse [Github's security advisory issue system](https://github.com/LemmyNet/lemmy/security/advisories/new).\n"
  },
  {
    "path": ".gitignore",
    "content": "# local ansible configuration\nansible/inventory\nansible/passwords/\n\n# docker build files\ndocker/lemmy_mine.hjson\ndocker/dev/env_deploy.sh\ndocker/volumes\ndocker/*.sql.xz\n\n# ide config\n.idea\n.vscode\n\n# local build files\ntarget\nenv_setup.sh\nquery_testing/**/reports/*.json\n\n# API tests\napi_tests/node_modules\napi_tests/.yalc\napi_tests/yalc.lock\napi_tests/pict-rs\napi_tests/speed_tests.sh\n\n# pictrs data\npictrs/\n\n# The generated typescript bindings\nbindings\n\n# database dumps\n*.sqldump\n\n# diesel\nmigrations/.diesel_lock\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"crates/utils/translations\"]\n\tpath = crates/email/translations\n\turl = https://github.com/LemmyNet/lemmy-translations.git\n        branch = main\n"
  },
  {
    "path": ".rustfmt.toml",
    "content": "tab_spaces = 2\nedition = \"2024\"\nimports_layout = \"HorizontalVertical\"\nimports_granularity = \"Crate\"\ngroup_imports = \"One\"\nwrap_comments = true\ncomment_width = 100\n"
  },
  {
    "path": ".woodpecker.yml",
    "content": "# TODO: The when: platform conditionals aren't working currently\n# See https://github.com/woodpecker-ci/woodpecker/issues/1677\n\nvariables:\n  # When updating the rust version here, be sure to update versions in `docker/Dockerfile`\n  # as well. Otherwise release builds can fail if Lemmy or dependencies rely on new Rust\n  # features. In particular the ARM builder image needs to be updated manually in the repo below:\n  # https://github.com/raskyld/lemmy-cross-toolchains\n  # Also be sure to change the version in `rust-toolchain.toml`\n  - &rust_image \"rust:1.94\"\n  - &rust_nightly_image \"rustlang/rust:nightly\"\n  - &install_pnpm \"npm install -g corepack@latest && corepack enable pnpm\"\n  - &install_binstall \"wget -q -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin\"\n  - &slow_check_paths\n    - event: pull_request\n      path:\n        include: [\n            # rust source code\n            \"crates/**\",\n            \"**/Cargo.toml\",\n            \"Cargo.lock\",\n            # database migrations\n            \"migrations/**\",\n            # typescript tests\n            \"api_tests/**\",\n            # config files and scripts used by ci\n            \".woodpecker.yml\",\n            \".rustfmt.toml\",\n            \"scripts/update_config_defaults.sh\",\n            \"diesel.toml\",\n            \".gitmodules\",\n          ]\n\nsteps:\n  prepare_repo:\n    image: alpine:3\n    commands:\n      - apk add git\n      - git submodule init\n      - git submodule update\n    when:\n      - event: [pull_request, tag]\n\n  prettier_check:\n    image: jauderho/prettier:3.7.4-alpine\n    commands:\n      - prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml'\n    when:\n      - event: pull_request\n\n  bash_fmt:\n    image: alpine:3\n    commands:\n      - apk add shfmt\n      - shfmt -i 2 -d */**.bash\n      - shfmt -i 2 -d */**.sh\n    when:\n      - event: pull_request\n\n  toml_fmt:\n    image: ghcr.io/shaddydc/taplo\n    commands:\n      - taplo format --check\n    when:\n      - event: pull_request\n\n  sql_fmt:\n    image: *rust_image\n    commands:\n      - apt-get install perl make bash\n      - ./scripts/alpine_install_pg_formatter.sh\n      - ./scripts/sql_format_check.sh\n    when:\n      - event: pull_request\n\n  cargo_fmt:\n    image: *rust_nightly_image\n    environment:\n      # store cargo data in repo folder so that it gets cached between steps\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - rustup component add rustfmt --toolchain nightly\n      - cargo +nightly fmt -- --check\n    when:\n      - event: pull_request\n\n  cargo_shear:\n    image: *rust_nightly_image\n    commands:\n      - *install_binstall\n      - cargo binstall -y cargo-shear --disable-strategies compile\n      - cargo shear --deny-warnings\n    when:\n      - event: pull_request\n\n  api_tests_lint:\n    image: node:24-trixie-slim\n    commands:\n      - *install_pnpm\n      - cd api_tests/\n      - pnpm i\n      - pnpm lint\n    when: *slow_check_paths\n\n  ignored_files:\n    image: alpine:3\n    commands:\n      - apk add git\n      - IGNORED=$(git ls-files --cached -i --exclude-standard)\n      - if [[ \"$IGNORED\" ]]; then echo \"Ignored files present:\\n$IGNORED\\n\"; exit 1; fi\n    when:\n      - event: pull_request\n\n  no_empty_files:\n    image: alpine:3\n    commands:\n      # Makes sure there are no files smaller than 2 bytes\n      # Don't use completely empty, as some editors use newlines\n      - EMPTY_FILES=$(find crates migrations api_tests/src config -type f -size -2c)\n      - if [[ \"$EMPTY_FILES\" ]]; then echo \"Empty files present:\\n$EMPTY_FILES\\n\"; exit 1; fi\n    when:\n      - event: pull_request\n\n  cargo_build:\n    image: *rust_image\n    environment:\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - cargo build\n      - mv target/debug/lemmy_server target/lemmy_server\n    when: *slow_check_paths\n\n  # `DROP OWNED` doesn't work for default user\n  create_database_user:\n    image: pgautoupgrade/pgautoupgrade:18-alpine\n    environment:\n      PGUSER: postgres\n      PGPASSWORD: password\n      PGHOST: database\n      PGDATABASE: lemmy\n    commands:\n      - psql -c \"CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;\"\n    when: *slow_check_paths\n\n  cargo_test:\n    image: *rust_image\n    environment:\n      LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy\n      RUST_BACKTRACE: \"1\"\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n      LEMMY_TEST_FAST_FEDERATION: \"1\"\n      LEMMY_CONFIG_LOCATION: /woodpecker/src/github.com/LemmyNet/lemmy/config/config.hjson\n    commands:\n      # Install pg_dump for the schema setup test (must match server version)\n      - apt update && apt install -y lsb-release\n      - sh -c 'echo \"deb [signed-by=/usr/share/keyrings/postgres-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main\" > /etc/apt/sources.list.d/pgdg.list'\n      - wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres-keyring.gpg\n      - apt update && apt install -y postgresql-client-18\n      # Run tests (if they fail, try again)\n      - cargo test --workspace || cargo test --workspace\n    when: *slow_check_paths\n\n  cargo_clippy:\n    image: *rust_image\n    environment:\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - rustup component add clippy\n      - cargo clippy --workspace --tests --all-targets --all-features -- -D warnings\n    when: *slow_check_paths\n\n  # make sure api builds with default features (used by other crates relying on lemmy api)\n  check_api_common_default_features:\n    image: *rust_image\n    environment:\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - cargo check --package lemmy_api_common\n    when: *slow_check_paths\n\n  check_disallowed_dependencies:\n    image: *rust_image\n    environment:\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - \"! cargo tree -p lemmy_api_common --no-default-features -i diesel\"\n      - \"! cargo tree -i aws-lc-sys\"\n    when: *slow_check_paths\n\n  lemmy_api_common_works_with_wasm:\n    image: *rust_image\n    environment:\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - \"rustup target add wasm32-unknown-unknown\"\n      - \"cargo check --target wasm32-unknown-unknown -p lemmy_api_common\"\n    when: *slow_check_paths\n\n  check_diesel_schema:\n    image: *rust_image\n    environment:\n      LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy\n      DATABASE_URL: postgres://lemmy:password@database:5432/lemmy\n      RUST_BACKTRACE: \"1\"\n      CARGO_HOME: .cargo_home\n      RUSTUP_HOME: .rustup_home\n    commands:\n      - *install_binstall\n      - cp crates/db_schema_file/src/schema.rs tmp.schema\n      - target/lemmy_server migration --all run\n      - apt-get update && apt-get install -y postgresql-client\n      - cargo binstall diesel_cli@2.3.2 -y --disable-strategies compile\n      - export PATH=\"$CARGO_HOME/bin:$PATH\"\n      - diesel print-schema\n      - diff tmp.schema crates/db_schema_file/src/schema.rs\n    when: *slow_check_paths\n\n  run_federation_tests:\n    image: node:24-trixie-slim\n    environment:\n      LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432\n      DO_WRITE_HOSTS_FILE: \"1\"\n    commands:\n      - *install_pnpm\n      - apt-get update && apt-get install -y bash curl postgresql-client\n      - bash api_tests/prepare-drone-federation-test.sh\n      - cd api_tests/\n      - pnpm i\n      # Unfortunately these tests are unstable on slower CI machines, so try it a few times.\n      - pnpm api-test-multiple\n    when: *slow_check_paths\n\n  federation_tests_server_output:\n    image: alpine:3\n    commands:\n      # `|| true` prevents this step from appearing to fail if the server output files don't exist\n      - cat target/log/lemmy_*.out || true\n      - \"# If you can't see all output, then use the download button\"\n    when:\n      - event: pull_request\n        status: failure\n\n  publish_release_docker:\n    image: woodpeckerci/plugin-docker-buildx\n    settings:\n      repo: dessalines/lemmy\n      dockerfile: docker/Dockerfile\n      username:\n        from_secret: docker_username\n      password:\n        from_secret: docker_password\n      # On release builds, switch these comment lines to also do arm64 qemu builds.\n      # This takes 8 hours, so its not a good idea to do it for any other release.\n      platforms: linux/amd64\n      # platforms: linux/amd64,linux/arm64\n      build_args:\n        RUST_RELEASE_MODE: release\n      build_args_from_env:\n        - CI_PIPELINE_EVENT\n      tag: ${CI_COMMIT_TAG=nightly}\n    when:\n      - event: tag\n      - event: cron\n\n  # lemmy container doesnt run as root so we need to change permissions to let it copy the binary\n  chmod_for_native_binary:\n    image: alpine:3\n    commands:\n      - chmod 777 .\n    when:\n      - event: tag\n\n  # extract lemmy binary from newly built docker image into workspace folder\n  extract_native_binary:\n    image: dessalines/lemmy:${CI_COMMIT_TAG=default}\n    commands:\n      - cp /usr/local/bin/lemmy_server .\n    when:\n      - event: tag\n\n  prepare_native_binary:\n    image: alpine:3\n    commands:\n      - sha256sum lemmy_server > sha256sum.txt\n      - gzip lemmy_server\n    when:\n      - event: tag\n\n  # https://woodpecker-ci.org/plugins/Release\n  publish_native_binary:\n    image: woodpeckerci/plugin-release\n    settings:\n      files:\n        - lemmy_server.gz\n        - sha256sum.txt\n      title: ${CI_COMMIT_TAG}\n      prerelease: true\n      api-key:\n        from_secret: github_token\n    when:\n      - event: tag\n\n  # using https://github.com/pksunkara/cargo-workspaces\n  publish_to_crates_io:\n    image: *rust_image\n    environment:\n      CARGO_API_TOKEN:\n        from_secret: cargo_api_token\n    commands:\n      - *install_binstall\n      - cargo binstall -y cargo-workspaces@0.4.1 --disable-strategies compile\n      - cp -r migrations crates/db_schema/\n      - cargo workspaces publish --token \"$CARGO_API_TOKEN\" --from-git --allow-dirty --no-verify --allow-branch \"${CI_COMMIT_TAG}\" --yes custom \"${CI_COMMIT_TAG}\"\n    when:\n      - event: tag\n\n  notify_success:\n    image: alpine:3\n    commands:\n      - apk add curl\n      - \"curl -H'Title: ✔️ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci\"\n    when:\n      - event: pull_request\n        status: [success]\n\n  notify_failure:\n    image: alpine:3\n    commands:\n      - apk add curl\n      - \"curl -H'Title: ❌ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci\"\n    when:\n      - event: pull_request\n        status: [failure]\n\n  notify_on_tag_deploy:\n    image: alpine:3\n    commands:\n      - apk add curl\n      - \"curl -H'Title: ${CI_REPO_NAME}:${CI_COMMIT_TAG} deployed' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci\"\n    when:\n      event: tag\n\nservices:\n  database:\n    image: pgautoupgrade/pgautoupgrade:18-alpine\n    environment:\n      POSTGRES_DB: lemmy\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: password\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace.package]\nversion = \"1.0.0-test-arm-qemu.0\"\nedition = \"2024\"\ndescription = \"A link aggregator for the fediverse\"\nlicense = \"AGPL-3.0\"\nhomepage = \"https://join-lemmy.org/\"\ndocumentation = \"https://join-lemmy.org/docs/en/index.html\"\nrepository = \"https://github.com/LemmyNet/lemmy\"\nrust-version = \"1.92\"\n\n# See https://github.com/johnthagen/min-sized-rust for additional optimizations\n[profile.release]\nlto = \"fat\"\nopt-level = 3     # Optimize for speed, not size.\ncodegen-units = 1 # Reduce parallel code generation.\n\n# This profile significantly speeds up build time. If debug info is needed you can comment the line\n# out temporarily, but make sure to leave this in the main branch.\n[profile.dev]\ndebug = 0\n\n# Optimize procedural macros\n[profile.dev.build-override]\nopt-level = 1\n\n[workspace]\nmembers = [\n  \"crates/utils\",\n  \"crates/db_schema\",\n  \"crates/db_schema_file\",\n  \"crates/diesel_utils\",\n  \"crates/email\",\n  \"crates/db_views/private_message\",\n  \"crates/db_views/local_user\",\n  \"crates/db_views/local_image\",\n  \"crates/db_views/person\",\n  \"crates/db_views/post\",\n  \"crates/db_views/vote\",\n  \"crates/db_views/local_image\",\n  \"crates/db_views/comment\",\n  \"crates/db_views/community\",\n  \"crates/db_views/community_moderator\",\n  \"crates/db_views/community_follower\",\n  \"crates/db_views/community_follower_approval\",\n  \"crates/db_views/custom_emoji\",\n  \"crates/db_views/notification\",\n  \"crates/db_views/notification_sql\",\n  \"crates/db_views/modlog\",\n  \"crates/db_views/person_content_combined\",\n  \"crates/db_views/person_saved_combined\",\n  \"crates/db_views/person_liked_combined\",\n  \"crates/db_views/post_comment_combined\",\n  \"crates/db_views/report_combined\",\n  \"crates/db_views/report_combined_sql\",\n  \"crates/db_views/search_combined\",\n  \"crates/db_views/site\",\n  \"crates/api/api\",\n  \"crates/api/api_crud\",\n  \"crates/api/api_common\",\n  \"crates/api/api_utils\",\n  \"crates/api/routes\",\n  \"crates/api/routes_v3\",\n  \"crates/apub/apub\",\n  \"crates/apub/activities\",\n  \"crates/apub/objects\",\n  \"crates/apub/send\",\n  \"crates/routes\",\n  \"crates/server\",\n]\nresolver = \"3\"\n\n[workspace.lints.clippy]\ncast_lossless = \"deny\"\ncomplexity = { level = \"deny\", priority = -1 }\ncorrectness = { level = \"deny\", priority = -1 }\ndbg_macro = \"deny\"\nexplicit_into_iter_loop = \"deny\"\nexplicit_iter_loop = \"deny\"\nget_first = \"deny\"\nimplicit_clone = \"deny\"\nindexing_slicing = \"deny\"\ninefficient_to_string = \"deny\"\nitems-after-statements = \"deny\"\nmanual_string_new = \"deny\"\nneedless_collect = \"deny\"\nperf = { level = \"deny\", priority = -1 }\nredundant_closure_for_method_calls = \"deny\"\nstyle = { level = \"deny\", priority = -1 }\nsuspicious = { level = \"deny\", priority = -1 }\nuninlined_format_args = \"allow\"\nunused_self = \"deny\"\nunwrap_used = \"deny\"\nunimplemented = \"deny\"\nunused_async = \"deny\"\nmap_err_ignore = \"deny\"\nexpect_used = \"deny\"\nas_conversions = \"deny\"\nlarge_futures = \"deny\"\ntests_outside_test_module = \"deny\"\ntry_err = \"deny\"\nunreachable = \"deny\"\nstring_slice = \"deny\"\nsame_name_method = \"deny\"\nreturn_and_then = \"deny\"\nref_patterns = \"deny\"\nredundant_type_annotations = \"deny\"\nif_then_some_else_none = \"deny\"\nallow_attributes = \"deny\"\n\n[workspace.dependencies]\nlemmy_api = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/api/api\" }\nlemmy_api_crud = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/api/api_crud\" }\nlemmy_api_routes = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/api/routes\" }\nlemmy_api_routes_v3 = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/api/routes_v3\" }\nlemmy_apub = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/apub/apub\" }\nlemmy_apub_activities = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/apub/activities\" }\nlemmy_apub_objects = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/apub/objects\" }\nlemmy_utils = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/utils\", default-features = false }\nlemmy_db_schema = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_schema\" }\nlemmy_db_schema_file = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_schema_file\" }\nlemmy_diesel_utils = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/diesel_utils\" }\nlemmy_api_utils = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/api/api_utils\" }\nlemmy_routes = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/routes\" }\nlemmy_apub_send = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/apub/send\" }\nlemmy_email = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/email\" }\nlemmy_db_views_comment = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/comment\" }\nlemmy_db_views_community = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/community\" }\nlemmy_db_views_community_follower = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/community_follower\" }\nlemmy_db_views_community_follower_approval = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/community_follower_approval\" }\nlemmy_db_views_community_moderator = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/community_moderator\" }\nlemmy_db_views_custom_emoji = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/custom_emoji\" }\nlemmy_db_views_notification = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/notification\" }\nlemmy_db_views_notification_sql = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/notification_sql\" }\nlemmy_db_views_local_image = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/local_image\" }\nlemmy_db_views_local_user = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/local_user\" }\nlemmy_db_views_modlog = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/modlog\" }\nlemmy_db_views_person = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/person\" }\nlemmy_db_views_person_content_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/person_content_combined\" }\nlemmy_db_views_person_liked_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/person_liked_combined\" }\nlemmy_db_views_person_saved_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/person_saved_combined\" }\nlemmy_db_views_post_comment_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/post_comment_combined\" }\nlemmy_db_views_post = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/post\" }\nlemmy_db_views_private_message = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/private_message\" }\nlemmy_db_views_registration_applications = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/registration_applications\" }\nlemmy_db_views_report_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/report_combined\" }\nlemmy_db_views_report_combined_sql = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/report_combined_sql\" }\nlemmy_db_views_search_combined = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/search_combined\" }\nlemmy_db_views_site = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/site\" }\nlemmy_db_views_vote = { version = \"=1.0.0-test-arm-qemu.0\", path = \"./crates/db_views/vote\" }\nactivitypub_federation = { version = \"0.7.0-beta.9\", default-features = false, features = [\n  \"actix-web\",\n] }\ndiesel = { version = \"2.3.6\", features = [\n  \"64-column-tables\",\n  \"chrono\",\n  \"postgres\",\n  \"serde_json\",\n  \"uuid\",\n] }\ndiesel_migrations = \"2.3.1\"\ndiesel-async = \"0.7.4\"\nserde = { version = \"1.0.228\", features = [\"derive\"] }\nserde_with = \"3.17.0\"\nactix-web = { version = \"4.13.0\", default-features = false, features = [\n  \"compress-brotli\",\n  \"compress-gzip\",\n  \"compress-zstd\",\n  \"cookies\",\n  \"macros\",\n  \"rustls-0_23\",\n] }\ntracing = { version = \"0.1.44\", default-features = false }\ntracing-actix-web = { version = \"0.7.21\", default-features = false }\ntracing-subscriber = { version = \"0.3.22\", features = [\"env-filter\", \"json\"] }\nurl = { version = \"2.5.8\", features = [\"serde\"] }\nreqwest = { version = \"0.13.2\", default-features = false, features = [\n  \"gzip\",\n  \"json\",\n  \"rustls-no-provider\",\n] }\nreqwest-middleware = \"0.5.1\"\nreqwest-tracing = \"0.7.0\"\nclokwerk = \"0.4.0\"\ndoku = { version = \"0.21.1\", features = [\"url-2\"] }\nbcrypt = \"0.19.0\"\nchrono = { version = \"0.4.44\", features = [\n  \"now\",\n  \"serde\",\n], default-features = false }\nserde_json = { version = \"1.0.149\", features = [\"preserve_order\"] }\nbase64 = \"0.22.1\"\nuuid = { version = \"1.22.0\", features = [\"serde\"] }\nanyhow = { version = \"1.0.102\", features = [\"backtrace\"] }\ndiesel_ltree = \"0.4.0\"\nserial_test = \"3.4.0\"\ntokio = { version = \"1.50.0\", features = [\"full\"] }\nregex = \"1.12.3\"\ndiesel-derive-newtype = \"2.1.2\"\ndiesel-derive-enum = { version = \"2.1.0\", features = [\"postgres\"] }\nenum-map = { version = \"2.7\" }\nstrum = { version = \"0.28.0\", features = [\"derive\"] }\nitertools = \"0.14.0\"\nfutures = \"0.3.32\"\nfutures-util = \"0.3.32\"\nhttp = \"1.3\"\nrosetta-i18n = \"0.1.3\"\nts-rs = { version = \"12.0.1\", features = [\n  \"chrono-impl\",\n  \"no-serde-warnings\",\n  \"url-impl\",\n] }\nrustls = { version = \"0.23.37\", features = [\"ring\"], default-features = false }\ntokio-postgres = \"0.7.16\"\ntokio-postgres-rustls = \"0.13.0\"\nurlencoding = \"2.1.3\"\nmoka = { version = \"0.12.14\", features = [\"future\"] }\ni-love-jesus = { version = \"0.3.0\" }\nclap = { version = \"4.5.60\", features = [\"derive\", \"env\"] }\npretty_assertions = \"1.4.1\"\nderive-new = \"0.7.0\"\nhtml2text = \"0.16.7\"\nasync-trait = \"0.1.89\"\neither = { version = \"1.15.0\", features = [\"serde\"] }\nextism = { version = \"1.13.0\", default-features = false, features = [\n  \"http\",\n  \"register-filesystem\",\n  \"register-http\",\n] }\nextism-convert = \"1.13.0\"\nunified-diff = \"0.2.1\"\ndiesel-uplete = { version = \"0.2.0\" }\ncfg-if = \"1\"\n\n# Speedup RSA key generation\n# https://github.com/RustCrypto/RSA/blob/master/README.md#example\n[profile.dev.package.num-bigint-dig]\nopt-level = 3\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": "README.md",
    "content": "<div align=\"center\">\n\n[![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/releases)\n[![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n[![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)](https://github.com/LemmyNet/lemmy/stargazers)\n<a href=\"https://endsoftwarepatents.org/innovating-without-patents\"><img style=\"height: 20px;\" src=\"https://static.fsf.org/nosvn/esp/logos/patent-free.svg\"></a>\n\n</div>\n\n<p align=\"center\">\n  <span>English</span> |\n  <a href=\"readmes/README.es.md\">Español</a> |\n  <a href=\"readmes/README.ru.md\">Русский</a> |\n  <a href=\"readmes/README.zh.hans.md\">汉语</a> |\n  <a href=\"readmes/README.zh.hant.md\">漢語</a> |\n  <a href=\"readmes/README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    A link aggregator and forum for the fediverse.\n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">Join Lemmy</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/index.html\">Documentation</a>\n    ·\n    <a href=\"https://matrix.to/#/#lemmy-space:matrix.org\">Matrix Chat</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Report Bug</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Request Feature</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">Releases</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/code_of_conduct.html\">Code of Conduct</a>\n  </p>\n</p>\n\n## About The Project\n\n| Desktop                                                                                                         | Mobile                                                                                                      |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).\n\nFor a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.\n\nIt is an easily self-hostable, decentralized alternative to Reddit and other link aggregators, outside of their corporate control and meddling.\n\nEach Lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.\n\n### Why's it called Lemmy?\n\n- Lead singer from [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g).\n- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).\n- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).\n- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).\n\n### Built With\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n## Features\n\n- Open source, [AGPL License](/LICENSE).\n- Self hostable, easy to deploy.\n  - Comes with [Docker](https://join-lemmy.org/docs/administration/install_docker.html) and [Ansible](https://join-lemmy.org/docs/administration/install_ansible.html).\n- Clean, mobile-friendly interface.\n  - Only a minimum of a username and password is required to sign up!\n  - User avatar support.\n  - Live-updating Comment threads.\n  - Full vote scores `(+/-)` like old Reddit.\n  - Themes, including light, dark, and solarized.\n  - Emojis with autocomplete support. Start typing `:`\n  - User tagging using `@`, Community tagging using `!`.\n  - Integrated image uploading in both posts and comments.\n  - A post can consist of a title and any combination of self text, a URL, or nothing else.\n  - Notifications, on comment replies and when you're tagged.\n    - Notifications can be sent via email.\n    - Private messaging support.\n  - i18n / internationalization support.\n  - RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.\n- Cross-posting support.\n  - A _similar post search_ when creating new posts. Great for question / answer communities.\n- Moderation abilities.\n  - Public Moderation Logs.\n  - Can sticky posts to the top of communities.\n  - Both site admins, and community moderators, who can appoint other moderators.\n  - Can lock, remove, and restore posts and comments.\n  - Can ban and unban users from communities and the site.\n  - Can transfer site and communities to others.\n- Can fully erase your data, replacing all posts and comments.\n- NSFW post / community support.\n- High performance.\n  - Server is written in rust.\n  - Supports arm64 / Raspberry Pi.\n\n## Installation\n\n- [Lemmy Administration Docs](https://join-lemmy.org/docs/administration/administration.html)\n\n## Lemmy Projects\n\n- [awesome-lemmy - A community driven list of apps and tools for lemmy](https://github.com/dbeley/awesome-lemmy)\n\n## Support / Donate\n\nLemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.\n\nLemmy is made possible by a generous grant from the [NLnet foundation](https://nlnet.nl/).\n\n- [Support on Liberapay](https://liberapay.com/Lemmy).\n- [Support on Ko-fi](https://ko-fi.com/lemmynet).\n- [Support on OpenCollective](https://opencollective.com/lemmy).\n- [Support on Patreon](https://www.patreon.com/dessalines).\n\n### Crypto\n\n- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n\n## Contributing\n\nRead the following documentation to setup the development environment and start coding:\n\n- [Contributing instructions](https://join-lemmy.org/docs/contributors/01-overview.html)\n- [Docker Development](https://join-lemmy.org/docs/contributors/03-docker-development.html)\n- [Local Development](https://join-lemmy.org/docs/contributors/02-local-development.html)\n\nWhen working on an issue or pull request, you can comment with any questions you may have so that maintainers can answer them. You can also join the [Matrix Development Chat](https://matrix.to/#/#lemmydev:matrix.org) for general assistance.\n\n### Translations\n\n- If you want to help with translating, take a look at [Weblate](https://weblate.join-lemmy.org/projects/lemmy/). You can also help by [translating the documentation](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).\n\n## Community\n\n- [Matrix Space](https://matrix.to/#/#lemmy-space:matrix.org)\n- [Lemmy Forum](https://lemmy.ml/c/lemmy)\n- [Lemmy Support Forum](https://lemmy.ml/c/lemmy_support)\n\n## Code Mirrors\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## Credits\n\nLogo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license.\n"
  },
  {
    "path": "api_tests/.npmrc",
    "content": "package-manager-strict=false\n"
  },
  {
    "path": "api_tests/.prettierrc.json",
    "content": "{\n  \"arrowParens\": \"avoid\",\n  \"semi\": true\n}\n"
  },
  {
    "path": "api_tests/eslint.config.mjs",
    "content": "import pluginJs from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\n\nexport default [\n  pluginJs.configs.recommended,\n  ...tseslint.configs.recommended,\n  {\n    languageOptions: {\n      parser: tseslint.parser,\n    },\n  },\n  // For some reason this has to be in its own block\n  {\n    ignores: [\n      \"putTypesInIndex.js\",\n      \"dist/*\",\n      \"docs/*\",\n      \".yalc\",\n      \"jest.config.js\",\n    ],\n  },\n  {\n    files: [\"src/**/*\"],\n    rules: {\n      \"@typescript-eslint/no-empty-interface\": 0,\n      \"@typescript-eslint/no-empty-function\": 0,\n      \"@typescript-eslint/ban-ts-comment\": 0,\n      \"@typescript-eslint/no-explicit-any\": 0,\n      \"@typescript-eslint/explicit-module-boundary-types\": 0,\n      \"@typescript-eslint/no-var-requires\": 0,\n      \"arrow-body-style\": 0,\n      curly: 0,\n      \"eol-last\": 0,\n      eqeqeq: 0,\n      \"func-style\": 0,\n      \"import/no-duplicates\": 0,\n      \"max-statements\": 0,\n      \"max-params\": 0,\n      \"new-cap\": 0,\n      \"no-console\": 0,\n      \"no-duplicate-imports\": 0,\n      \"no-extra-parens\": 0,\n      \"no-return-assign\": 0,\n      \"no-throw-literal\": 0,\n      \"no-trailing-spaces\": 0,\n      \"no-unused-expressions\": 0,\n      \"no-useless-constructor\": 0,\n      \"no-useless-escape\": 0,\n      \"no-var\": 0,\n      \"prefer-const\": 0,\n      \"prefer-rest-params\": 0,\n      \"quote-props\": 0,\n      \"unicorn/filename-case\": 0,\n    },\n  },\n];\n"
  },
  {
    "path": "api_tests/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testEnvironment: \"node\",\n};\n"
  },
  {
    "path": "api_tests/package.json",
    "content": "{\n  \"name\": \"api_tests\",\n  \"version\": \"0.0.1\",\n  \"description\": \"API tests for lemmy backend\",\n  \"main\": \"index.js\",\n  \"repository\": \"https://github.com/LemmyNet/lemmy\",\n  \"author\": \"Dessalines\",\n  \"license\": \"AGPL-3.0\",\n  \"packageManager\": \"pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501\",\n  \"scripts\": {\n    \"lint\": \"tsc --noEmit && eslint --report-unused-disable-directives && prettier --check 'src/**/*.ts'\",\n    \"fix\": \"prettier --write src && eslint --fix src\",\n    \"api-test\": \"jest -i --bail --verbose --silent --testPathIgnorePatterns speed.spec.ts\",\n    \"api-test-multiple\": \"pnpm run api-test || pnpm run api-test || pnpm run api-test || pnpm run api-test\",\n    \"api-test-follow\": \"jest -i follow.spec.ts\",\n    \"api-test-comment\": \"jest -i comment.spec.ts\",\n    \"api-test-post\": \"jest -i post.spec.ts\",\n    \"api-test-user\": \"jest -i user.spec.ts\",\n    \"api-test-community\": \"jest -i community.spec.ts\",\n    \"api-test-private-community\": \"jest -i private_comm.spec.ts\",\n    \"api-test-private-message\": \"jest -i private_message.spec.ts\",\n    \"api-test-image\": \"jest -i image.spec.ts\",\n    \"api-test-tags\": \"jest -i tags.spec.ts\",\n    \"api-test-speed\": \"jest -i speed.spec.ts\",\n    \"api-test-apiv3\": \"jest -i apiv3.spec.ts\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.29.0\",\n    \"@types/jest\": \"^30.0.0\",\n    \"@types/node\": \"^24.0.3\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.34.1\",\n    \"@typescript-eslint/parser\": \"^8.34.1\",\n    \"eslint\": \"^9.29.0\",\n    \"eslint-plugin-prettier\": \"^5.5.0\",\n    \"jest\": \"^30.0.0\",\n    \"joi\": \"^18.0.0\",\n    \"lemmy-js-client\": \"1.0.0-pictrs-fields-db.0\",\n    \"lemmy-js-client-019\": \"npm:lemmy-js-client@0.19.9\",\n    \"prettier\": \"^3.5.3\",\n    \"ts-jest\": \"^29.4.0\",\n    \"tsoa\": \"^6.6.0\",\n    \"typescript\": \"^5.8.3\",\n    \"typescript-eslint\": \"^8.34.1\"\n  }\n}\n"
  },
  {
    "path": "api_tests/pnpm-workspace.yaml",
    "content": "onlyBuiltDependencies:\n  - unrs-resolver\n"
  },
  {
    "path": "api_tests/prepare-drone-federation-test.sh",
    "content": "#!/usr/bin/env bash\n# IMPORTANT NOTE: this script does not use the normal LEMMY_DATABASE_URL format\n#   it is expected that this script is called by run-federation-test.sh script.\nset -e\n\nif [ -z \"$LEMMY_LOG_LEVEL\" ]; then\n  LEMMY_LOG_LEVEL=info\nfi\n\nexport RUST_BACKTRACE=1\nexport RUST_LOG=\"warn,lemmy_server=$LEMMY_LOG_LEVEL,lemmy_federate=$LEMMY_LOG_LEVEL,lemmy_api=$LEMMY_LOG_LEVEL,lemmy_api_common=$LEMMY_LOG_LEVEL,lemmy_api_crud=$LEMMY_LOG_LEVEL,lemmy_apub=$LEMMY_LOG_LEVEL,lemmy_db_schema=$LEMMY_LOG_LEVEL,lemmy_db_views=$LEMMY_LOG_LEVEL,lemmy_routes=$LEMMY_LOG_LEVEL,lemmy_utils=$LEMMY_LOG_LEVEL,lemmy_websocket=$LEMMY_LOG_LEVEL\"\n\nexport LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min\n\nPICTRS_PATH=\"api_tests/pict-rs\"\nPICTRS_EXPECTED_HASH=\"7f7ac2a45ef9b13403ee139b7512135be6b060ff2f6460e0c800e18e1b49d2fd  api_tests/pict-rs\"\n\n# Pictrs setup. Download file with hash check and up to 3 retries.\nif [ ! -f \"$PICTRS_PATH\" ]; then\n  count=0\n  while [ ! -f \"$PICTRS_PATH\" ] && [ \"$count\" -lt 3 ]; do\n    # This one sometimes goes down\n    curl \"https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.17-pre.9/pict-rs-linux-amd64\" -o \"$PICTRS_PATH\"\n    # curl \"https://codeberg.org/asonix/pict-rs/releases/download/v0.5.5/pict-rs-linux-amd64\" -o \"$PICTRS_PATH\"\n    PICTRS_HASH=$(sha256sum \"$PICTRS_PATH\")\n    if [[ \"$PICTRS_HASH\" != \"$PICTRS_EXPECTED_HASH\" ]]; then\n      echo \"Pictrs binary hash mismatch, was $PICTRS_HASH but expected $PICTRS_EXPECTED_HASH\"\n      rm \"$PICTRS_PATH\"\n      let count=count+1\n    fi\n  done\n  chmod +x \"$PICTRS_PATH\"\nfi\n\n./api_tests/pict-rs \\\n  run -a 0.0.0.0:8080 \\\n  --danger-dummy-mode \\\n  --api-key \"my-pictrs-key\" \\\n  filesystem -p /tmp/pictrs/files \\\n  sled -p /tmp/pictrs/sled-repo 2>&1 &\n\nfor INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do\n  echo \"DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE\"\n  psql \"${LEMMY_DATABASE_URL}/lemmy\" -c \"DROP DATABASE IF EXISTS $INSTANCE\"\n  echo \"create database\"\n  psql \"${LEMMY_DATABASE_URL}/lemmy\" -c \"CREATE DATABASE $INSTANCE\"\ndone\n\nif [ -z \"$DO_WRITE_HOSTS_FILE\" ]; then\n  if ! grep -q lemmy-alpha /etc/hosts; then\n    echo \"Please add the following to your /etc/hosts file, then press enter:\n\n      127.0.0.1       lemmy-alpha\n      127.0.0.1       lemmy-beta\n      127.0.0.1       lemmy-gamma\n      127.0.0.1       lemmy-delta\n      127.0.0.1       lemmy-epsilon\"\n    read -p \"\"\n  fi\nelse\n  for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do\n    echo \"127.0.0.1 $INSTANCE\" >>/etc/hosts\n  done\nfi\n\necho \"$PWD\"\n\nLOG_DIR=target/log\nmkdir -p $LOG_DIR\n\necho \"start alpha\"\nLEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \\\n  LEMMY_DATABASE_URL=\"${LEMMY_DATABASE_URL}/lemmy_alpha\" \\\n  target/lemmy_server >$LOG_DIR/lemmy_alpha.out 2>&1 &\n\necho \"start beta\"\nLEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \\\n  LEMMY_DATABASE_URL=\"${LEMMY_DATABASE_URL}/lemmy_beta\" \\\n  target/lemmy_server >$LOG_DIR/lemmy_beta.out 2>&1 &\n\necho \"start gamma\"\nLEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \\\n  LEMMY_DATABASE_URL=\"${LEMMY_DATABASE_URL}/lemmy_gamma\" \\\n  target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 &\n\necho \"start delta\"\nLEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \\\n  LEMMY_DATABASE_URL=\"${LEMMY_DATABASE_URL}/lemmy_delta\" \\\n  target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &\n\necho \"start epsilon\"\nLEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \\\n  LEMMY_PLUGIN_PATH=api_tests/plugins \\\n  LEMMY_DATABASE_URL=\"${LEMMY_DATABASE_URL}/lemmy_epsilon\" \\\n  target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 &\n\necho \"wait for all instances to start\"\nwhile [[ \"$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-alpha:8541/api/v4/site')\" != \"200\" ]]; do sleep 1; done\necho \"alpha started\"\nwhile [[ \"$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v4/site')\" != \"200\" ]]; do sleep 1; done\necho \"beta started\"\nwhile [[ \"$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v4/site')\" != \"200\" ]]; do sleep 1; done\necho \"gamma started\"\nwhile [[ \"$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v4/site')\" != \"200\" ]]; do sleep 1; done\necho \"delta started\"\nwhile [[ \"$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v4/site')\" != \"200\" ]]; do sleep 1; done\necho \"epsilon started. All started\"\n"
  },
  {
    "path": "api_tests/run-federation-test.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nexport LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432\npushd ..\ncargo build\nrm target/lemmy_server || true\ncp target/debug/lemmy_server target/lemmy_server\nkillall -s1 lemmy_server || true\n./api_tests/prepare-drone-federation-test.sh\npopd\n\npnpm i\npnpm api-test || true\n\nkillall -s1 lemmy_server || true\nkillall -s1 pict-rs || true\nfor INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do\n  psql \"$LEMMY_DATABASE_URL\" -c \"DROP DATABASE $INSTANCE\"\ndone\nrm -r /tmp/pictrs\n"
  },
  {
    "path": "api_tests/src/apiv3.spec.ts",
    "content": "jest.setTimeout(180000);\n\nimport {\n  LemmyHttp,\n  Login,\n  CreatePost,\n  ResolveObject,\n} from \"lemmy-js-client-019\";\nimport { beta, betaUrl, setupLogins, unfollows } from \"./shared\";\nimport { CreateComment } from \"lemmy-js-client\";\n\nbeforeAll(async () => {\n  await setupLogins();\n});\n\nafterAll(unfollows);\n\ntest(\"API v3\", async () => {\n  let login_form: Login = {\n    username_or_email: \"lemmy_beta\",\n    password: \"lemmylemmy\",\n  };\n  const login = await beta.login(login_form);\n  expect(login.jwt).toBeDefined();\n\n  let user = new LemmyHttp(betaUrl, {\n    headers: { Authorization: `Bearer ${login.jwt ?? \"\"}` },\n  });\n\n  let resolve_form: ResolveObject = {\n    q: \"!main@lemmy-beta:8551\",\n  };\n  const community = await user\n    .resolveObject(resolve_form)\n    .then(a => a.community);\n  expect(community?.community).toBeDefined();\n\n  const post_form: CreatePost = {\n    name: \"post from api v3\",\n    community_id: community!.community.id,\n  };\n  const post = await user.createPost(post_form);\n  expect(post.post_view.post).toBeDefined();\n  const post_id = post.post_view.post.id;\n\n  const post_listing = await user.getPosts();\n  expect(\n    post_listing.posts.find(p => {\n      return p.post.id === post_id;\n    })?.post,\n  ).toStrictEqual(post.post_view.post);\n\n  const comment_form: CreateComment = {\n    content: \"comment from api v3\",\n    post_id,\n  };\n  const comment = await user.createComment(comment_form);\n  expect(comment.comment_view.comment).toBeDefined();\n\n  const comment_listing = await user.getComments({ post_id });\n  expect(comment_listing.comments[0].comment).toStrictEqual(\n    comment.comment_view.comment,\n  );\n});\n"
  },
  {
    "path": "api_tests/src/comment.spec.ts",
    "content": "jest.setTimeout(180000);\n\nimport { PostResponse } from \"lemmy-js-client/dist/types/PostResponse\";\nimport {\n  alpha,\n  beta,\n  gamma,\n  setupLogins,\n  createPost,\n  getPost,\n  resolveComment,\n  likeComment,\n  followBeta,\n  resolveBetaCommunity,\n  createComment,\n  editComment,\n  deleteComment,\n  removeComment,\n  resolvePost,\n  unfollowRemotes,\n  createCommunity,\n  registerUser,\n  reportComment,\n  randomString,\n  unfollows,\n  getComment,\n  getComments,\n  getCommentParentId,\n  resolveCommunity,\n  waitUntil,\n  waitForPost,\n  alphaUrl,\n  betaUrl,\n  followCommunity,\n  blockCommunity,\n  saveUserSettings,\n  listReports,\n  listPersonContent,\n  listNotifications,\n  lockComment,\n  statusNotFound,\n  statusBadRequest,\n  jestLemmyError,\n  getUnreadCounts,\n} from \"./shared\";\nimport {\n  CommentReportView,\n  CommentView,\n  CommunityView,\n  DistinguishComment,\n  LemmyError,\n  ReportCombinedView,\n  SaveUserSettings,\n} from \"lemmy-js-client\";\n\nlet betaCommunity: CommunityView | undefined;\nlet postOnAlphaRes: PostResponse;\n\nbeforeAll(async () => {\n  await setupLogins();\n  await Promise.allSettled([followBeta(alpha), followBeta(gamma)]);\n  betaCommunity = await resolveBetaCommunity(alpha);\n  if (betaCommunity) {\n    postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);\n  }\n});\n\nafterAll(unfollows);\n\nfunction assertCommentFederation(\n  commentOne?: CommentView,\n  commentTwo?: CommentView,\n) {\n  expect(commentOne?.comment.ap_id).toBe(commentTwo?.comment.ap_id);\n  expect(commentOne?.comment.content).toBe(commentTwo?.comment.content);\n  expect(commentOne?.creator.name).toBe(commentTwo?.creator.name);\n  expect(commentOne?.community.ap_id).toBe(commentTwo?.community.ap_id);\n  expect(commentOne?.comment.published_at).toBe(\n    commentTwo?.comment.published_at,\n  );\n  expect(commentOne?.comment.updated_at).toBe(commentOne?.comment.updated_at);\n  expect(commentOne?.comment.deleted).toBe(commentOne?.comment.deleted);\n  expect(commentOne?.comment.removed).toBe(commentOne?.comment.removed);\n}\n\ntest(\"Create a comment\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  expect(commentRes.comment_view.comment.content).toBeDefined();\n  expect(commentRes.comment_view.community.local).toBe(false);\n  expect(commentRes.comment_view.creator.local).toBe(true);\n  expect(commentRes.comment_view.comment.score).toBe(1);\n\n  // Make sure that comment is liked on beta\n  let betaComment = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.score === 1,\n  );\n  expect(betaComment).toBeDefined();\n  expect(betaComment?.community.local).toBe(true);\n  expect(betaComment?.creator.local).toBe(false);\n  expect(betaComment?.comment.score).toBe(1);\n  assertCommentFederation(betaComment, commentRes.comment_view);\n});\n\ntest(\"Create a comment in a non-existent post\", async () => {\n  await jestLemmyError(\n    () => createComment(alpha, -1),\n    new LemmyError(\"not_found\", statusNotFound),\n  );\n});\n\ntest(\"Update a comment\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  // Federate the comment first\n  let betaComment = await resolveComment(beta, commentRes.comment_view.comment);\n  assertCommentFederation(betaComment, commentRes.comment_view);\n\n  let updateCommentRes = await editComment(\n    alpha,\n    commentRes.comment_view.comment.id,\n  );\n  expect(updateCommentRes.comment_view.comment.content).toBe(\n    \"A jest test federated comment update\",\n  );\n  expect(updateCommentRes.comment_view.community.local).toBe(false);\n  expect(updateCommentRes.comment_view.creator.local).toBe(true);\n\n  // Make sure that post is updated on beta\n  let betaCommentUpdated = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.content === \"A jest test federated comment update\",\n  );\n  assertCommentFederation(betaCommentUpdated, updateCommentRes.comment_view);\n});\n\ntest(\"Delete a comment\", async () => {\n  let post = await createPost(alpha, betaCommunity!.community.id);\n  // creating a comment on alpha (remote from home of community)\n  let commentRes = await createComment(alpha, post.post_view.post.id);\n\n  // Find the comment on beta (home of community)\n  let betaComment = await resolveComment(beta, commentRes.comment_view.comment);\n  if (!betaComment) {\n    throw \"Missing beta comment before delete\";\n  }\n\n  // Find the comment on remote instance gamma\n  let gammaComment = (\n    await waitUntil(\n      () =>\n        resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),\n      r => r.message !== \"not_found\",\n    )\n  ).comment;\n  if (!gammaComment) {\n    throw \"Missing gamma comment (remote-home-remote replication) before delete\";\n  }\n\n  let deleteCommentRes = await deleteComment(\n    alpha,\n    true,\n    commentRes.comment_view.comment.id,\n  );\n  expect(deleteCommentRes.comment_view.comment.deleted).toBe(true);\n\n  // Make sure that comment is deleted on beta\n  await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.deleted === true,\n  );\n\n  // Make sure that comment is deleted on gamma after delete\n  await waitUntil(\n    () => resolveComment(gamma, commentRes.comment_view.comment),\n    c => c?.comment.deleted === true,\n  );\n\n  // Test undeleting the comment\n  let undeleteCommentRes = await deleteComment(\n    alpha,\n    false,\n    commentRes.comment_view.comment.id,\n  );\n  expect(undeleteCommentRes.comment_view.comment.deleted).toBe(false);\n\n  // Make sure that comment is undeleted on beta\n  let betaComment2 = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.deleted === false,\n  );\n  assertCommentFederation(betaComment2, undeleteCommentRes.comment_view);\n});\n\ntest.skip(\"Remove a comment from admin and community on the same instance\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n\n  // Get the id for beta\n  let betaCommentId = (\n    await resolveComment(beta, commentRes.comment_view.comment)\n  )?.comment.id;\n\n  if (!betaCommentId) {\n    throw \"beta comment id is missing\";\n  }\n\n  // The beta admin removes it (the community lives on beta)\n  let removeCommentRes = await removeComment(beta, true, betaCommentId);\n  expect(removeCommentRes.comment_view.comment.removed).toBe(true);\n\n  // Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it)\n  let refetchedPostComments = await listPersonContent(\n    alpha,\n    commentRes.comment_view.comment.creator_id,\n    \"comments\",\n  );\n  let firstRefetchedComment = refetchedPostComments.items[0] as CommentView;\n  expect(firstRefetchedComment.comment.removed).toBe(true);\n\n  // beta will unremove the comment\n  let unremoveCommentRes = await removeComment(beta, false, betaCommentId);\n  expect(unremoveCommentRes.comment_view.comment.removed).toBe(false);\n\n  // Make sure that comment is unremoved on alpha\n  let refetchedPostComments2 = await getComments(\n    alpha,\n    postOnAlphaRes.post_view.post.id,\n  );\n  expect(refetchedPostComments2.items[0].comment.removed).toBe(false);\n  assertCommentFederation(\n    refetchedPostComments2.items[0],\n    unremoveCommentRes.comment_view,\n  );\n});\n\ntest(\"Remove a comment from admin and community on different instance\", async () => {\n  let newAlphaApi = await registerUser(alpha, alphaUrl);\n\n  // New alpha user creates a community, post, and comment.\n  let newCommunity = await createCommunity(newAlphaApi);\n  let newPost = await createPost(\n    newAlphaApi,\n    newCommunity.community_view.community.id,\n  );\n  let commentRes = await createComment(newAlphaApi, newPost.post_view.post.id);\n  expect(commentRes.comment_view.comment.content).toBeDefined();\n\n  // Beta searches that to cache it, then removes it\n  let betaComment = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment !== undefined,\n  );\n\n  if (!betaComment) {\n    throw \"beta comment missing\";\n  }\n\n  let removeCommentRes = await removeComment(\n    beta,\n    true,\n    betaComment.comment.id,\n  );\n  expect(removeCommentRes.comment_view.comment.removed).toBe(true);\n\n  // Comment text is also hidden from list\n  let listComments = await getComments(\n    beta,\n    removeCommentRes.comment_view.post.id,\n  );\n  expect(listComments.items.length).toBe(1);\n  expect(listComments.items[0].comment.removed).toBe(true);\n\n  // Make sure its not removed on alpha\n  let refetchedPostComments = await getComments(\n    alpha,\n    newPost.post_view.post.id,\n  );\n  expect(refetchedPostComments.items[0].comment.removed).toBe(false);\n  assertCommentFederation(\n    refetchedPostComments.items[0],\n    commentRes.comment_view,\n  );\n});\n\ntest(\"Unlike a comment\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n\n  // Lemmy automatically creates 1 like (vote) by author of comment.\n  // Make sure that comment is liked (voted up) on gamma, downstream peer\n  // This is testing replication from remote-home-remote (alpha-beta-gamma)\n\n  let gammaComment1 = await waitUntil(\n    () => resolveComment(gamma, commentRes.comment_view.comment),\n    c => c?.comment.score === 1,\n  );\n  expect(gammaComment1).toBeDefined();\n  expect(gammaComment1?.community.local).toBe(false);\n  expect(gammaComment1?.creator.local).toBe(false);\n  expect(gammaComment1?.comment.score).toBe(1);\n\n  let unlike = await likeComment(\n    alpha,\n    undefined,\n    commentRes.comment_view.comment,\n  );\n  expect(unlike.comment_view.comment.score).toBe(0);\n\n  // Make sure that comment is unliked on beta\n  let betaComment = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.score === 0,\n  );\n  expect(betaComment).toBeDefined();\n  expect(betaComment?.community.local).toBe(true);\n  expect(betaComment?.creator.local).toBe(false);\n  expect(betaComment?.comment.score).toBe(0);\n\n  // Make sure that comment is unliked on gamma, downstream peer\n  // This is testing replication from remote-home-remote (alpha-beta-gamma)\n  let gammaComment = await waitUntil(\n    () => resolveComment(gamma, commentRes.comment_view.comment),\n    c => c?.comment.score === 0,\n  );\n  expect(gammaComment).toBeDefined();\n  expect(gammaComment?.community.local).toBe(false);\n  expect(gammaComment?.creator.local).toBe(false);\n  expect(gammaComment?.comment.score).toBe(0);\n});\n\ntest(\"Federated comment like\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.score === 1,\n  );\n  // Find the comment on beta\n  let betaComment = await resolveComment(beta, commentRes.comment_view.comment);\n\n  if (!betaComment) {\n    throw \"Missing beta comment\";\n  }\n\n  let like = await likeComment(beta, true, betaComment.comment);\n  expect(like.comment_view.comment.score).toBe(2);\n\n  // Get the post from alpha, check the likes\n  let postComments = await waitUntil(\n    () => getComments(alpha, postOnAlphaRes.post_view.post.id),\n    c => c.items[0].comment.score === 2,\n  );\n  expect(postComments.items[0].comment.score).toBe(2);\n});\n\ntest(\"Reply to a comment from another instance, get notification\", async () => {\n  await alpha.markAllNotificationsAsRead();\n\n  let betaCommunity = await waitUntil(\n    () => resolveBetaCommunity(alpha),\n    c => !!c?.community.instance_id,\n  );\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);\n\n  // Create a root-level trunk-branch comment on alpha\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  // find that comment id on beta\n  let betaComment = await waitUntil(\n    () => resolveComment(beta, commentRes.comment_view.comment),\n    c => c?.comment.score === 1,\n  );\n\n  if (!betaComment) {\n    throw \"Missing beta comment\";\n  }\n\n  // Reply from beta, extending the branch\n  let replyRes = await createComment(\n    beta,\n    betaComment.post.id,\n    betaComment.comment.id,\n  );\n  expect(replyRes.comment_view.comment.content).toBeDefined();\n  expect(replyRes.comment_view.community.local).toBe(true);\n  expect(replyRes.comment_view.creator.local).toBe(true);\n  expect(getCommentParentId(replyRes.comment_view.comment)).toBe(\n    betaComment.comment.id,\n  );\n  expect(replyRes.comment_view.comment.score).toBe(1);\n\n  // Make sure that reply comment is seen on alpha\n  let commentSearch = await waitUntil(\n    () => resolveComment(alpha, replyRes.comment_view.comment),\n    c => c?.comment.score === 1,\n  );\n  let alphaComment = commentSearch!;\n  let postComments = await waitUntil(\n    () => getComments(alpha, postOnAlphaRes.post_view.post.id),\n    pc => pc.items.length >= 2,\n  );\n  // Note: this test fails when run twice and this count will differ\n  expect(postComments.items.length).toBeGreaterThanOrEqual(2);\n  expect(alphaComment.comment.content).toBeDefined();\n\n  expect(getCommentParentId(alphaComment.comment)).toBe(\n    postComments.items[1].comment.id,\n  );\n  expect(alphaComment.community.local).toBe(false);\n  expect(alphaComment.creator.local).toBe(false);\n  expect(alphaComment.comment.score).toBe(1);\n  assertCommentFederation(alphaComment, replyRes.comment_view);\n\n  // Did alpha get notified of the reply from beta?\n  let alphaUnreadCountRes = await waitUntil(\n    () => getUnreadCounts(alpha),\n    e => e.notification_count >= 1,\n  );\n  expect(alphaUnreadCountRes.notification_count).toBeGreaterThanOrEqual(1);\n\n  // check inbox of replies on alpha, fetching read/unread both\n  let alphaRepliesRes = await waitUntil(\n    () => listNotifications(alpha, \"reply\"),\n    r => r.items.length > 0,\n  );\n  const alphaReply = alphaRepliesRes.items.find(\n    r =>\n      r.data.type_ == \"comment\" &&\n      r.data.comment.id === alphaComment.comment.id,\n  );\n  expect(alphaReply).toBeDefined();\n  if (!alphaReply) throw Error();\n  const alphaReplyData = alphaReply.data as CommentView;\n  expect(alphaReplyData.comment!.content).toBeDefined();\n  expect(alphaReplyData.community!.local).toBe(false);\n  expect(alphaReplyData.creator.local).toBe(false);\n  expect(alphaReplyData.comment!.score).toBe(1);\n  // ToDo: interesting alphaRepliesRes.replies[0].comment_reply.id is 1, meaning? how did that come about?\n  expect(alphaReplyData.comment!.id).toBe(alphaComment.comment.id);\n  // this is a new notification, getReplies fetch was for read/unread both, confirm it is unread.\n  expect(alphaReply.notification.read).toBe(false);\n});\n\ntest(\"Bot reply notifications are filtered when bots are hidden\", async () => {\n  const newAlphaBot = await registerUser(alpha, alphaUrl);\n  let form: SaveUserSettings = {\n    bot_account: true,\n  };\n  await saveUserSettings(newAlphaBot, form);\n\n  const alphaCommunity = await resolveCommunity(\n    alpha,\n    \"!main@lemmy-alpha:8541\",\n  );\n\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n\n  await alpha.markAllNotificationsAsRead();\n  form = {\n    show_bot_accounts: false,\n  };\n  await saveUserSettings(alpha, form);\n  const postOnAlphaRes = await createPost(alpha, alphaCommunity.community.id);\n\n  // Bot reply to alpha's post\n  let commentRes = await createComment(\n    newAlphaBot,\n    postOnAlphaRes.post_view.post.id,\n  );\n  expect(commentRes).toBeDefined();\n\n  let alphaUnreadCountRes = await getUnreadCounts(alpha);\n  expect(alphaUnreadCountRes.notification_count).toBe(0);\n\n  // This both restores the original state that may be expected by other tests\n  // implicitly and is used by the next steps to ensure replies are still\n  // returned when a user later decides to show bot accounts again.\n  form = {\n    show_bot_accounts: true,\n  };\n  await saveUserSettings(alpha, form);\n\n  alphaUnreadCountRes = await getUnreadCounts(alpha);\n  expect(alphaUnreadCountRes.notification_count).toBe(1);\n\n  let alphaUnreadRepliesRes = await listNotifications(alpha, \"reply\", true);\n  expect(alphaUnreadRepliesRes.items.length).toBe(1);\n  expect(alphaUnreadRepliesRes.items[0].notification.comment_id).toBe(\n    commentRes.comment_view.comment.id,\n  );\n});\n\ntest(\"Mention beta from alpha comment\", async () => {\n  if (!betaCommunity) throw Error(\"no community\");\n  const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);\n  // Create a new branch, trunk-level comment branch, from alpha instance\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  // Create a reply comment to previous comment, this has a mention in body\n  let mentionContent = \"A test mention of @lemmy_beta@lemmy-beta:8551\";\n  let mentionRes = await createComment(\n    alpha,\n    postOnAlphaRes.post_view.post.id,\n    commentRes.comment_view.comment.id,\n    mentionContent,\n  );\n  expect(mentionRes.comment_view.comment.content).toBeDefined();\n  expect(mentionRes.comment_view.community.local).toBe(false);\n  expect(mentionRes.comment_view.creator.local).toBe(true);\n  expect(mentionRes.comment_view.comment.score).toBe(1);\n\n  // get beta's localized copy of the alpha post\n  let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post);\n  if (!betaPost) {\n    throw \"unable to locate post on beta\";\n  }\n  expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id);\n  expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name);\n\n  // Make sure that both new comments are seen on beta and have parent/child relationship\n  let betaPostComments = await waitUntil(\n    () => getComments(beta, betaPost!.post.id),\n    c => c.items[1]?.comment.score === 1,\n  );\n  expect(betaPostComments.items.length).toEqual(2);\n  // the trunk-branch root comment will be older than the mention reply comment, so index 1\n  let betaRootComment = betaPostComments.items[1];\n  // the trunk-branch root comment should not have a parent\n  expect(getCommentParentId(betaRootComment.comment)).toBeUndefined();\n  expect(betaRootComment.comment.content).toBeDefined();\n  // the mention reply comment should have parent that points to the branch root level comment\n  expect(getCommentParentId(betaPostComments.items[0].comment)).toBe(\n    betaPostComments.items[1].comment.id,\n  );\n  expect(betaRootComment.community.local).toBe(true);\n  expect(betaRootComment.creator.local).toBe(false);\n  expect(betaRootComment.comment.score).toBe(1);\n  assertCommentFederation(betaRootComment, commentRes.comment_view);\n\n  let mentionsRes = await waitUntil(\n    () => listNotifications(beta, \"mention\"),\n    m => !!m.items[0],\n  );\n\n  const firstMention = mentionsRes.items[0];\n  let firstMentionData = firstMention.data as CommentView;\n  expect(firstMentionData.comment!.content).toBeDefined();\n  expect(firstMentionData.community!.local).toBe(true);\n  expect(firstMentionData.creator.local).toBe(false);\n  expect(firstMentionData.comment!.score).toBe(1);\n  // the reply comment with mention should be the most fresh, newest, index 0\n  expect(firstMentionData.comment!.id).toBe(\n    betaPostComments.items[0].comment.id,\n  );\n});\n\ntest(\"Comment Search\", async () => {\n  let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);\n  let betaComment = await resolveComment(beta, commentRes.comment_view.comment);\n  assertCommentFederation(betaComment, commentRes.comment_view);\n});\n\ntest(\"A and G subscribe to B (center) A posts, G mentions B, it gets announced to A\", async () => {\n  // Create a local post\n  let alphaCommunity = await resolveCommunity(alpha, \"!main@lemmy-alpha:8541\");\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n\n  // follow community from beta so that it accepts the mention\n  let betaCommunity = await resolveCommunity(\n    beta,\n    alphaCommunity.community.ap_id,\n  );\n  await followCommunity(beta, true, betaCommunity!.community.id);\n\n  let alphaPost = await createPost(alpha, alphaCommunity.community.id);\n  expect(alphaPost.post_view.community.local).toBe(true);\n\n  // Make sure gamma sees it\n  let gammaPost = await resolvePost(gamma, alphaPost.post_view.post);\n\n  if (!gammaPost) {\n    throw \"Missing gamma post\";\n  }\n\n  let commentContent =\n    \"A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8551\";\n  let commentRes = await createComment(\n    gamma,\n    gammaPost.post.id,\n    undefined,\n    commentContent,\n  );\n  expect(commentRes.comment_view.comment.content).toBe(commentContent);\n  expect(commentRes.comment_view.community.local).toBe(false);\n  expect(commentRes.comment_view.creator.local).toBe(true);\n  expect(commentRes.comment_view.comment.score).toBe(1);\n\n  // Make sure alpha sees it\n  let alphaPostComments2 = await waitUntil(\n    () => getComments(alpha, alphaPost.post_view.post.id),\n    e => e.items[0]?.comment.score === 1,\n  );\n  expect(alphaPostComments2.items[0].comment.content).toBe(commentContent);\n  expect(alphaPostComments2.items[0].community.local).toBe(true);\n  expect(alphaPostComments2.items[0].creator.local).toBe(false);\n  expect(alphaPostComments2.items[0].comment.score).toBe(1);\n  assertCommentFederation(alphaPostComments2.items[0], commentRes.comment_view);\n\n  // Make sure beta has mentions\n  let relevantMention = await waitUntil(\n    () =>\n      listNotifications(beta, \"mention\").then(m =>\n        m.items.find(m => {\n          let data = m.data as CommentView;\n          return (\n            m.notification.kind == \"mention\" &&\n            data.comment.ap_id === commentRes.comment_view.comment.ap_id\n          );\n        }),\n      ),\n    e => !!e,\n  );\n  if (!relevantMention) throw Error(\"could not find mention\");\n  let relevantMentionData = relevantMention.data as CommentView;\n  expect(relevantMentionData.comment!.content).toBe(commentContent);\n  expect(relevantMentionData.community!.local).toBe(false);\n  expect(relevantMentionData.creator.local).toBe(false);\n  // TODO this is failing because fetchInReplyTos aren't getting score\n  // expect(mentionsRes.mentions[0].score).toBe(1);\n});\n\ntest(\"Check that activity from another instance is sent to third instance\", async () => {\n  // Alpha and gamma users follow beta community\n  let alphaFollow = await followBeta(alpha);\n  expect(alphaFollow.community_view.community.local).toBe(false);\n  expect(alphaFollow.community_view.community.name).toBe(\"main\");\n\n  let gammaFollow = await followBeta(gamma);\n  expect(gammaFollow.community_view.community.local).toBe(false);\n  expect(gammaFollow.community_view.community.name).toBe(\"main\");\n  await waitUntil(\n    () => resolveBetaCommunity(alpha),\n    c => c?.community_actions?.follow_state === \"accepted\",\n  );\n  await waitUntil(\n    () => resolveBetaCommunity(gamma),\n    c => c?.community_actions?.follow_state === \"accepted\",\n  );\n\n  // Create a post on beta\n  let betaPost = await createPost(beta, 2);\n  expect(betaPost.post_view.community.local).toBe(true);\n\n  // Make sure gamma and alpha see it\n  let gammaPost = await waitForPost(gamma, betaPost.post_view.post);\n  if (!gammaPost) {\n    throw \"Missing gamma post\";\n  }\n  expect(gammaPost.post).toBeDefined();\n\n  let alphaPost = await waitForPost(alpha, betaPost.post_view.post);\n  if (!alphaPost) {\n    throw \"Missing alpha post\";\n  }\n  expect(alphaPost.post).toBeDefined();\n\n  // The bug: gamma comments, and alpha should see it.\n  let commentContent = \"Comment from gamma\";\n  let commentRes = await createComment(\n    gamma,\n    gammaPost.post.id,\n    undefined,\n    commentContent,\n  );\n  expect(commentRes.comment_view.comment.content).toBe(commentContent);\n  expect(commentRes.comment_view.community.local).toBe(false);\n  expect(commentRes.comment_view.creator.local).toBe(true);\n  expect(commentRes.comment_view.comment.score).toBe(1);\n\n  // Make sure alpha sees it\n  let alphaPostComments2 = await waitUntil(\n    () => getComments(alpha, alphaPost!.post.id),\n    e => e.items[0]?.comment.score === 1,\n  );\n  expect(alphaPostComments2.items[0].comment.content).toBe(commentContent);\n  expect(alphaPostComments2.items[0].community.local).toBe(false);\n  expect(alphaPostComments2.items[0].creator.local).toBe(false);\n  expect(alphaPostComments2.items[0].comment.score).toBe(1);\n  assertCommentFederation(alphaPostComments2.items[0], commentRes.comment_view);\n\n  await Promise.allSettled([unfollowRemotes(alpha), unfollowRemotes(gamma)]);\n});\n\ntest(\"Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.\", async () => {\n  // Unfollow all remote communities\n  let my_user = await unfollowRemotes(alpha);\n  expect(my_user.follows.filter(c => c.community.local == false).length).toBe(\n    0,\n  );\n\n  // B creates a post, and two comments, should be invisible to A\n  let postOnBetaRes = await createPost(beta, 2);\n  expect(postOnBetaRes.post_view.post.name).toBeDefined();\n\n  let parentCommentContent = \"An invisible top level comment from beta\";\n  let parentCommentRes = await createComment(\n    beta,\n    postOnBetaRes.post_view.post.id,\n    undefined,\n    parentCommentContent,\n  );\n  expect(parentCommentRes.comment_view.comment.content).toBe(\n    parentCommentContent,\n  );\n\n  // B creates a comment, then a child one of that.\n  let childCommentContent = \"An invisible child comment from beta\";\n  let childCommentRes = await createComment(\n    beta,\n    postOnBetaRes.post_view.post.id,\n    parentCommentRes.comment_view.comment.id,\n    childCommentContent,\n  );\n  expect(childCommentRes.comment_view.comment.content).toBe(\n    childCommentContent,\n  );\n\n  // Follow beta again\n  let follow = await followBeta(alpha);\n  expect(follow.community_view.community.local).toBe(false);\n  expect(follow.community_view.community.name).toBe(\"main\");\n\n  // An update to the child comment on beta, should push the post, parent, and child to alpha now\n  let updatedCommentContent = \"An update child comment from beta\";\n  let updateRes = await editComment(\n    beta,\n    childCommentRes.comment_view.comment.id,\n    updatedCommentContent,\n  );\n  expect(updateRes.comment_view.comment.content).toBe(updatedCommentContent);\n\n  // Get the post from alpha\n  let alphaPostB = await waitForPost(alpha, postOnBetaRes.post_view.post);\n\n  if (!alphaPostB) {\n    throw \"Missing alpha post B\";\n  }\n\n  let alphaPost = await getPost(alpha, alphaPostB.post.id);\n  let alphaPostComments = await waitUntil(\n    () => getComments(alpha, alphaPostB!.post.id),\n    c =>\n      c.items[1]?.comment.content ===\n        parentCommentRes.comment_view.comment.content &&\n      c.items[0]?.comment.content === updateRes.comment_view.comment.content,\n  );\n  expect(alphaPost.post_view.post.name).toBeDefined();\n  assertCommentFederation(\n    alphaPostComments.items[1],\n    parentCommentRes.comment_view,\n  );\n  assertCommentFederation(alphaPostComments.items[0], updateRes.comment_view);\n  expect(alphaPost.post_view.community.local).toBe(false);\n  expect(alphaPost.post_view.creator.local).toBe(false);\n\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Report a comment\", async () => {\n  let betaCommunity = await resolveBetaCommunity(beta);\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let postOnBetaRes = (await createPost(beta, betaCommunity.community.id))\n    .post_view.post;\n  expect(postOnBetaRes).toBeDefined();\n  let commentRes = (await createComment(beta, postOnBetaRes.id)).comment_view\n    .comment;\n  expect(commentRes).toBeDefined();\n\n  let alphaComment = await resolveComment(alpha, commentRes);\n  if (!alphaComment) {\n    throw \"Missing alpha comment\";\n  }\n\n  const reason = randomString(10);\n  let alphaReport = (\n    await reportComment(alpha, alphaComment.comment.id, reason)\n  ).comment_report_view.comment_report;\n\n  let betaReport = (\n    (await waitUntil(\n      () =>\n        listReports(beta).then(p =>\n          p.items.find(r => {\n            return checkCommentReportReason(r, reason);\n          }),\n        ),\n      e => !!e,\n    )!) as CommentReportView\n  ).comment_report;\n  expect(betaReport).toBeDefined();\n  expect(betaReport.resolved).toBe(false);\n  expect(betaReport.original_comment_text).toBe(\n    alphaReport.original_comment_text,\n  );\n  expect(betaReport.reason).toBe(alphaReport.reason);\n});\n\ntest(\"Dont send a comment reply to a blocked community\", async () => {\n  await beta.markAllNotificationsAsRead();\n  let newCommunity = await createCommunity(beta);\n  let newCommunityId = newCommunity.community_view.community.id;\n\n  // Create a post on beta\n  let betaPost = await createPost(beta, newCommunityId);\n\n  let alphaPost = await resolvePost(alpha, betaPost.post_view.post);\n  if (!alphaPost) {\n    throw \"unable to locate post on alpha\";\n  }\n\n  // Check beta's inbox count\n  let unreadCount = await getUnreadCounts(beta);\n  expect(unreadCount.notification_count).toBe(0);\n\n  // Beta blocks the new beta community\n  let blockRes = await blockCommunity(beta, newCommunityId, true);\n  expect(blockRes.community_view.community_actions?.blocked_at).toBeDefined();\n\n  // Alpha creates a comment\n  let commentRes = await createComment(alpha, alphaPost.post.id);\n  expect(commentRes.comment_view.comment.content).toBeDefined();\n  let alphaComment = await resolveComment(\n    beta,\n    commentRes.comment_view.comment,\n  );\n  if (!alphaComment) {\n    throw \"Missing alpha comment before block\";\n  }\n\n  // Check beta's inbox count, make sure it stays the same\n  unreadCount = await getUnreadCounts(beta);\n  expect(unreadCount.notification_count).toBe(0);\n\n  let replies = await listNotifications(beta, \"reply\", true);\n  expect(replies.items.length).toBe(0);\n\n  // Unblock the community\n  blockRes = await blockCommunity(beta, newCommunityId, false);\n  expect(blockRes.community_view.community_actions?.blocked_at).toBeUndefined();\n});\n\n/// Fetching a deeply nested comment can lead to stack overflow as all parent comments are also\n/// fetched recursively. Ensure that it works properly.\ntest(\"Fetch a deeply nested comment\", async () => {\n  const alphaCommunity = await resolveCommunity(\n    alpha,\n    \"!main@lemmy-alpha:8541\",\n  );\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n  const postOnAlphaRes = await createPost(alpha, alphaCommunity.community.id);\n  let lastComment;\n  for (let i = 1; i < 50; i++) {\n    let commentRes = await createComment(\n      alpha,\n      postOnAlphaRes.post_view.post.id,\n      lastComment?.comment_view.comment.id,\n    );\n    expect(commentRes.comment_view.comment).toBeDefined();\n    lastComment = commentRes;\n  }\n\n  let betaComment = await resolveComment(\n    beta,\n    lastComment!.comment_view.comment,\n  );\n\n  expect(betaComment?.comment).toBeDefined();\n  expect(betaComment?.post).toBeDefined();\n});\n\ntest(\"Distinguish comment\", async () => {\n  const community = (await resolveBetaCommunity(beta))?.community;\n  let post = await createPost(beta, community!.id);\n  let commentRes = await createComment(beta, post.post_view.post.id);\n  const form: DistinguishComment = {\n    comment_id: commentRes.comment_view.comment.id,\n    distinguished: true,\n  };\n  await beta.distinguishComment(form);\n\n  let alphaPost = await resolvePost(alpha, post.post_view.post);\n\n  // Find the comment on alpha (home of community)\n  let alphaComments = await waitUntil(\n    () => getComments(alpha, alphaPost?.post.id),\n    c => c.items[0].comment.distinguished,\n  );\n\n  assertCommentFederation(alphaComments.items[0], commentRes.comment_view);\n});\n\ntest(\"Lock comment\", async () => {\n  let newBetaApi = await registerUser(beta, betaUrl);\n\n  const alphaCommunity = await resolveCommunity(\n    alpha,\n    \"!main@lemmy-alpha:8541\",\n  );\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n\n  let post = await createPost(alpha, alphaCommunity.community.id);\n  let betaPost = await resolvePost(beta, post.post_view.post);\n\n  if (!betaPost) {\n    throw \"unable to locate post on beta\";\n  }\n\n  // Create a comment hierarchy like this:\n  // 1\n  // | \\\n  // 2  4\n  // |\n  // 3\n\n  let comment1 = await createComment(alpha, post.post_view.post.id);\n  let betaComment1 = await resolveComment(beta, comment1.comment_view.comment);\n  if (!betaComment1) {\n    throw \"unable to locate comment on beta\";\n  }\n  await followCommunity(newBetaApi, true, betaComment1!.community.id);\n\n  let comment2 = await createComment(\n    alpha,\n    post.post_view.post.id,\n    comment1.comment_view.comment.id,\n  );\n  let betaComment2 = await resolveComment(beta, comment2.comment_view.comment);\n  if (!betaComment2) {\n    throw \"unable to locate comment on beta\";\n  }\n  let comment3 = await createComment(\n    newBetaApi,\n    betaPost.post.id,\n    betaComment2.comment.id,\n  );\n\n  // Lock comment2 and wait for it to federate\n  await lockComment(alpha, true, comment2.comment_view.comment);\n\n  const comment_ap_id = comment3.comment_view.comment.ap_id;\n  await waitUntil(\n    () => getComments(newBetaApi, betaPost.post.id),\n    c => {\n      const find = c.items.find(c => c.comment.ap_id == comment_ap_id);\n      return find?.comment.locked ?? false;\n    },\n  );\n\n  // Make sure newBeta can't respond to comment3\n  await jestLemmyError(\n    () =>\n      createComment(\n        newBetaApi,\n        betaPost.post.id,\n        comment3.comment_view.comment.id,\n      ),\n    new LemmyError(\"locked\", statusBadRequest),\n  );\n\n  // newBeta should still be able to respond to comment1\n  expect(\n    await createComment(newBetaApi, betaPost.post.id, betaComment1.comment.id),\n  ).toBeDefined();\n});\n\ntest(\"Remove children\", async () => {\n  const alphaCommunity = await resolveCommunity(\n    alpha,\n    \"!main@lemmy-alpha:8541\",\n  );\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n\n  let post = await createPost(alpha, alphaCommunity.community.id);\n  let betaPost = await resolvePost(beta, post.post_view.post);\n\n  if (!betaPost) {\n    throw \"unable to locate post on beta\";\n  }\n  await followCommunity(beta, true, betaPost.community.id);\n\n  let comment1 = await createComment(beta, betaPost.post.id);\n  let comment2 = await createComment(\n    beta,\n    betaPost.post.id,\n    comment1.comment_view.comment.id,\n  );\n  await createComment(beta, betaPost.post.id, comment2.comment_view.comment.id);\n  await createComment(beta, betaPost.post.id, comment1.comment_view.comment.id);\n\n  // Wait until the comments have federated\n  await waitUntil(\n    () => getPost(alpha, post.post_view.post.id),\n    p => p.post_view.post.comments == 4,\n  );\n\n  let commentOnAlpha = await resolveComment(\n    alpha,\n    comment1.comment_view.comment,\n  );\n  if (!commentOnAlpha) {\n    throw \"unable to locate comment on alpha\";\n  }\n\n  await removeComment(alpha, true, commentOnAlpha.comment.id, true);\n\n  let post2 = await getPost(alpha, post.post_view.post.id);\n  expect(post2.post_view.post.comments).toBe(0);\n\n  // Wait until the remove has federated\n  await waitUntil(\n    () => getComment(beta, comment1.comment_view.comment.id),\n    c => c.comment_view.comment.removed,\n  );\n\n  // Make sure removal federates properly\n  let betaPost2 = await resolvePost(beta, post.post_view.post);\n  if (!betaPost2) {\n    throw \"unable to locate post on beta\";\n  }\n  expect(betaPost2.post.comments).toBe(0);\n});\n\nfunction checkCommentReportReason(rcv: ReportCombinedView, reason: string) {\n  switch (rcv.type_) {\n    case \"comment\":\n      return rcv.comment_report.reason === reason;\n    default:\n      return false;\n  }\n}\n"
  },
  {
    "path": "api_tests/src/community.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport { AddModToCommunity } from \"lemmy-js-client/dist/types/AddModToCommunity\";\nimport {\n  alpha,\n  beta,\n  gamma,\n  setupLogins,\n  resolveCommunity,\n  createCommunity,\n  deleteCommunity,\n  removeCommunity,\n  getCommunity,\n  followCommunity,\n  banPersonFromCommunity,\n  resolvePerson,\n  createPost,\n  getPost,\n  resolvePost,\n  registerUser,\n  getPosts,\n  getComments,\n  createComment,\n  getCommunityByName,\n  waitUntil,\n  alphaUrl,\n  delta,\n  editCommunity,\n  unfollows,\n  getMyUser,\n  userBlockInstanceCommunities,\n  resolveBetaCommunity,\n  reportCommunity,\n  randomString,\n  assertCommunityFederation,\n  listReports,\n  statusBadRequest,\n  jestLemmyError,\n} from \"./shared\";\nimport { AdminAllowInstanceParams } from \"lemmy-js-client/dist/types/AdminAllowInstanceParams\";\nimport {\n  CommunityReport,\n  CommunityReportView,\n  EditCommunity,\n  FollowMultiCommunity,\n  GetPosts,\n  LemmyError,\n  MultiCommunityView,\n  ReportCombinedView,\n  ResolveCommunityReport,\n  Search,\n} from \"lemmy-js-client\";\n\nbeforeAll(setupLogins);\nafterAll(unfollows);\n\ntest(\"Create community\", async () => {\n  let communityRes = await createCommunity(alpha);\n  expect(communityRes.community_view.community.name).toBeDefined();\n\n  // A dupe check\n  let prevName = communityRes.community_view.community.name;\n  await jestLemmyError(\n    () => createCommunity(alpha, prevName),\n    new LemmyError(\"already_exists\", statusBadRequest),\n  );\n\n  // Cache the community on beta, make sure it has the other fields\n  let searchShort = `!${prevName}@lemmy-alpha:8541`;\n  let betaCommunity = await resolveCommunity(beta, searchShort);\n  assertCommunityFederation(betaCommunity, communityRes.community_view);\n});\n\ntest(\"Delete community\", async () => {\n  let communityRes = await createCommunity(beta);\n\n  // Cache the community on Alpha\n  let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;\n  let alphaCommunity = await resolveCommunity(alpha, searchShort);\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n  assertCommunityFederation(alphaCommunity, communityRes.community_view);\n\n  // Follow the community from alpha\n  let follow = await followCommunity(alpha, true, alphaCommunity.community.id);\n\n  // Make sure the follow response went through\n  expect(follow.community_view.community.local).toBe(false);\n\n  let deleteCommunityRes = await deleteCommunity(\n    beta,\n    true,\n    communityRes.community_view.community.id,\n  );\n  expect(deleteCommunityRes.community_view.community.deleted).toBe(true);\n  expect(deleteCommunityRes.community_view.community.title).toBe(\n    communityRes.community_view.community.title,\n  );\n\n  // Make sure it got deleted on A\n  let communityOnAlphaDeleted = await waitUntil(\n    () => getCommunity(alpha, alphaCommunity!.community.id),\n    g => g.community_view.community.deleted,\n  );\n  expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true);\n\n  // Undelete\n  let undeleteCommunityRes = await deleteCommunity(\n    beta,\n    false,\n    communityRes.community_view.community.id,\n  );\n  expect(undeleteCommunityRes.community_view.community.deleted).toBe(false);\n\n  // Make sure it got undeleted on A\n  let communityOnAlphaUnDeleted = await waitUntil(\n    () => getCommunity(alpha, alphaCommunity!.community.id),\n    g => !g.community_view.community.deleted,\n  );\n  expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe(\n    false,\n  );\n});\n\ntest(\"Remove community\", async () => {\n  let communityRes = await createCommunity(beta);\n\n  // Cache the community on Alpha\n  let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;\n  let alphaCommunity = await resolveCommunity(alpha, searchShort);\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n  assertCommunityFederation(alphaCommunity, communityRes.community_view);\n\n  // Follow the community from alpha\n  let follow = await followCommunity(alpha, true, alphaCommunity.community.id);\n\n  // Make sure the follow response went through\n  expect(follow.community_view.community.local).toBe(false);\n\n  let removeCommunityRes = await removeCommunity(\n    beta,\n    true,\n    communityRes.community_view.community.id,\n  );\n  expect(removeCommunityRes.community_view.community.removed).toBe(true);\n  expect(removeCommunityRes.community_view.community.title).toBe(\n    communityRes.community_view.community.title,\n  );\n\n  // Make sure it got Removed on A\n  let communityOnAlphaRemoved = await waitUntil(\n    () => getCommunity(alpha, alphaCommunity!.community.id),\n    g => g.community_view.community.removed,\n  );\n  expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true);\n\n  // unremove\n  let unremoveCommunityRes = await removeCommunity(\n    beta,\n    false,\n    communityRes.community_view.community.id,\n  );\n  expect(unremoveCommunityRes.community_view.community.removed).toBe(false);\n\n  // Make sure it got undeleted on A\n  let communityOnAlphaUnRemoved = await waitUntil(\n    () => getCommunity(alpha, alphaCommunity!.community.id),\n    g => !g.community_view.community.removed,\n  );\n  expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe(\n    false,\n  );\n});\n\ntest(\"Report a community\", async () => {\n  // Create community on alpha\n  let alphaCommunity = await createCommunity(alpha);\n  expect(alphaCommunity.community_view.community).toBeDefined();\n\n  // Send report from beta\n  let betaCommunity = await resolveCommunity(\n    beta,\n    alphaCommunity.community_view.community.ap_id,\n  );\n  let betaReport = (\n    await reportCommunity(beta, betaCommunity!.community.id, randomString(10))\n  ).community_report_view.community_report;\n  expect(betaReport).toBeDefined();\n\n  // Report was federated to alpha\n  let alphaReport = (\n    (await waitUntil(\n      () =>\n        listReports(alpha).then(p =>\n          p.items.find(r => {\n            return checkCommunityReportName(r, betaReport);\n          }),\n        ),\n      res => !!res,\n    ))! as CommunityReportView\n  ).community_report;\n  expect(alphaReport).toBeDefined();\n  expect(alphaReport.resolved).toBe(false);\n  expect(alphaReport.original_community_name).toBe(\n    betaReport.original_community_name,\n  );\n  expect(alphaReport.original_community_title).toBe(\n    betaReport.original_community_title,\n  );\n  expect(alphaReport.original_community_banner).toBe(\n    betaReport.original_community_banner,\n  );\n  expect(alphaReport.original_community_sidebar).toBe(\n    betaReport.original_community_sidebar,\n  );\n  expect(alphaReport.original_community_icon).toBe(\n    betaReport.original_community_icon,\n  );\n  expect(alphaReport.original_community_sidebar).toBe(\n    betaReport.original_community_sidebar,\n  );\n  expect(alphaReport.reason).toBe(betaReport.reason);\n\n  // Resolve report as admin of the community's instance\n  let resolveParams: ResolveCommunityReport = {\n    report_id: alphaReport.id,\n    resolved: true,\n  };\n  let resolve = await alpha.resolveCommunityReport(resolveParams);\n  expect(resolve.community_report_view.community_report.resolved).toBeTruthy();\n\n  // Report should be marked resolved on reporter's instance\n  let resolvedReport = (\n    (await waitUntil(\n      () =>\n        listReports(beta).then(p =>\n          p.items.find(r => {\n            return (\n              checkCommunityReportName(r, alphaReport) && r.resolver != null\n            );\n          }),\n        ),\n      res => !!res,\n    ))! as CommunityReportView\n  ).community_report;\n  expect(resolvedReport).toBeDefined();\n  expect(resolvedReport.resolved).toBe(true);\n});\n\ntest(\"Search for beta community\", async () => {\n  let communityRes = await createCommunity(beta);\n  expect(communityRes.community_view.community.name).toBeDefined();\n\n  let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;\n  let alphaCommunity = await resolveCommunity(alpha, searchShort);\n  assertCommunityFederation(alphaCommunity, communityRes.community_view);\n});\n\ntest(\"Admin actions in remote community are not federated to origin\", async () => {\n  // create a community on alpha\n  let communityRes = (await createCommunity(alpha)).community_view;\n  expect(communityRes.community.name).toBeDefined();\n\n  // gamma follows community and posts in it\n  let gammaCommunity = await resolveCommunity(\n    gamma,\n    communityRes.community.ap_id,\n  );\n  if (!gammaCommunity) {\n    throw \"Missing gamma community\";\n  }\n  await followCommunity(gamma, true, gammaCommunity.community.id);\n  gammaCommunity = await waitUntil(\n    () => resolveCommunity(gamma, communityRes.community.ap_id),\n    g => g?.community_actions?.follow_state == \"accepted\",\n  );\n  if (!gammaCommunity) {\n    throw \"Missing gamma community\";\n  }\n  expect(gammaCommunity.community_actions?.follow_state).toBe(\"accepted\");\n  let gammaPost = (await createPost(gamma, gammaCommunity.community.id))\n    .post_view;\n  expect(gammaPost.post.id).toBeDefined();\n  expect(gammaPost.creator_banned_from_community).toBe(false);\n\n  // admin of beta decides to ban gamma from community\n  let betaCommunity = await resolveCommunity(\n    beta,\n    communityRes.community.ap_id,\n  );\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let bannedUserInfo1 = (await getMyUser(gamma)).local_user_view.person;\n  if (!bannedUserInfo1) {\n    throw \"Missing banned user 1\";\n  }\n  let bannedUserInfo2 = await resolvePerson(beta, bannedUserInfo1.ap_id);\n\n  if (!bannedUserInfo2) {\n    throw \"Missing banned user 2\";\n  }\n  let banRes = await banPersonFromCommunity(\n    beta,\n    bannedUserInfo2.person.id,\n    betaCommunity.community.id,\n    true,\n    true,\n  );\n  expect(banRes).toBeDefined();\n\n  // ban doesn't federate to community's origin instance alpha\n  let alphaPost = await resolvePost(alpha, gammaPost.post);\n  expect(alphaPost?.creator_banned_from_community).toBe(false);\n\n  // and neither to gamma\n  let gammaPost2 = await getPost(gamma, gammaPost.post.id);\n  expect(gammaPost2.post_view.creator_banned_from_community).toBe(false);\n});\n\ntest(\"moderator view\", async () => {\n  // register a new user with their own community on alpha and post to it\n  let otherUser = await registerUser(alpha, alphaUrl);\n\n  let otherCommunity = (await createCommunity(otherUser)).community_view;\n  expect(otherCommunity.community.name).toBeDefined();\n  let otherPost = (await createPost(otherUser, otherCommunity.community.id))\n    .post_view;\n  expect(otherPost.post.id).toBeDefined();\n\n  let otherComment = (await createComment(otherUser, otherPost.post.id))\n    .comment_view;\n  expect(otherComment.comment.id).toBeDefined();\n\n  // create a community and post on alpha\n  let alphaCommunity = (await createCommunity(alpha)).community_view;\n  expect(alphaCommunity.community.name).toBeDefined();\n  let alphaPost = (await createPost(alpha, alphaCommunity.community.id))\n    .post_view;\n  expect(alphaPost.post.id).toBeDefined();\n\n  let alphaComment = (await createComment(otherUser, alphaPost.post.id))\n    .comment_view;\n  expect(alphaComment.comment.id).toBeDefined();\n\n  // other user also posts on alpha's community\n  let otherAlphaPost = (\n    await createPost(otherUser, alphaCommunity.community.id)\n  ).post_view;\n  expect(otherAlphaPost.post.id).toBeDefined();\n\n  let otherAlphaComment = (\n    await createComment(otherUser, otherAlphaPost.post.id)\n  ).comment_view;\n  expect(otherAlphaComment.comment.id).toBeDefined();\n\n  // alpha lists posts and comments on home page, should contain all posts that were made\n  let posts = (await getPosts(alpha, \"all\")).items;\n  expect(posts).toBeDefined();\n  let postIds = posts.map(post => post.post.id);\n\n  let comments = (await getComments(alpha, undefined, \"all\")).items;\n  expect(comments).toBeDefined();\n  let commentIds = comments.map(comment => comment.comment.id);\n\n  expect(postIds).toContain(otherPost.post.id);\n  expect(commentIds).toContain(otherComment.comment.id);\n\n  expect(postIds).toContain(alphaPost.post.id);\n  expect(commentIds).toContain(alphaComment.comment.id);\n\n  expect(postIds).toContain(otherAlphaPost.post.id);\n  expect(commentIds).toContain(otherAlphaComment.comment.id);\n\n  // in moderator view, alpha should not see otherPost, wich was posted on a community alpha doesn't moderate\n  posts = (await getPosts(alpha, \"moderator_view\")).items;\n  expect(posts).toBeDefined();\n  postIds = posts.map(post => post.post.id);\n\n  comments = (await getComments(alpha, undefined, \"moderator_view\")).items;\n  expect(comments).toBeDefined();\n  commentIds = comments.map(comment => comment.comment.id);\n\n  expect(postIds).not.toContain(otherPost.post.id);\n  expect(commentIds).not.toContain(otherComment.comment.id);\n\n  expect(postIds).toContain(alphaPost.post.id);\n  expect(commentIds).toContain(alphaComment.comment.id);\n\n  expect(postIds).toContain(otherAlphaPost.post.id);\n  expect(commentIds).toContain(otherAlphaComment.comment.id);\n});\n\ntest(\"Get community for different casing on domain\", async () => {\n  let communityRes = await createCommunity(alpha);\n  expect(communityRes.community_view.community.name).toBeDefined();\n\n  // A dupe check\n  let prevName = communityRes.community_view.community.name;\n  await jestLemmyError(\n    () => createCommunity(alpha, prevName),\n    new LemmyError(\"already_exists\", statusBadRequest),\n  );\n\n  // Cache the community on beta, make sure it has the other fields\n  let communityName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;\n  let betaCommunity = (await getCommunityByName(beta, communityName))\n    .community_view;\n  assertCommunityFederation(betaCommunity, communityRes.community_view);\n});\n\ntest(\"User blocks instance, communities are hidden\", async () => {\n  // create community and post on beta\n  let communityRes = await createCommunity(beta);\n  expect(communityRes.community_view.community.name).toBeDefined();\n  let postRes = await createPost(\n    beta,\n    communityRes.community_view.community.id,\n  );\n  expect(postRes.post_view.post.id).toBeDefined();\n\n  // fetch post to alpha\n  let alphaPost = await resolvePost(alpha, postRes.post_view.post);\n  expect(alphaPost?.post).toBeDefined();\n\n  // post should be included in listing\n  let listing = await getPosts(alpha, \"all\");\n  let listing_ids = listing.items.map(p => p.post.ap_id);\n  expect(listing_ids).toContain(postRes.post_view.post.ap_id);\n\n  // block the beta instance\n  await userBlockInstanceCommunities(\n    alpha,\n    alphaPost!.community.instance_id,\n    true,\n  );\n\n  // after blocking, post should not be in listing\n  let listing2 = await getPosts(alpha, \"all\");\n  let listing_ids2 = listing2.items.map(p => p.post.ap_id);\n  expect(listing_ids2.indexOf(postRes.post_view.post.ap_id)).toBe(-1);\n\n  // unblock instance again\n  await userBlockInstanceCommunities(\n    alpha,\n    alphaPost!.community.instance_id,\n    false,\n  );\n\n  // post should be included in listing\n  let listing3 = await getPosts(alpha, \"all\");\n  let listing_ids3 = listing3.items.map(p => p.post.ap_id);\n  expect(listing_ids3).toContain(postRes.post_view.post.ap_id);\n});\n\n// TODO: this test keeps failing randomly in CI\ntest.skip(\"Community follower count is federated\", async () => {\n  // Follow the beta community from alpha\n  let community = await createCommunity(beta);\n  let communityActorId = community.community_view.community.ap_id;\n  let resolved = await resolveCommunity(alpha, communityActorId);\n  if (!resolved?.community) {\n    throw \"Missing beta community\";\n  }\n\n  await followCommunity(alpha, true, resolved.community.id);\n  let followed = await waitUntil(\n    () => resolveCommunity(alpha, communityActorId),\n    c => c?.community_actions?.follow_state == \"accepted\",\n  );\n\n  // Make sure there is 1 subscriber\n  expect(followed?.community.subscribers).toBe(1);\n\n  // Follow the community from gamma\n  resolved = await resolveCommunity(gamma, communityActorId);\n  if (!resolved?.community) {\n    throw \"Missing beta community\";\n  }\n\n  await followCommunity(gamma, true, resolved.community.id);\n  followed = await waitUntil(\n    () => resolveCommunity(gamma, communityActorId),\n    c => c?.community_actions?.follow_state == \"accepted\",\n  );\n\n  // Make sure there are 2 subscribers\n  expect(followed?.community?.subscribers).toBe(2);\n\n  // Follow the community from delta\n  resolved = await resolveCommunity(delta, communityActorId);\n  if (!resolved?.community) {\n    throw \"Missing beta community\";\n  }\n\n  await followCommunity(delta, true, resolved.community.id);\n  followed = await waitUntil(\n    () => resolveCommunity(delta, communityActorId),\n    c => c?.community_actions?.follow_state == \"accepted\",\n  );\n});\n\ntest(\"Dont receive community activities after unsubscribe\", async () => {\n  let communityRes = await createCommunity(alpha);\n  expect(communityRes.community_view.community.name).toBeDefined();\n  expect(communityRes.community_view.community.subscribers).toBe(1);\n\n  let betaCommunity = await resolveCommunity(\n    beta,\n    communityRes.community_view.community.ap_id,\n  );\n  assertCommunityFederation(betaCommunity, communityRes.community_view);\n\n  // follow alpha community from beta\n  await followCommunity(beta, true, betaCommunity!.community.id);\n\n  // ensure that follower count was updated\n  let communityRes1 = await getCommunity(\n    alpha,\n    communityRes.community_view.community.id,\n  );\n  expect(communityRes1.community_view.community.subscribers).toBe(2);\n\n  // temporarily block alpha, so that it doesn't know about unfollow\n  let allow_instance_params: AdminAllowInstanceParams = {\n    instance: \"lemmy-alpha\",\n    allow: false,\n    reason: \"allow\",\n  };\n  await beta.adminAllowInstance(allow_instance_params);\n\n  // unfollow\n  await followCommunity(beta, false, betaCommunity!.community.id);\n\n  // ensure that alpha still sees beta as follower\n  let communityRes2 = await getCommunity(\n    alpha,\n    communityRes.community_view.community.id,\n  );\n  expect(communityRes2.community_view.community.subscribers).toBe(2);\n\n  // unblock alpha\n  allow_instance_params.allow = true;\n  await beta.adminAllowInstance(allow_instance_params);\n\n  // create a post, it shouldnt reach beta\n  let postRes = await createPost(\n    alpha,\n    communityRes.community_view.community.id,\n  );\n  expect(postRes.post_view.post.id).toBeDefined();\n  // await longDelay();\n\n  let form: Search = {\n    q: postRes.post_view.post.name,\n    type_: \"posts\",\n    listing_type: \"all\",\n  };\n\n  let res = await beta.search(form);\n  expect(res.search.length).toBe(0);\n});\n\ntest(\"Fetch community, includes posts\", async () => {\n  let communityRes = await createCommunity(alpha);\n  expect(communityRes.community_view.community.name).toBeDefined();\n  expect(communityRes.community_view.community.subscribers).toBe(1);\n\n  let postRes = await createPost(\n    alpha,\n    communityRes.community_view.community.id,\n  );\n  expect(postRes.post_view.post).toBeDefined();\n\n  let resolvedCommunity = await waitUntil(\n    () => resolveCommunity(beta, communityRes.community_view.community.ap_id),\n    c => c?.community.id != undefined,\n  );\n  let betaCommunity = resolvedCommunity;\n  expect(betaCommunity?.community.ap_id).toBe(\n    communityRes.community_view.community.ap_id,\n  );\n\n  let post_listing = await waitUntil(\n    () => getPosts(beta, \"all\", betaCommunity?.community.id),\n    p => p.items.length == 1,\n  );\n  expect(post_listing.items[0].post.ap_id).toBe(postRes.post_view.post.ap_id);\n});\n\ntest(\"Content in local-only community doesn't federate\", async () => {\n  // create a community and set it local-only\n  let communityRes = (await createCommunity(alpha)).community_view.community;\n  let form: EditCommunity = {\n    community_id: communityRes.id,\n    visibility: \"local_only_public\",\n  };\n  await editCommunity(alpha, form);\n\n  // cant resolve the community from another instance\n  await jestLemmyError(\n    () => resolveCommunity(beta, communityRes.ap_id),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest),\n    false,\n  );\n\n  // create a post, also cant resolve it\n  let postRes = await createPost(alpha, communityRes.id);\n  await jestLemmyError(\n    () => resolvePost(beta, postRes.post_view.post),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest),\n    false,\n  );\n});\n\ntest(\"Remote mods can edit communities\", async () => {\n  let communityRes = await createCommunity(alpha);\n\n  let betaCommunity = await resolveCommunity(\n    beta,\n    communityRes.community_view.community.ap_id,\n  );\n  if (!betaCommunity?.community) {\n    throw \"Missing beta community\";\n  }\n  let betaOnAlpha = await resolvePerson(alpha, \"lemmy_beta@lemmy-beta:8551\");\n\n  let form: AddModToCommunity = {\n    community_id: communityRes.community_view.community.id,\n    person_id: betaOnAlpha?.person.id as number,\n    added: true,\n  };\n  alpha.addModToCommunity(form);\n\n  let form2: EditCommunity = {\n    community_id: betaCommunity.community.id as number,\n    sidebar: \"Example sidebar\",\n  };\n\n  await editCommunity(beta, form2);\n\n  const communityId = communityRes.community_view.community.id;\n  await waitUntil(\n    () => getCommunity(alpha, communityId),\n    c => c.community_view.community.sidebar == \"Example sidebar\",\n  );\n});\n\ntest(\"Remote mods can add mods\", async () => {\n  let alphaCommunity = await createCommunity(alpha);\n\n  let betaCommunity = await resolveCommunity(\n    beta,\n    alphaCommunity.community_view.community.ap_id,\n  );\n  if (!betaCommunity?.community) {\n    throw \"Missing beta community\";\n  }\n  let betaOnAlpha = await resolvePerson(alpha, \"lemmy_beta@lemmy-beta:8551\");\n  let gammaOnBeta = await resolvePerson(beta, \"lemmy_gamma@lemmy-gamma:8561\");\n\n  // Follow so we get activities\n  await followCommunity(beta, true, betaCommunity.community.id);\n\n  let form: AddModToCommunity = {\n    community_id: alphaCommunity.community_view.community.id,\n    person_id: betaOnAlpha?.person.id as number,\n    added: true,\n  };\n  await alpha.addModToCommunity(form);\n\n  await waitUntil(\n    () => getCommunity(beta, betaCommunity.community.id),\n    c => c.moderators.length == 2,\n  );\n\n  let form2: AddModToCommunity = {\n    community_id: betaCommunity.community.id,\n    person_id: gammaOnBeta?.person.id as number,\n    added: true,\n  };\n  await beta.addModToCommunity(form2);\n\n  await waitUntil(\n    () => getCommunity(beta, betaCommunity.community.id),\n    c => c.moderators.length == 3,\n  );\n\n  await waitUntil(\n    () => getCommunity(alpha, alphaCommunity.community_view.community.id),\n    c => c.moderators.length == 3,\n  );\n});\n\ntest(\"Community name with non-ascii chars\", async () => {\n  const name = \"това_ме_ядосва\" + Math.random().toString().slice(2, 6);\n  let communityRes = await createCommunity(alpha, name);\n\n  let betaCommunity1 = await resolveCommunity(\n    beta,\n    communityRes.community_view.community.ap_id,\n  );\n  expect(betaCommunity1?.community.name).toBe(name);\n\n  let alphaCommunity2 = await getCommunityByName(alpha, name);\n  expect(alphaCommunity2.community_view.community.name).toBe(name);\n\n  let fediName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;\n  let betaCommunity2 = await getCommunityByName(beta, fediName);\n  expect(betaCommunity2.community_view.community.name).toBe(name);\n\n  let postRes = await createPost(beta, betaCommunity1!.community.id);\n\n  let form: GetPosts = {\n    community_name: fediName,\n  };\n  let posts = await beta.getPosts(form);\n  expect(posts.items.length).toBe(1);\n  expect(posts.items[0].post.name).toBe(postRes.post_view.post.name);\n});\n\ntest(\"Multi-community\", async () => {\n  // create multi\n  const multiName = randomString(10);\n  let res = await alpha.createMultiCommunity({ name: multiName });\n  let myUser = await getMyUser(alpha);\n  expect(res.multi_community_view.multi.name).toBe(multiName);\n  expect(res.multi_community_view.multi.ap_id).toBe(\n    `http://lemmy-alpha:8541/m/${multiName}`,\n  );\n  expect(res.multi_community_view.owner.id).toBe(\n    myUser.local_user_view.person.id,\n  );\n\n  // add initial community\n  let community1 = (await createCommunity(alpha)).community_view.community;\n  let entryRes = await alpha.createMultiCommunityEntry({\n    id: res.multi_community_view.multi.id,\n    community_id: community1.id,\n  });\n  expect(entryRes.community_view.community.id).toBe(community1.id);\n\n  // resolve over federation\n  let betaMulti = (\n    await beta.resolveObject({ q: res.multi_community_view.multi.ap_id })\n  ).resolve as MultiCommunityView;\n  expect(betaMulti.multi.ap_id).toBe(res.multi_community_view.multi.ap_id);\n\n  // follow multi over federation\n  let form: FollowMultiCommunity = {\n    multi_community_id: betaMulti.multi.id,\n    follow: true,\n  };\n  await beta.followMultiCommunity(form);\n\n  let betaRes = await waitUntil(\n    () => beta.getMultiCommunity({ id: betaMulti.multi.id }),\n    m => m.communities.length >= 1,\n  );\n  expect(betaRes.communities[0].community.ap_id).toBe(community1.ap_id);\n\n  let followed = await waitUntil(\n    () => beta.listMultiCommunities({}),\n    m => m.items.length >= 1,\n  );\n  expect(followed.items[0].multi.ap_id).toBe(betaMulti.multi.ap_id);\n\n  // add community to multi\n  let community2 = await waitUntil(\n    () => resolveBetaCommunity(alpha),\n    c => !!c?.community.instance_id,\n  );\n  if (!community2) {\n    throw \"Missing beta community\";\n  }\n\n  let entryRes2 = await alpha.createMultiCommunityEntry({\n    id: res.multi_community_view.multi.id,\n    community_id: community2!.community.id,\n  });\n  expect(entryRes2.community_view.community.id).toBe(community2.community.id);\n\n  // federated to beta\n  betaRes = await waitUntil(\n    () => beta.getMultiCommunity({ id: betaMulti.multi.id }),\n    m => m.communities.length >= 2,\n  );\n  let ap_ids = betaRes.communities.map(c => c.community.ap_id);\n  expect(ap_ids.includes(community2!.community.ap_id)).toBeTruthy();\n\n  let post = await createPost(alpha, community2!.community.id);\n\n  await waitUntil(\n    () =>\n      beta.getPosts({\n        multi_community_id: betaRes.multi_community_view.multi.id,\n      }),\n    p => p.items.map(p => p.post.ap_id).includes(post.post_view.post.ap_id),\n  );\n});\n\ntest(\"Mark existing community as local-only, ensure it federates\", async () => {\n  let communityRes = await createCommunity(alpha);\n  expect(communityRes.community_view.community.name).toBeDefined();\n\n  let community = communityRes.community_view.community;\n\n  let betaCommunity = await resolveCommunity(beta, community.ap_id);\n  assertCommunityFederation(betaCommunity, communityRes.community_view);\n\n  await followCommunity(beta, true, betaCommunity!.community.id);\n  await waitUntil(\n    () => getCommunity(beta, betaCommunity!.community.id),\n    g => g?.community_view.community_actions?.follow_state == \"accepted\",\n  );\n\n  let res = await editCommunity(alpha, {\n    community_id: community.id,\n    visibility: \"local_only_private\",\n  });\n  expect(res.community_view.community.visibility).toBe(\"local_only_private\");\n\n  await waitUntil(\n    () => getCommunity(beta, betaCommunity!.community.id),\n    g => g?.community_view.community?.deleted,\n  );\n\n  let res2 = await editCommunity(alpha, {\n    community_id: community.id,\n    visibility: \"public\",\n  });\n  expect(res2.community_view.community.visibility).toBe(\"public\");\n\n  await waitUntil(\n    () => getCommunity(beta, betaCommunity!.community.id),\n    g => !g?.community_view.community?.deleted,\n  );\n});\n\nfunction checkCommunityReportName(\n  rcv: ReportCombinedView,\n  report: CommunityReport,\n) {\n  switch (rcv.type_) {\n    case \"community\":\n      return (\n        rcv.community_report.original_community_name ===\n        report.original_community_name\n      );\n    default:\n      return false;\n  }\n}\n"
  },
  {
    "path": "api_tests/src/follow.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport {\n  alpha,\n  setupLogins,\n  resolveBetaCommunity,\n  followCommunity,\n  waitUntil,\n  beta,\n  betaUrl,\n  registerUser,\n  unfollows,\n  getMyUser,\n  alphaUrl,\n} from \"./shared\";\n\nbeforeAll(setupLogins);\n\nafterAll(unfollows);\n\ntest(\"Follow local community\", async () => {\n  let user = await registerUser(beta, betaUrl);\n\n  let community = await resolveBetaCommunity(user);\n  let follow = await followCommunity(user, true, community!.community.id);\n\n  // Make sure the follow response went through\n  expect(follow.community_view.community.local).toBe(true);\n  expect(follow.community_view.community_actions?.follow_state).toBe(\n    \"accepted\",\n  );\n  expect(follow.community_view.community.subscribers).toBe(\n    community!.community.subscribers + 1,\n  );\n  expect(follow.community_view.community.subscribers_local).toBe(\n    community!.community.subscribers_local + 1,\n  );\n\n  // Test an unfollow\n  let unfollow = await followCommunity(user, false, community!.community.id);\n  expect(\n    unfollow.community_view.community_actions?.follow_state,\n  ).toBeUndefined();\n  expect(unfollow.community_view.community.subscribers).toBe(\n    community?.community.subscribers,\n  );\n  expect(unfollow.community_view.community.subscribers_local).toBe(\n    community?.community.subscribers_local,\n  );\n});\n\ntest(\"Follow federated community\", async () => {\n  let user = await registerUser(alpha, alphaUrl);\n\n  const betaCommunityInitial = await waitUntil(\n    () => resolveBetaCommunity(user),\n    c => !!c?.community && c.community.subscribers >= 1,\n  );\n  if (!betaCommunityInitial) {\n    throw \"Missing beta community\";\n  }\n\n  let follow = await followCommunity(\n    user,\n    true,\n    betaCommunityInitial.community.id,\n  );\n  expect(follow.community_view.community_actions?.follow_state).toBe(\"pending\");\n  const betaCommunity = await waitUntil(\n    () => resolveBetaCommunity(user),\n    c => c?.community_actions?.follow_state === \"accepted\",\n  );\n\n  // Make sure the follow response went through\n  expect(betaCommunity?.community.local).toBe(false);\n  expect(betaCommunity?.community.name).toBe(\"main\");\n  expect(betaCommunity?.community_actions?.follow_state).toBe(\"accepted\");\n  expect(betaCommunity?.community.subscribers_local).toBe(\n    betaCommunityInitial.community.subscribers_local + 1,\n  );\n\n  // check that unfollow was federated\n  let communityOnBeta1 = await resolveBetaCommunity(beta);\n  expect(communityOnBeta1?.community.subscribers).toBe(\n    betaCommunityInitial.community.subscribers + 1,\n  );\n\n  // Check it from local\n  let my_user = await getMyUser(user);\n  let remoteCommunityId = my_user?.follows.find(\n    c =>\n      c.community.local == false &&\n      c.community.id === betaCommunityInitial.community.id,\n  )?.community.id;\n  expect(remoteCommunityId).toBeDefined();\n\n  if (!remoteCommunityId) {\n    throw \"Missing remote community id\";\n  }\n\n  // Test an unfollow\n  let unfollow = await followCommunity(user, false, remoteCommunityId);\n  expect(\n    unfollow.community_view.community_actions?.follow_state,\n  ).toBeUndefined();\n\n  // Make sure you are unsubbed locally\n  let siteUnfollowCheck = await getMyUser(user);\n  expect(\n    siteUnfollowCheck.follows.find(\n      c => c.community.id === betaCommunityInitial.community.id,\n    ),\n  ).toBe(undefined);\n\n  // check that unfollow was federated\n  let communityOnBeta2 = await waitUntil(\n    () => resolveBetaCommunity(beta),\n    c =>\n      c?.community.subscribers === betaCommunityInitial.community.subscribers,\n  );\n  expect(communityOnBeta2?.community.subscribers).toBe(\n    betaCommunityInitial.community.subscribers,\n  );\n  expect(communityOnBeta2?.community.subscribers_local).toBe(1);\n});\n"
  },
  {
    "path": "api_tests/src/image.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport {\n  UploadImage,\n  PurgePerson,\n  PurgePost,\n  DeleteImageParams,\n} from \"lemmy-js-client\";\nimport {\n  alpha,\n  alphaImage,\n  alphaUrl,\n  beta,\n  betaUrl,\n  createCommunity,\n  createPost,\n  deleteAllMedia,\n  epsilon,\n  followCommunity,\n  gamma,\n  imageFetchLimit,\n  registerUser,\n  resolveBetaCommunity,\n  resolveCommunity,\n  resolvePost,\n  setupLogins,\n  waitForPost,\n  unfollows,\n  getPost,\n  waitUntil,\n  createPostWithThumbnail,\n  sampleImage,\n  sampleSite,\n  getMyUser,\n} from \"./shared\";\n\nbeforeAll(setupLogins);\n\nafterAll(async () => {\n  await Promise.allSettled([unfollows(), deleteAllMedia(alpha)]);\n});\n\ntest(\"Upload image and delete it\", async () => {\n  const health = await alpha.imageHealth();\n  expect(health.success).toBeTruthy();\n\n  // Upload test image. We use a simple string buffer as pictrs doesn't require an actual image\n  // in testing mode.\n  const upload_form: UploadImage = {\n    image: Buffer.from(\"test\"),\n  };\n  const upload = await alphaImage.uploadImage(upload_form);\n  expect(upload.image_url).toBeDefined();\n  expect(upload.filename).toBeDefined();\n\n  // ensure that image download is working. theres probably a better way to do this\n  const response = await fetch(upload.image_url ?? \"\");\n  const content = await response.text();\n  expect(content.length).toBeGreaterThan(0);\n\n  // Ensure that it comes back with the list_media endpoint\n  const listMediaRes = await alphaImage.listMedia();\n  expect(listMediaRes.items.length).toBe(1);\n\n  // Ensure that it also comes back with the admin all images\n  const listMediaAdminRes = await alpha.listMediaAdmin({\n    limit: imageFetchLimit,\n  });\n\n  // This number comes from all the previous thumbnails fetched in other tests.\n  const previousThumbnails = 1;\n  expect(listMediaAdminRes.items.length).toBe(previousThumbnails);\n\n  // Make sure the uploader is correct\n  expect(listMediaRes.items[0].person.ap_id).toBe(\n    `http://lemmy-alpha:8541/u/lemmy_alpha`,\n  );\n\n  // delete image\n  const delete_form: DeleteImageParams = {\n    filename: upload.filename,\n  };\n  const delete_ = await alphaImage.deleteMedia(delete_form);\n  expect(delete_.success).toBe(true);\n\n  // ensure that image is deleted\n  const response2 = await fetch(upload.image_url ?? \"\");\n  const content2 = await response2.text();\n  expect(content2).toBe(\"\");\n\n  // Ensure that it shows the image is deleted\n  const deletedListMediaRes = await alphaImage.listMedia();\n  expect(deletedListMediaRes.items.length).toBe(0);\n\n  // Ensure that the admin shows its deleted\n  const deletedListAllMediaRes = await alphaImage.listMediaAdmin({\n    limit: imageFetchLimit,\n  });\n  expect(deletedListAllMediaRes.items.length).toBe(previousThumbnails - 1);\n});\n\ntest(\"Purge user, uploaded image removed\", async () => {\n  let user = await registerUser(alphaImage, alphaUrl);\n\n  // upload test image\n  const upload_form: UploadImage = {\n    image: Buffer.from(\"test\"),\n  };\n  const upload = await user.uploadImage(upload_form);\n  expect(upload.filename).toBeDefined();\n  expect(upload.image_url).toBeDefined();\n\n  // ensure that image download is working. theres probably a better way to do this\n  const response = await fetch(upload.image_url ?? \"\");\n  const content = await response.text();\n  expect(content.length).toBeGreaterThan(0);\n\n  // purge user\n  let my_user = await getMyUser(user);\n  const purgeForm: PurgePerson = {\n    person_id: my_user.local_user_view.person.id,\n    reason: \"purge\",\n  };\n  const delete_ = await alphaImage.purgePerson(purgeForm);\n  expect(delete_.success).toBe(true);\n\n  // ensure that image is deleted\n  const response2 = await fetch(upload.image_url ?? \"\");\n  const content2 = await response2.text();\n  expect(content2).toBe(\"\");\n});\n\ntest(\"Purge post, linked image removed\", async () => {\n  let user = await registerUser(beta, betaUrl);\n\n  // upload test image\n  const upload_form: UploadImage = {\n    image: Buffer.from(\"test\"),\n  };\n  const upload = await user.uploadImage(upload_form);\n  expect(upload.filename).toBeDefined();\n  expect(upload.image_url).toBeDefined();\n\n  // ensure that image download is working. theres probably a better way to do this\n  const response = await fetch(upload.image_url ?? \"\");\n  const content = await response.text();\n  expect(content.length).toBeGreaterThan(0);\n\n  let community = await resolveBetaCommunity(user);\n  let post = await createPost(user, community!.community.id, upload.image_url);\n  expect(post.post_view.post.url).toBe(upload.image_url);\n  expect(post.post_view.image_details).toBeDefined();\n\n  // purge post\n  const purgeForm: PurgePost = {\n    post_id: post.post_view.post.id,\n    reason: \"purge\",\n  };\n  const delete_ = await beta.purgePost(purgeForm);\n  expect(delete_.success).toBe(true);\n\n  // ensure that image is deleted\n  const response2 = await fetch(upload.image_url ?? \"\");\n  const content2 = await response2.text();\n  expect(content2).toBe(\"\");\n});\n\ntest(\"Images in remote image post are proxied if setting enabled\", async () => {\n  let community = await createCommunity(gamma);\n  let postRes = await createPost(\n    gamma,\n    community.community_view.community.id,\n    sampleImage,\n    `![](${sampleImage})`,\n  );\n  const post = postRes.post_view.post;\n  expect(post).toBeDefined();\n\n  // Make sure it fetched the image details\n  expect(postRes.post_view.image_details).toBeDefined();\n\n  // remote image gets proxied after upload\n  expect(\n    post.thumbnail_url?.startsWith(\n      \"http://lemmy-gamma:8561/api/v4/image/proxy?url\",\n    ),\n  ).toBeTruthy();\n  expect(\n    post.body?.startsWith(\"![](http://lemmy-gamma:8561/api/v4/image/proxy?url\"),\n  ).toBeTruthy();\n\n  // Make sure that it contains `jpg`, to be sure its an image\n  expect(post.thumbnail_url?.includes(\".jpg\")).toBeTruthy();\n\n  let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);\n  expect(epsilonPostRes?.post).toBeDefined();\n\n  // Fetch the post again, the metadata should be backgrounded now\n  // Wait for the metadata to get fetched, since this is backgrounded now\n  let epsilonPostRes2 = await waitUntil(\n    () => getPost(epsilon, epsilonPostRes!.post.id),\n    p => p.post_view.post.thumbnail_url != undefined,\n  );\n  const epsilonPost = epsilonPostRes2.post_view.post;\n\n  expect(\n    epsilonPost.thumbnail_url?.startsWith(\n      \"http://lemmy-epsilon:8581/api/v4/image/proxy?url\",\n    ),\n  ).toBeTruthy();\n  expect(\n    epsilonPost.body?.startsWith(\n      \"![](http://lemmy-epsilon:8581/api/v4/image/proxy?url\",\n    ),\n  ).toBeTruthy();\n\n  // Make sure that it contains `jpg`, to be sure its an image\n  expect(epsilonPost.thumbnail_url?.includes(\".jpg\")).toBeTruthy();\n});\n\ntest(\"Thumbnail of remote image link is proxied if setting enabled\", async () => {\n  let community = await createCommunity(gamma);\n  let postRes = await createPost(\n    gamma,\n    community.community_view.community.id,\n    // The sample site metadata thumbnail ends in png\n    sampleSite,\n  );\n  const post = postRes.post_view.post;\n  expect(post).toBeDefined();\n\n  // remote image gets proxied after upload\n  expect(\n    post.thumbnail_url?.startsWith(\n      \"http://lemmy-gamma:8561/api/v4/image/proxy?url\",\n    ),\n  ).toBeTruthy();\n\n  // Make sure that it contains `png`, to be sure its an image\n  expect(post.thumbnail_url?.includes(\".png\")).toBeTruthy();\n\n  let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);\n  expect(epsilonPostRes?.post).toBeDefined();\n\n  let epsilonPostRes2 = await waitUntil(\n    () => getPost(epsilon, epsilonPostRes!.post.id),\n    p => p.post_view.post.thumbnail_url != undefined,\n  );\n  const epsilonPost = epsilonPostRes2.post_view.post;\n\n  expect(\n    epsilonPost.thumbnail_url?.startsWith(\n      \"http://lemmy-epsilon:8581/api/v4/image/proxy?url\",\n    ),\n  ).toBeTruthy();\n\n  // Make sure that it contains `png`, to be sure its an image\n  expect(epsilonPost.thumbnail_url?.includes(\".png\")).toBeTruthy();\n});\n\ntest(\"No image proxying if setting is disabled\", async () => {\n  let user = await registerUser(beta, betaUrl);\n  let community = await createCommunity(alpha);\n  let betaCommunity = await resolveCommunity(\n    beta,\n    community.community_view.community.ap_id,\n  );\n  await followCommunity(beta, true, betaCommunity!.community.id);\n\n  const upload_form: UploadImage = {\n    image: Buffer.from(\"test\"),\n  };\n  const upload = await user.uploadImage(upload_form);\n  let post = await createPost(\n    alpha,\n    community.community_view.community.id,\n    upload.image_url,\n    `![](${sampleImage})`,\n  );\n  expect(post.post_view.post).toBeDefined();\n\n  // remote image doesn't get proxied after upload\n  expect(\n    post.post_view.post.url?.startsWith(\"http://lemmy-beta:8551/api/v4/image/\"),\n  ).toBeTruthy();\n  expect(post.post_view.post.body).toBe(`![](${sampleImage})`);\n\n  let betaPost = await waitForPost(beta, post.post_view.post, res => {\n    return res?.post.alt_text != null;\n  });\n  expect(betaPost.post).toBeDefined();\n\n  // remote image doesn't get proxied after federation\n  expect(\n    betaPost.post.url?.startsWith(\"http://lemmy-beta:8551/api/v4/image/\"),\n  ).toBeTruthy();\n  expect(betaPost.post.body).toBe(`![](${sampleImage})`);\n  // Make sure the alt text got federated\n  expect(post.post_view.post.alt_text).toBe(betaPost.post.alt_text);\n});\n\ntest(\"Make regular post, and give it a custom thumbnail\", async () => {\n  const uploadForm1: UploadImage = {\n    image: Buffer.from(\"testRegular1\"),\n  };\n  const upload1 = await alphaImage.uploadImage(uploadForm1);\n\n  const community = await createCommunity(alphaImage);\n\n  // Use wikipedia since it has an opengraph image\n  const wikipediaUrl = \"https://wikipedia.org/\";\n\n  let post = await createPostWithThumbnail(\n    alphaImage,\n    community.community_view.community.id,\n    wikipediaUrl,\n    upload1.image_url!,\n  );\n\n  // Wait for the metadata to get fetched, since this is backgrounded now\n  post = await waitUntil(\n    () => getPost(alphaImage, post.post_view.post.id),\n    p => p.post_view.post.thumbnail_url != undefined,\n  );\n  expect(post.post_view.post.url).toBe(wikipediaUrl);\n  // Make sure it uses custom thumbnail\n  expect(post.post_view.post.thumbnail_url).toBe(upload1.image_url);\n});\n\ntest(\"Create an image post, and make sure a custom thumbnail doesn't overwrite it\", async () => {\n  const uploadForm1: UploadImage = {\n    image: Buffer.from(\"test1\"),\n  };\n  const upload1 = await alphaImage.uploadImage(uploadForm1);\n\n  const uploadForm2: UploadImage = {\n    image: Buffer.from(\"test2\"),\n  };\n  const upload2 = await alphaImage.uploadImage(uploadForm2);\n\n  const community = await createCommunity(alphaImage);\n\n  let post = await createPostWithThumbnail(\n    alphaImage,\n    community.community_view.community.id,\n    upload1.image_url!,\n    upload2.image_url!,\n  );\n  post = await waitUntil(\n    () => getPost(alphaImage, post.post_view.post.id),\n    p => p.post_view.post.thumbnail_url != undefined,\n  );\n  expect(post.post_view.post.url).toBe(upload1.image_url);\n  // Make sure the custom thumbnail is ignored\n  expect(post.post_view.post.thumbnail_url == upload2.image_url).toBe(false);\n});\n"
  },
  {
    "path": "api_tests/src/post.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport { CommunityView } from \"lemmy-js-client/dist/types/CommunityView\";\nimport {\n  alpha,\n  beta,\n  gamma,\n  delta,\n  epsilon,\n  setupLogins,\n  createPost,\n  editPost,\n  featurePost,\n  lockPost,\n  resolvePost,\n  likePost,\n  followBeta,\n  resolveBetaCommunity,\n  createComment,\n  deletePost,\n  removePost,\n  getPost,\n  unfollowRemotes,\n  resolvePerson,\n  banPersonFromSite,\n  followCommunity,\n  banPersonFromCommunity,\n  reportPost,\n  randomString,\n  registerUser,\n  unfollows,\n  resolveCommunity,\n  waitUntil,\n  waitForPost,\n  alphaUrl,\n  loginUser,\n  createCommunity,\n  listReports,\n  getMyUser,\n  listNotifications,\n  getModlog,\n  statusNotFound,\n  statusBadRequest,\n  getSite,\n  jestLemmyError,\n} from \"./shared\";\nimport { PostView } from \"lemmy-js-client/dist/types/PostView\";\nimport { AdminBlockInstanceParams } from \"lemmy-js-client/dist/types/AdminBlockInstanceParams\";\nimport {\n  AddModToCommunity,\n  EditSite,\n  EditPost,\n  PostReport,\n  PostReportView,\n  ReportCombinedView,\n  ResolveObject,\n  ResolvePostReport,\n  LemmyError,\n} from \"lemmy-js-client\";\n\nlet betaCommunity: CommunityView | undefined;\n\nbeforeAll(async () => {\n  await setupLogins();\n  betaCommunity = await resolveBetaCommunity(alpha);\n  expect(betaCommunity).toBeDefined();\n\n  // Hack: Force outgoing federation queue for beta to be created on epsilon,\n  // otherwise report test fails\n  let person = await resolvePerson(epsilon, \"@lemmy_beta@lemmy-beta:8551\");\n  expect(person?.person).toBeDefined();\n});\n\nafterAll(unfollows);\n\nasync function assertPostFederation(\n  postOne: PostView,\n  postTwo: PostView,\n  waitForMeta = true,\n) {\n  // Link metadata is generated in background task and may not be ready yet at this time,\n  // so wait for it explicitly. For removed posts we cant refetch anything.\n  if (waitForMeta) {\n    postOne = await waitForPost(beta, postOne.post, res => {\n      return res === null || !!res?.post.embed_title;\n    });\n    postTwo = await waitForPost(\n      beta,\n      postTwo.post,\n      res => res === null || !!res?.post.embed_title,\n    );\n  }\n\n  expect(postOne?.post.ap_id).toBe(postTwo?.post.ap_id);\n  expect(postOne?.post.name).toBe(postTwo?.post.name);\n  expect(postOne?.post.body).toBe(postTwo?.post.body);\n  // TODO url clears arent working\n  // expect(postOne?.post.url).toBe(postTwo?.post.url);\n  expect(postOne?.post.nsfw).toBe(postTwo?.post.nsfw);\n  expect(postOne?.post.embed_title).toBe(postTwo?.post.embed_title);\n  expect(postOne?.post.embed_description).toBe(postTwo?.post.embed_description);\n  expect(postOne?.post.embed_video_url).toBe(postTwo?.post.embed_video_url);\n  expect(postOne?.post.published_at).toBe(postTwo?.post.published_at);\n  expect(postOne?.community.ap_id).toBe(postTwo?.community.ap_id);\n  expect(postOne?.post.locked).toBe(postTwo?.post.locked);\n  expect(postOne?.post.removed).toBe(postTwo?.post.removed);\n  expect(postOne?.post.deleted).toBe(postTwo?.post.deleted);\n}\n\ntest(\"Create a post\", async () => {\n  // Block alpha\n  let block_instance_params: AdminBlockInstanceParams = {\n    instance: \"lemmy-alpha\",\n    block: true,\n    reason: \"block\",\n  };\n  await epsilon.adminBlockInstance(block_instance_params);\n\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  let postRes = await createPost(\n    alpha,\n    betaCommunity.community.id,\n    \"https://example.com/\",\n    \"აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი\",\n  );\n  expect(postRes.post_view.post).toBeDefined();\n  expect(postRes.post_view.community.local).toBe(false);\n  expect(postRes.post_view.creator.local).toBe(true);\n  expect(postRes.post_view.post.score).toBe(1);\n\n  // Make sure that post is liked on beta\n  const betaPost = await waitForPost(\n    beta,\n    postRes.post_view.post,\n    res => res?.post.score === 1,\n  );\n\n  expect(betaPost).toBeDefined();\n  expect(betaPost?.community.local).toBe(true);\n  expect(betaPost?.creator.local).toBe(false);\n  expect(betaPost?.post.score).toBe(1);\n  await assertPostFederation(betaPost, postRes.post_view);\n\n  // Delta only follows beta, so it should not see an alpha ap_id\n  await jestLemmyError(\n    () => resolvePost(delta, postRes.post_view.post),\n    new LemmyError(\n      \"resolve_object_failed\",\n      statusBadRequest,\n      'Domain \"lemmy-alpha\" is not in allowlist',\n    ),\n  );\n\n  // Epsilon has alpha blocked, it should not see the alpha post\n  await jestLemmyError(\n    () => resolvePost(epsilon, postRes.post_view.post),\n    new LemmyError(\n      \"resolve_object_failed\",\n      statusBadRequest,\n      'Domain \"lemmy-alpha\" is blocked',\n    ),\n  );\n\n  // remove blocked instance\n  block_instance_params.block = false;\n  await epsilon.adminBlockInstance(block_instance_params);\n});\n\ntest(\"Create a post in a non-existent community\", async () => {\n  await jestLemmyError(\n    () => createPost(alpha, -2),\n    new LemmyError(\"not_found\", statusNotFound),\n  );\n});\n\ntest(\"Unlike a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  let unlike = await likePost(alpha, undefined, postRes.post_view.post);\n  expect(unlike.post_view.post.score).toBe(0);\n\n  // Try to unlike it again, make sure it stays at 0\n  let unlike2 = await likePost(alpha, undefined, postRes.post_view.post);\n  expect(unlike2.post_view.post.score).toBe(0);\n\n  // Make sure that post is unliked on beta\n  const betaPost = await waitForPost(\n    beta,\n    postRes.post_view.post,\n    post => post?.post.score === 0,\n  );\n\n  expect(betaPost).toBeDefined();\n  expect(betaPost?.community.local).toBe(true);\n  expect(betaPost?.creator.local).toBe(false);\n  expect(betaPost?.post.score).toBe(0);\n  await assertPostFederation(betaPost, postRes.post_view);\n});\n\ntest(\"Update a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  let updatedName = \"A jest test federated post, updated\";\n  let updatedPost = await editPost(alpha, postRes.post_view.post);\n  expect(updatedPost.post_view.post.name).toBe(updatedName);\n  expect(updatedPost.post_view.community.local).toBe(false);\n  expect(updatedPost.post_view.creator.local).toBe(true);\n\n  // Make sure that post is updated on beta\n  let betaPost = await waitForPost(beta, updatedPost.post_view.post);\n  expect(betaPost.community.local).toBe(true);\n  expect(betaPost.creator.local).toBe(false);\n  expect(betaPost.post.name).toBe(updatedName);\n  await assertPostFederation(betaPost, updatedPost.post_view);\n\n  // Make sure lemmy beta cannot update the post\n  await jestLemmyError(\n    () => editPost(beta, betaPost.post),\n    new LemmyError(\"no_post_edit_allowed\", statusBadRequest),\n  );\n});\n\ntest(\"Sticky a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n\n  let betaPost1 = await waitForPost(beta, postRes.post_view.post);\n  if (!betaPost1) {\n    throw \"Missing beta post1\";\n  }\n  let stickiedPostRes = await featurePost(beta, true, betaPost1.post);\n  expect(stickiedPostRes.post_view.post.featured_community).toBe(true);\n\n  // Make sure that post is stickied on beta\n  let betaPost = await resolvePost(beta, postRes.post_view.post);\n  expect(betaPost?.community.local).toBe(true);\n  expect(betaPost?.creator.local).toBe(false);\n  expect(betaPost?.post.featured_community).toBe(true);\n\n  // Unsticky a post\n  let unstickiedPost = await featurePost(beta, false, betaPost1.post);\n  expect(unstickiedPost.post_view.post.featured_community).toBe(false);\n\n  // Make sure that post is unstickied on beta\n  let betaPost2 = await resolvePost(beta, postRes.post_view.post);\n  expect(betaPost2?.community.local).toBe(true);\n  expect(betaPost2?.creator.local).toBe(false);\n  expect(betaPost2?.post.featured_community).toBe(false);\n\n  // Make sure that gamma cannot sticky the post on beta\n  let gammaPost = await resolvePost(gamma, postRes.post_view.post);\n  if (!gammaPost) {\n    throw \"Missing gamma post\";\n  }\n  // This has been failing occasionally\n  await featurePost(gamma, true, gammaPost.post);\n  let betaPost3 = await resolvePost(beta, postRes.post_view.post);\n  // expect(gammaTrySticky.post_view.post.featured_community).toBe(true);\n  expect(betaPost3?.post.featured_community).toBe(false);\n});\n\ntest(\"Collection of featured posts gets federated\", async () => {\n  // create a new community and feature a post\n  let community = await createCommunity(alpha);\n  let post = await createPost(alpha, community.community_view.community.id);\n  let featuredPost = await featurePost(alpha, true, post.post_view.post);\n  expect(featuredPost.post_view.post.featured_community).toBe(true);\n\n  // fetch the community, ensure that post is also fetched and marked as featured\n  let betaCommunity = await resolveCommunity(\n    beta,\n    community.community_view.community.ap_id,\n  );\n  expect(betaCommunity).toBeDefined();\n\n  const betaPost = await waitForPost(\n    beta,\n    post.post_view.post,\n    post => post?.post.featured_community === true,\n  );\n  expect(betaPost).toBeDefined();\n});\n\ntest(\"Lock a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  await followCommunity(alpha, true, betaCommunity.community.id);\n  await waitUntil(\n    () => resolveBetaCommunity(alpha),\n    c => c?.community_actions?.follow_state == \"accepted\",\n  );\n\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  let betaPost1 = await waitForPost(beta, postRes.post_view.post);\n  // Lock the post\n  let lockedPostRes = await lockPost(beta, true, betaPost1.post);\n  expect(lockedPostRes.post_view.post.locked).toBe(true);\n\n  // Make sure that post is locked on alpha\n  let alphaPost1 = await waitForPost(\n    alpha,\n    postRes.post_view.post,\n    post => !!post && post.post.locked,\n  );\n\n  // Try to make a new comment there, on alpha. For this we need to create a normal\n  // user account because admins/mods can comment in locked posts.\n  let user = await registerUser(alpha, alphaUrl);\n  await jestLemmyError(\n    () => createComment(user, alphaPost1.post.id),\n    new LemmyError(\"locked\", statusBadRequest),\n  );\n\n  // Unlock a post\n  let unlockedPost = await lockPost(beta, false, betaPost1.post);\n  expect(unlockedPost.post_view.post.locked).toBe(false);\n\n  // Make sure that post is unlocked on alpha\n  let alphaPost2 = await waitForPost(\n    alpha,\n    postRes.post_view.post,\n    post => !!post && !post.post.locked,\n  );\n  expect(alphaPost2.community.local).toBe(false);\n  expect(alphaPost2.creator.local).toBe(true);\n  expect(alphaPost2.post.locked).toBe(false);\n\n  // Try to create a new comment, on alpha\n  let commentAlpha = await createComment(user, alphaPost1.post.id);\n  expect(commentAlpha).toBeDefined();\n});\n\ntest(\"Delete a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  expect(postRes.post_view.post).toBeDefined();\n\n  await waitForPost(beta, postRes.post_view.post, p => p?.post.id != undefined);\n\n  let deletedPost = await deletePost(alpha, true, postRes.post_view.post);\n  // Make sure lemmy alpha sees post is deleted\n  await waitUntil(\n    () => getPost(alpha, postRes.post_view.post.id),\n    p => p.post_view.post.deleted,\n  );\n  expect(deletedPost.post_view.post.name).toBe(postRes.post_view.post.name);\n\n  // Make sure lemmy beta sees post is deleted\n  // This will be undefined because of the tombstone\n  await waitForPost(beta, postRes.post_view.post, p => p?.post == undefined);\n\n  // Undelete\n  let undeletedPost = await deletePost(alpha, false, postRes.post_view.post);\n  await waitUntil(\n    () => getPost(alpha, postRes.post_view.post.id),\n    p => !p.post_view.post.deleted,\n  );\n\n  // Make sure lemmy beta sees post is undeleted\n  let betaPost2 = await waitForPost(\n    beta,\n    postRes.post_view.post,\n    p => !!p && !p.post.deleted,\n  );\n\n  if (!betaPost2) {\n    throw \"Missing beta post 2\";\n  }\n  expect(betaPost2.post.deleted).toBe(false);\n  await assertPostFederation(betaPost2, undeletedPost.post_view);\n\n  // Make sure lemmy beta cannot delete the post\n  await jestLemmyError(\n    () => deletePost(beta, true, betaPost2.post),\n    new LemmyError(\"no_post_edit_allowed\", statusBadRequest),\n  );\n});\n\ntest(\"Remove a post from admin and community on different instance\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  let gammaCommunity = (\n    await resolveCommunity(gamma, betaCommunity.community.ap_id)\n  )?.community;\n  if (!gammaCommunity) {\n    throw \"Missing gamma community\";\n  }\n  let postRes = await createPost(gamma, gammaCommunity.id);\n\n  let alphaPost = await resolvePost(alpha, postRes.post_view.post);\n  if (!alphaPost) {\n    throw \"Missing alpha post\";\n  }\n  let removedPost = await removePost(alpha, true, alphaPost.post);\n  expect(removedPost.post_view.post.removed).toBe(true);\n  expect(removedPost.post_view.post.name).toBe(postRes.post_view.post.name);\n\n  // Make sure lemmy beta sees post is NOT removed\n  let betaPost = await resolvePost(beta, postRes.post_view.post);\n  if (!betaPost) {\n    throw \"Missing beta post\";\n  }\n  expect(betaPost.post.removed).toBe(false);\n\n  // Undelete\n  let undeletedPost = await removePost(alpha, false, alphaPost.post);\n  expect(undeletedPost.post_view.post.removed).toBe(false);\n\n  // Make sure lemmy beta sees post is undeleted\n  let betaPost2 = await resolvePost(beta, postRes.post_view.post);\n  expect(betaPost2?.post.removed).toBe(false);\n  await assertPostFederation(betaPost2!, undeletedPost.post_view);\n});\n\ntest(\"Remove a post from admin and community on same instance\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  await followBeta(alpha);\n  let gammaCommunity = await resolveCommunity(\n    gamma,\n    betaCommunity.community.ap_id,\n  );\n  let postRes = await createPost(gamma, gammaCommunity!.community.id);\n  expect(postRes.post_view.post).toBeDefined();\n  // Get the id for beta\n  let betaPost = await waitForPost(beta, postRes.post_view.post);\n  expect(betaPost).toBeDefined();\n\n  let alphaPost0 = await waitForPost(alpha, postRes.post_view.post);\n  expect(alphaPost0).toBeDefined();\n\n  // The beta admin removes it (the community lives on beta)\n  let removePostRes = await removePost(beta, true, betaPost.post);\n  expect(removePostRes.post_view.post.removed).toBe(true);\n\n  // Make sure lemmy alpha sees post is removed\n  let alphaPost = await waitUntil(\n    () => getPost(alpha, alphaPost0.post.id),\n    p => p?.post_view.post.removed,\n  );\n  expect(alphaPost?.post_view.post.removed).toBe(true);\n  await assertPostFederation(\n    alphaPost.post_view,\n    removePostRes.post_view,\n    false,\n  );\n\n  // Undelete\n  let undeletedPost = await removePost(beta, false, betaPost.post);\n  expect(undeletedPost.post_view.post.removed).toBe(false);\n\n  // Make sure lemmy alpha sees post is undeleted\n  let alphaPost2 = await waitForPost(\n    alpha,\n    postRes.post_view.post,\n    p => !!p && !p.post.removed,\n  );\n  expect(alphaPost2.post.removed).toBe(false);\n  await assertPostFederation(alphaPost2, undeletedPost.post_view);\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Search for a post\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  await unfollowRemotes(alpha);\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  expect(postRes.post_view.post).toBeDefined();\n\n  let betaPost = await waitForPost(beta, postRes.post_view.post);\n  expect(betaPost?.post.name).toBeDefined();\n});\n\ntest(\"Enforce site ban federation for local user\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  // create a test user\n  let alphaUserHttp = await registerUser(alpha, alphaUrl);\n  let alphaUserPerson = (await getMyUser(alphaUserHttp)).local_user_view.person;\n  let alphaUserActorId = alphaUserPerson?.ap_id;\n  if (!alphaUserActorId) {\n    throw \"Missing alpha user actor id\";\n  }\n  expect(alphaUserActorId).toBeDefined();\n  await followBeta(alphaUserHttp);\n\n  let alphaPerson = await resolvePerson(alphaUserHttp, alphaUserActorId!);\n  if (!alphaPerson) {\n    throw \"Missing alpha person\";\n  }\n  expect(alphaPerson).toBeDefined();\n\n  // alpha makes post in beta community, it federates to beta instance\n  let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);\n  let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);\n\n  // ban alpha from its own instance\n  let banAlpha = await banPersonFromSite(\n    alpha,\n    alphaPerson.person.id,\n    true,\n    true,\n  );\n  expect(banAlpha.person_view.banned).toBe(true);\n\n  // alpha ban should be federated to beta\n  let alphaUserOnBeta1 = await waitUntil(\n    () => resolvePerson(beta, alphaUserActorId!),\n    res => res?.banned == true,\n  );\n  expect(alphaUserOnBeta1?.banned).toBe(true);\n\n  // existing alpha post should be removed on beta\n  let betaBanRes = await waitUntil(\n    () => getPost(beta, searchBeta1.post.id),\n    s => s.post_view.post.removed,\n  );\n  expect(betaBanRes.post_view.post.removed).toBe(true);\n\n  // Unban alpha\n  let unBanAlpha = await banPersonFromSite(\n    alpha,\n    alphaPerson.person.id,\n    false,\n    true,\n  );\n  expect(unBanAlpha.person_view.banned).toBe(false);\n\n  // existing alpha post should be restored on beta\n  betaBanRes = await waitUntil(\n    () => getPost(beta, searchBeta1.post.id),\n    s => !s.post_view.post.removed,\n  );\n  expect(betaBanRes.post_view.post.removed).toBe(false);\n\n  // Login gets invalidated by ban, need to login again\n  if (!alphaUserPerson) {\n    throw \"Missing alpha person\";\n  }\n  let newAlphaUserJwt = await loginUser(alpha, alphaUserPerson.name);\n  alphaUserHttp.setHeaders({\n    Authorization: \"Bearer \" + newAlphaUserJwt.jwt,\n  });\n  // alpha makes new post in beta community, it federates\n  let postRes2 = await createPost(alphaUserHttp, betaCommunity!.community.id);\n  await waitForPost(beta, postRes2.post_view.post);\n\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Enforce site ban federation for federated user\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  // create a test user\n  let alphaUserHttp = await registerUser(alpha, alphaUrl);\n  let alphaUserPerson = (await getMyUser(alphaUserHttp)).local_user_view.person;\n  let alphaUserActorId = alphaUserPerson?.ap_id;\n  if (!alphaUserActorId) {\n    throw \"Missing alpha user actor id\";\n  }\n  expect(alphaUserActorId).toBeDefined();\n  await followBeta(alphaUserHttp);\n\n  let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!);\n  expect(alphaUserOnBeta2?.banned).toBe(false);\n\n  if (!alphaUserOnBeta2?.person) {\n    throw \"Missing alpha person\";\n  }\n\n  // alpha makes post in beta community, it federates to beta instance\n  let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);\n  let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);\n  expect(searchBeta1.post).toBeDefined();\n\n  // Now ban and remove their data from beta\n  let banAlphaOnBeta = await banPersonFromSite(\n    beta,\n    alphaUserOnBeta2.person.id,\n    true,\n    true,\n  );\n  expect(banAlphaOnBeta.person_view.banned).toBe(true);\n\n  // existing alpha post should be removed on beta\n  let betaRemovedPost = await getPost(beta, searchBeta1.post.id);\n  expect(betaRemovedPost.post_view.post.removed).toBe(true);\n\n  // post should also be removed on alpha\n  let alphaRemovedPost = await waitUntil(\n    () => getPost(alpha, postRes1.post_view.post.id),\n    s => s.post_view.post.removed,\n  );\n  expect(alphaRemovedPost.post_view.post.removed).toBe(true);\n\n  // User should not be shown to be banned from alpha\n  let alphaPerson2 = (await getMyUser(alphaUserHttp)).local_user_view;\n  expect(alphaPerson2.banned).toBe(false);\n\n  // post to beta community is rejected\n  await jestLemmyError(\n    () => createPost(alphaUserHttp, betaCommunity!.community.id),\n    new LemmyError(\"site_ban\", statusBadRequest),\n  );\n\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Enforce community ban for federated user\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  await followBeta(alpha);\n  let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`;\n  let alphaPerson = await resolvePerson(beta, alphaShortname);\n  if (!alphaPerson) {\n    throw \"Missing alpha person\";\n  }\n  expect(alphaPerson).toBeDefined();\n\n  // make a post in beta, it goes through\n  let postRes1 = await createPost(alpha, betaCommunity.community.id);\n  let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);\n  expect(searchBeta1.post).toBeDefined();\n\n  // ban alpha from beta community\n  let banAlpha = await banPersonFromCommunity(\n    beta,\n    alphaPerson.person.id,\n    searchBeta1.community.id,\n    true,\n    true,\n  );\n  expect(banAlpha).toBeDefined();\n\n  // ensure that the post by alpha got removed\n  let removePostRes = await waitUntil(\n    () => getPost(alpha, postRes1.post_view.post.id),\n    s => s.post_view.post.removed,\n  );\n  expect(removePostRes.post_view.post.removed).toBe(true);\n  expect(removePostRes.post_view.creator_banned_from_community).toBe(true);\n  expect(\n    removePostRes.community_view.community_actions?.received_ban_at,\n  ).toBeDefined();\n\n  // Alpha tries to make post on beta, but it fails because of ban\n  await jestLemmyError(\n    () => createPost(alpha, betaCommunity!.community.id),\n    new LemmyError(\"person_is_banned_from_community\", statusBadRequest),\n  );\n\n  // Unban alpha\n  let unBanAlpha = await banPersonFromCommunity(\n    beta,\n    alphaPerson.person.id,\n    searchBeta1.community.id,\n    false,\n    false,\n  );\n  expect(unBanAlpha).toBeDefined();\n\n  // Check that unban was federated to alpha\n  await waitUntil(\n    () => getModlog(alpha),\n    m =>\n      m.items[0].modlog.kind == \"mod_ban_from_community\" &&\n      m.items[0].modlog.is_revert == true,\n  );\n\n  let postRes3 = await createPost(alpha, betaCommunity.community.id);\n  expect(postRes3.post_view.post).toBeDefined();\n  expect(postRes3.post_view.community.local).toBe(false);\n  expect(postRes3.post_view.creator.local).toBe(true);\n  expect(postRes3.post_view.post.score).toBe(1);\n\n  // Make sure that post makes it to beta community\n  let postRes4 = await waitForPost(beta, postRes3.post_view.post);\n  expect(postRes4.post).toBeDefined();\n  expect(postRes4.creator_banned).toBe(false);\n\n  await unfollowRemotes(alpha);\n});\n\ntest(\"A and G subscribe to B (center) A posts, it gets announced to G\", async () => {\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  await followBeta(alpha);\n\n  let postRes = await createPost(alpha, betaCommunity.community.id);\n  expect(postRes.post_view.post).toBeDefined();\n\n  let betaPost = await resolvePost(gamma, postRes.post_view.post);\n  expect(betaPost?.post.name).toBeDefined();\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Report a post\", async () => {\n  // Create post from alpha\n  let alphaCommunity = await resolveBetaCommunity(alpha);\n  await followBeta(alpha);\n  let alphaPost = await createPost(alpha, alphaCommunity!.community.id);\n  expect(alphaPost.post_view.post).toBeDefined();\n\n  // add remote mod on epsilon\n  await followBeta(epsilon);\n\n  let betaCommunity = await resolveBetaCommunity(beta);\n  let epsilonUser = await resolvePerson(\n    beta,\n    \"@lemmy_epsilon@lemmy-epsilon:8581\",\n  );\n  let mod_params: AddModToCommunity = {\n    community_id: betaCommunity!.community.id,\n    person_id: epsilonUser!.person.id,\n    added: true,\n  };\n  let res = await beta.addModToCommunity(mod_params);\n  expect(res.moderators.length).toBe(2);\n\n  // Send report from gamma\n  let gammaPost = await resolvePost(gamma, alphaPost.post_view.post);\n  let gammaReport = (\n    await reportPost(gamma, gammaPost!.post.id, randomString(10))\n  ).post_report_view.post_report;\n  expect(gammaReport).toBeDefined();\n\n  // Report was federated to community instance\n  let betaReport = (\n    (await waitUntil(\n      () =>\n        listReports(beta).then(p =>\n          p.items.find(r => {\n            return checkPostReportName(r, gammaReport);\n          }),\n        ),\n      res => !!res,\n    ))! as PostReportView\n  ).post_report;\n  expect(betaReport).toBeDefined();\n  expect(betaReport.resolved).toBe(false);\n  expect(betaReport.original_post_name).toBe(gammaReport.original_post_name);\n  //expect(betaReport.original_post_url).toBe(gammaReport.original_post_url);\n  expect(betaReport.original_post_body).toBe(gammaReport.original_post_body);\n  expect(betaReport.reason).toBe(gammaReport.reason);\n  await unfollowRemotes(alpha);\n\n  // Report was federated to poster's instance. Alpha is not a community mod and doesnt see\n  // the report by default, so we need to pass show_mod_reports = true.\n  let alphaReport = (\n    (await waitUntil(\n      () =>\n        listReports(alpha, true).then(p =>\n          p.items.find(r => {\n            return checkPostReportName(r, gammaReport);\n          }),\n        ),\n      res => !!res,\n    ))! as PostReportView\n  ).post_report;\n  expect(alphaReport).toBeDefined();\n  expect(alphaReport.resolved).toBe(false);\n  expect(alphaReport.original_post_name).toBe(gammaReport.original_post_name);\n  //expect(alphaReport.original_post_url).toBe(gammaReport.original_post_url);\n  expect(alphaReport.original_post_body).toBe(gammaReport.original_post_body);\n  expect(alphaReport.reason).toBe(gammaReport.reason);\n\n  // Report was federated to remote mod instance\n  let epsilonReport = (\n    (await waitUntil(\n      () =>\n        listReports(epsilon).then(p =>\n          p.items.find(r => {\n            return checkPostReportName(r, gammaReport);\n          }),\n        ),\n      res => !!res,\n    ))! as PostReportView\n  ).post_report;\n  expect(epsilonReport).toBeDefined();\n  expect(epsilonReport.resolved).toBe(false);\n  expect(epsilonReport.original_post_name).toBe(gammaReport.original_post_name);\n\n  // Resolve report as remote mod\n  let resolve_params: ResolvePostReport = {\n    report_id: epsilonReport.id,\n    resolved: true,\n  };\n  let resolve = await epsilon.resolvePostReport(resolve_params);\n  expect(resolve.post_report_view.post_report.resolved).toBeTruthy();\n\n  // Report should be marked resolved on community instance\n  let resolvedReport = (\n    (await waitUntil(\n      () =>\n        listReports(beta).then(p =>\n          p.items.find(r => {\n            return checkPostReportName(r, gammaReport) && !!r.resolver;\n          }),\n        ),\n      res => !!res,\n    ))! as PostReportView\n  ).post_report;\n  expect(resolvedReport).toBeDefined();\n  expect(resolvedReport.resolved).toBe(true);\n});\n\ntest(\"Fetch post via redirect\", async () => {\n  await followBeta(alpha);\n  let alphaPost = await createPost(alpha, betaCommunity!.community.id);\n  expect(alphaPost.post_view.post).toBeDefined();\n  // Make sure that post is liked on beta\n  const betaPost = await waitForPost(\n    beta,\n    alphaPost.post_view.post,\n    res => res?.post.score === 1,\n  );\n\n  expect(betaPost).toBeDefined();\n  expect(betaPost.post?.ap_id).toBe(alphaPost.post_view.post.ap_id);\n\n  // Fetch post from url on beta instance instead of ap_id\n  let q = `http://lemmy-beta:8551/post/${betaPost.post.id}`;\n  let form: ResolveObject = {\n    q,\n  };\n  let gammaPost = await gamma\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"post\" ? a : undefined));\n\n  expect(gammaPost).toBeDefined();\n  expect(gammaPost?.post.ap_id).toBe(alphaPost.post_view.post.ap_id);\n  await unfollowRemotes(alpha);\n});\n\ntest(\"Block post that contains banned URL\", async () => {\n  let editSiteForm: EditSite = {\n    blocked_urls: [\"https://evil.com/\"],\n  };\n\n  await epsilon.editSite(editSiteForm);\n\n  await waitUntil(\n    () => epsilon.getSite(),\n    s => s.blocked_urls.length == 1,\n  );\n\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  await jestLemmyError(\n    () => createPost(epsilon, betaCommunity!.community.id, \"https://evil.com\"),\n    new LemmyError(\"blocked_url\", statusBadRequest),\n  );\n\n  // Later tests need this to be empty\n  editSiteForm.blocked_urls = [];\n  await epsilon.editSite(editSiteForm);\n});\n\ntest(\"Fetch post with redirect\", async () => {\n  let alphaPost = await createPost(alpha, betaCommunity!.community.id);\n  expect(alphaPost.post_view.post).toBeDefined();\n\n  // beta fetches from alpha as usual\n  let betaPost = await resolvePost(beta, alphaPost.post_view.post);\n  expect(betaPost?.post).toBeDefined();\n\n  // gamma fetches from beta, and gets redirected to alpha\n  let gammaPost = await resolvePost(gamma, betaPost!.post);\n  expect(gammaPost?.post).toBeDefined();\n\n  // fetch remote object from local url, which redirects to the original url\n  let form: ResolveObject = {\n    q: `http://lemmy-gamma:8561/post/${gammaPost?.post.id}`,\n  };\n  let gammaPost2 = await gamma\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"post\" ? a : undefined));\n\n  expect(gammaPost2?.post).toBeDefined();\n});\n\ntest(\"Mention beta from alpha post body\", async () => {\n  if (!betaCommunity) throw Error(\"no community\");\n  let mentionContent = \"A test mention of @lemmy_beta@lemmy-beta:8551\";\n\n  const postOnAlphaRes = await createPost(\n    alpha,\n    betaCommunity.community.id,\n    undefined,\n    mentionContent,\n  );\n\n  expect(postOnAlphaRes.post_view.post.body).toBeDefined();\n  expect(postOnAlphaRes.post_view.community.local).toBe(false);\n  expect(postOnAlphaRes.post_view.creator.local).toBe(true);\n  expect(postOnAlphaRes.post_view.post.score).toBe(1);\n\n  // get beta's localized copy of the alpha post\n  let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post);\n  if (!betaPost) {\n    throw \"unable to locate post on beta\";\n  }\n  expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id);\n  expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name);\n  await assertPostFederation(betaPost, postOnAlphaRes.post_view);\n\n  let mentionsRes = await waitUntil(\n    () => listNotifications(beta, \"mention\"),\n    m => !!m.items[0],\n  );\n\n  const firstMention = mentionsRes.items[0].data as PostView;\n  expect(firstMention.post!.body).toBeDefined();\n  expect(firstMention.community!.local).toBe(true);\n  expect(firstMention.creator.local).toBe(false);\n  expect(firstMention.post!.score).toBe(1);\n});\n\ntest(\"Rewrite markdown links\", async () => {\n  const community = await resolveBetaCommunity(beta);\n\n  // create a post\n  let postRes1 = await createPost(beta, community!.community.id);\n\n  // link to this post in markdown\n  let postRes2 = await createPost(\n    beta,\n    community!.community.id,\n    \"https://example.com/\",\n    `[link](${postRes1.post_view.post.ap_id})`,\n  );\n  expect(postRes2.post_view.post).toBeDefined();\n\n  // fetch both posts from another instance\n  const alphaPost1 = await resolvePost(alpha, postRes1.post_view.post);\n  const alphaPost2 = await resolvePost(alpha, postRes2.post_view.post);\n\n  // remote markdown link is replaced with local link\n  expect(alphaPost2?.post.body).toBe(\n    `[link](http://lemmy-alpha:8541/post/${alphaPost1?.post.id})`,\n  );\n});\n\ntest(\"Don't allow NSFW posts on instances that disable it\", async () => {\n  // Disallow NSFW on gamma\n  let editSiteForm: EditSite = {\n    disallow_nsfw_content: true,\n  };\n  await gamma.editSite(editSiteForm);\n\n  // Wait for cache on Gamma's LocalSite\n  await waitUntil(\n    () => getSite(gamma),\n    s => s.site_view.local_site.disallow_nsfw_content,\n  );\n\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n\n  // Make a NSFW post\n  let postRes = await createPost(beta, betaCommunity.community.id);\n  let form: EditPost = {\n    nsfw: true,\n    post_id: postRes.post_view.post.id,\n  };\n  let updatePost = await beta.editPost(form);\n\n  // Gamma reject resolving the post\n  await jestLemmyError(\n    () => resolvePost(gamma, updatePost.post_view.post),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest, \"NsfwNotAllowed\"),\n  );\n\n  // Local users can't create NSFW post on Gamma\n  let gammaCommunity = await resolveCommunity(\n    gamma,\n    betaCommunity.community.ap_id,\n  );\n  if (!gammaCommunity) {\n    throw \"Missing gamma community\";\n  }\n  let gammaPost = await createPost(gamma, gammaCommunity.community.id);\n  let form2: EditPost = {\n    nsfw: true,\n    post_id: gammaPost.post_view.post.id,\n  };\n  await jestLemmyError(\n    () => gamma.editPost(form2),\n    new LemmyError(\"nsfw_not_allowed\", statusBadRequest),\n  );\n});\n\ntest(\"Plugin test\", async () => {\n  let community = await createCommunity(epsilon);\n  let postRes1 = await createPost(\n    epsilon,\n    community.community_view.community.id,\n    \"https://example.com/\",\n    randomString(10),\n    \"Rust\",\n  );\n  expect(postRes1.post_view.post.name).toBe(\"Go\");\n\n  await jestLemmyError(\n    () =>\n      createPost(\n        epsilon,\n        community.community_view.community.id,\n        \"https://example.com/\",\n        randomString(10),\n        \"Java\",\n      ),\n    new LemmyError(\"plugin_error\", statusBadRequest, \"We dont talk about Java\"),\n  );\n});\n\nfunction checkPostReportName(rcv: ReportCombinedView, report: PostReport) {\n  switch (rcv.type_) {\n    case \"post\":\n      return rcv.post_report.original_post_name === report.original_post_name;\n    default:\n      return false;\n  }\n}\n"
  },
  {
    "path": "api_tests/src/private_comm.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport { FollowCommunity, LemmyError, LemmyHttp } from \"lemmy-js-client\";\nimport {\n  alpha,\n  setupLogins,\n  createCommunity,\n  unfollows,\n  registerUser,\n  listCommunityPendingFollows,\n  getCommunity,\n  approveCommunityPendingFollow,\n  randomString,\n  createPost,\n  createComment,\n  beta,\n  resolveCommunity,\n  betaUrl,\n  resolvePost,\n  resolveComment,\n  likeComment,\n  waitUntil,\n  gamma,\n  getPosts,\n  getComments,\n  statusNotFound,\n  jestLemmyError,\n  statusBadRequest,\n  getUnreadCounts,\n} from \"./shared\";\n\nbeforeAll(setupLogins);\nafterAll(unfollows);\n\ntest(\"Follow a private community\", async () => {\n  // create private community\n  const community = await createCommunity(alpha, randomString(10), \"private\");\n  expect(community.community_view.community.visibility).toBe(\"private\");\n  const alphaCommunityId = community.community_view.community.id;\n\n  // No pending follows yet\n  const pendingFollows0 = await listCommunityPendingFollows(alpha);\n  expect(pendingFollows0.items.length).toBe(0);\n  const pendingFollowsCount0 = await getUnreadCounts(alpha);\n  expect(pendingFollowsCount0.pending_follow_count).toBe(0);\n\n  // follow as new user\n  const user = await registerUser(beta, betaUrl);\n  const betaCommunity = await resolveCommunity(\n    user,\n    community.community_view.community.ap_id,\n  );\n  expect(betaCommunity).toBeDefined();\n  expect(betaCommunity?.community.visibility).toBe(\"private\");\n  const betaCommunityId = betaCommunity!.community.id;\n  const follow_form: FollowCommunity = {\n    community_id: betaCommunityId,\n    follow: true,\n  };\n  await user.followCommunity(follow_form);\n\n  // Follow listed as pending\n  const follow1 = await getCommunity(user, betaCommunityId);\n  expect(follow1.community_view.community_actions?.follow_state).toBe(\n    \"approval_required\",\n  );\n\n  // Wait for follow to federate, shown as pending\n  let pendingFollows1 = await waitUntil(\n    () => listCommunityPendingFollows(alpha),\n    f => f.items.length == 1,\n  );\n  expect(pendingFollows1.items[0].is_new_instance).toBe(true);\n  const pendingFollowsCount1 = await getUnreadCounts(alpha);\n  expect(pendingFollowsCount1.pending_follow_count).toBe(1);\n\n  // user still sees approval required at this point\n  const betaCommunity2 = await getCommunity(user, betaCommunityId);\n  expect(betaCommunity2.community_view.community_actions?.follow_state).toBe(\n    \"approval_required\",\n  );\n\n  // Approve the follow\n  const approve = await approveCommunityPendingFollow(\n    alpha,\n    alphaCommunityId,\n    pendingFollows1.items[0].person.id,\n  );\n  expect(approve.success).toBe(true);\n\n  // Follow is confirmed\n  await waitUntil(\n    () => getCommunity(user, betaCommunityId),\n    c => c.community_view.community_actions?.follow_state == \"accepted\",\n  );\n  const pendingFollows2 = await listCommunityPendingFollows(alpha);\n  expect(pendingFollows2.items.length).toBe(0);\n  const pendingFollowsCount2 = await getUnreadCounts(alpha);\n  expect(pendingFollowsCount2.pending_follow_count).toBe(0);\n\n  // follow with another user from that instance, is_new_instance should be false now\n  const user2 = await registerUser(beta, betaUrl);\n  await user2.followCommunity(follow_form);\n  let pendingFollows3 = await waitUntil(\n    () => listCommunityPendingFollows(alpha),\n    f => f.items.length == 1,\n  );\n  expect(pendingFollows3.items[0].is_new_instance).toBe(false);\n\n  // cleanup pending follow\n  const approve2 = await approveCommunityPendingFollow(\n    alpha,\n    alphaCommunityId,\n    pendingFollows3.items[0].person.id,\n  );\n  expect(approve2.success).toBe(true);\n});\n\ntest(\"Only followers can view and interact with private community content\", async () => {\n  // create private community\n  const community = await createCommunity(alpha, randomString(10), \"private\");\n  expect(community.community_view.community.visibility).toBe(\"private\");\n  const alphaCommunityId = community.community_view.community.id;\n\n  // create post and comment\n  const post0 = await createPost(alpha, alphaCommunityId);\n  const post_id = post0.post_view.post.id;\n  expect(post_id).toBeDefined();\n  const comment = await createComment(alpha, post_id);\n  const comment_id = comment.comment_view.comment.id;\n  expect(comment_id).toBeDefined();\n\n  // user is not following the community and cannot view nor create posts\n  const user = await registerUser(beta, betaUrl);\n  const betaCommunity = (\n    await resolveCommunity(user, community.community_view.community.ap_id)\n  )?.community;\n  await jestLemmyError(\n    () => resolvePost(user, post0.post_view.post),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest),\n    false,\n  );\n  await jestLemmyError(\n    () => resolveComment(user, comment.comment_view.comment),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest),\n    false,\n  );\n  await jestLemmyError(\n    () => createPost(user, betaCommunity!.id),\n    new LemmyError(\"not_found\", statusNotFound),\n  );\n\n  // follow the community and approve\n  const follow_form: FollowCommunity = {\n    community_id: betaCommunity!.id,\n    follow: true,\n  };\n  await user.followCommunity(follow_form);\n  approveFollower(alpha, alphaCommunityId);\n\n  // now user can fetch posts and comments in community (using signed fetch), and create posts\n  await waitUntil(\n    () => resolvePost(user, post0.post_view.post),\n    p => p?.post.id != undefined,\n  );\n  const resolvedComment = await resolveComment(\n    user,\n    comment.comment_view.comment,\n  );\n  expect(resolvedComment?.comment.id).toBeDefined();\n\n  const post1 = await createPost(user, betaCommunity!.id);\n  expect(post1.post_view).toBeDefined();\n  const like = await likeComment(user, true, resolvedComment!.comment);\n  expect(like.comment_view.comment_actions?.vote_is_upvote).toBe(true);\n});\n\ntest(\"Reject follower\", async () => {\n  // create private community\n  const community = await createCommunity(alpha, randomString(10), \"private\");\n  expect(community.community_view.community.visibility).toBe(\"private\");\n  const alphaCommunityId = community.community_view.community.id;\n\n  // user is not following the community and cannot view nor create posts\n  const user = await registerUser(beta, betaUrl);\n  const betaCommunity1 = (\n    await resolveCommunity(user, community.community_view.community.ap_id)\n  )?.community;\n\n  // follow the community and reject\n  const follow_form: FollowCommunity = {\n    community_id: betaCommunity1!.id,\n    follow: true,\n  };\n  const follow = await user.followCommunity(follow_form);\n  expect(follow.community_view.community_actions?.follow_state).toBe(\n    \"approval_required\",\n  );\n\n  const pendingFollows1 = await waitUntil(\n    () => listCommunityPendingFollows(alpha),\n    f => f.items.length == 1,\n  );\n  const approve = await approveCommunityPendingFollow(\n    alpha,\n    alphaCommunityId,\n    pendingFollows1.items[0].person.id,\n    false,\n  );\n  expect(approve.success).toBe(true);\n\n  await waitUntil(\n    () => getCommunity(user, betaCommunity1!.id),\n    c => c.community_view.community_actions?.follow_state === undefined,\n  );\n});\n\ntest(\"Follow a private community and receive activities\", async () => {\n  // create private community\n  const community = await createCommunity(alpha, randomString(10), \"private\");\n  expect(community.community_view.community.visibility).toBe(\"private\");\n  const alphaCommunityId = community.community_view.community.id;\n\n  // follow with users from beta and gamma\n  const betaCommunity = await resolveCommunity(\n    beta,\n    community.community_view.community.ap_id,\n  );\n  expect(betaCommunity).toBeDefined();\n  const betaCommunityId = betaCommunity!.community.id;\n  const follow_form_beta: FollowCommunity = {\n    community_id: betaCommunityId,\n    follow: true,\n  };\n  await beta.followCommunity(follow_form_beta);\n  await approveFollower(alpha, alphaCommunityId);\n\n  const gammaCommunityId = (await resolveCommunity(\n    gamma,\n    community.community_view.community.ap_id,\n  ))!.community.id;\n  const follow_form_gamma: FollowCommunity = {\n    community_id: gammaCommunityId,\n    follow: true,\n  };\n  await gamma.followCommunity(follow_form_gamma);\n  await approveFollower(alpha, alphaCommunityId);\n\n  // Follow is confirmed\n  await waitUntil(\n    () => getCommunity(beta, betaCommunityId),\n    c => c.community_view.community_actions?.follow_state == \"accepted\",\n  );\n  await waitUntil(\n    () => getCommunity(gamma, gammaCommunityId),\n    c => c.community_view.community_actions?.follow_state == \"accepted\",\n  );\n\n  // create a post and comment from gamma\n  const post = await createPost(gamma, gammaCommunityId);\n  const post_id = post.post_view.post.id;\n  expect(post_id).toBeDefined();\n  const comment = await createComment(gamma, post_id);\n  const comment_id = comment.comment_view.comment.id;\n  expect(comment_id).toBeDefined();\n\n  // post and comment were federated to beta\n  let posts = await waitUntil(\n    () => getPosts(beta, \"all\", betaCommunityId),\n    c => c.items.length == 1,\n  );\n  expect(posts.items[0].post.ap_id).toBe(post.post_view.post.ap_id);\n  expect(posts.items[0].post.name).toBe(post.post_view.post.name);\n  let comments = await waitUntil(\n    () => getComments(beta, posts.items[0].post.id),\n    c => c.items.length == 1,\n  );\n  expect(comments.items[0].comment.ap_id).toBe(\n    comment.comment_view.comment.ap_id,\n  );\n  expect(comments.items[0].comment.content).toBe(\n    comment.comment_view.comment.content,\n  );\n});\n\ntest(\"Fetch remote content in private community\", async () => {\n  // create private community\n  const community = await createCommunity(alpha, randomString(10), \"private\");\n  expect(community.community_view.community.visibility).toBe(\"private\");\n  const alphaCommunityId = community.community_view.community.id;\n\n  const betaCommunityId = (await resolveCommunity(\n    beta,\n    community.community_view.community.ap_id,\n  ))!.community.id;\n  const follow_form_beta: FollowCommunity = {\n    community_id: betaCommunityId,\n    follow: true,\n  };\n  await beta.followCommunity(follow_form_beta);\n  await approveFollower(alpha, alphaCommunityId);\n\n  // Follow is confirmed\n  await waitUntil(\n    () => getCommunity(beta, betaCommunityId),\n    c => c.community_view.community_actions?.follow_state == \"accepted\",\n  );\n\n  // beta creates post and comment\n  const post = await createPost(beta, betaCommunityId);\n  const post_id = post.post_view.post.id;\n  expect(post_id).toBeDefined();\n  const comment = await createComment(beta, post_id);\n  const comment_id = comment.comment_view.comment.id;\n  expect(comment_id).toBeDefined();\n\n  // Wait for it to federate\n  await waitUntil(\n    () => resolveComment(alpha, comment.comment_view.comment),\n    p => p?.comment.id != undefined,\n  );\n\n  // create gamma user\n  const gammaCommunityId = (await resolveCommunity(\n    gamma,\n    community.community_view.community.ap_id,\n  ))!.community.id;\n  const follow_form: FollowCommunity = {\n    community_id: gammaCommunityId,\n    follow: true,\n  };\n\n  // cannot fetch post yet\n  await jestLemmyError(\n    () => resolvePost(gamma, post.post_view.post),\n    new LemmyError(\"resolve_object_failed\", statusBadRequest),\n    false,\n  );\n  // follow community and approve\n  await gamma.followCommunity(follow_form);\n  await approveFollower(alpha, alphaCommunityId);\n\n  // now user can fetch posts and comments in community (using signed fetch), and create posts.\n  // for this to work, beta checks with alpha if gamma is really an approved follower.\n  let resolvedPost = await waitUntil(\n    () => resolvePost(gamma, post.post_view.post),\n    p => p?.post.id != undefined,\n  );\n  expect(resolvedPost?.post.ap_id).toBe(post.post_view.post.ap_id);\n  const resolvedComment = await waitUntil(\n    () => resolveComment(gamma, comment.comment_view.comment),\n    p => p?.comment.id != undefined,\n  );\n  expect(resolvedComment?.comment.ap_id).toBe(\n    comment.comment_view.comment.ap_id,\n  );\n});\n\nasync function approveFollower(user: LemmyHttp, community_id: number) {\n  let pendingFollows1 = await waitUntil(\n    () => listCommunityPendingFollows(user),\n    f => f.items.length == 1,\n  );\n  const approve = await approveCommunityPendingFollow(\n    alpha,\n    community_id,\n    pendingFollows1.items[0].person.id,\n  );\n  expect(approve.success).toBe(true);\n}\n"
  },
  {
    "path": "api_tests/src/private_message.spec.ts",
    "content": "jest.setTimeout(120000);\nimport { LemmyError, PrivateMessageView } from \"lemmy-js-client\";\nimport {\n  alpha,\n  beta,\n  setupLogins,\n  createPrivateMessage,\n  editPrivateMessage,\n  deletePrivateMessage,\n  waitUntil,\n  reportPrivateMessage,\n  unfollows,\n  listNotifications,\n  resolvePerson,\n  statusBadRequest,\n  jestLemmyError,\n} from \"./shared\";\n\nlet recipient_id: number;\n\nbeforeAll(async () => {\n  await setupLogins();\n  let betaUser = await beta.getMyUser();\n  let betaUserOnAlpha = await resolvePerson(\n    alpha,\n    betaUser.local_user_view.person.ap_id,\n  );\n  recipient_id = betaUserOnAlpha!.person.id;\n});\n\nafterAll(unfollows);\n\ntest(\"Create a private message\", async () => {\n  let pmRes = await createPrivateMessage(alpha, recipient_id);\n  expect(pmRes.private_message_view.private_message.content).toBeDefined();\n  expect(pmRes.private_message_view.private_message.local).toBe(true);\n  expect(pmRes.private_message_view.creator.local).toBe(true);\n  expect(pmRes.private_message_view.recipient.local).toBe(false);\n\n  let betaPms = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    e => !!e.items[0],\n  );\n  const firstPm = betaPms.items[0].data as PrivateMessageView;\n  expect(firstPm.private_message.content).toBeDefined();\n  expect(firstPm.private_message.local).toBe(false);\n  expect(firstPm.creator.local).toBe(false);\n  expect(firstPm.recipient.local).toBe(true);\n});\n\ntest(\"Update a private message\", async () => {\n  let updatedContent = \"A jest test federated private message edited\";\n\n  let pmRes = await createPrivateMessage(alpha, recipient_id);\n  let pmUpdated = await editPrivateMessage(\n    alpha,\n    pmRes.private_message_view.private_message.id,\n  );\n  expect(pmUpdated.private_message_view.private_message.content).toBe(\n    updatedContent,\n  );\n\n  let betaPms = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    p =>\n      p.items[0].data.type_ == \"private_message\" &&\n      p.items[0].data.private_message.content === updatedContent,\n  );\n  let pm = betaPms.items[0].data as PrivateMessageView;\n  expect(pm.private_message.content).toBe(updatedContent);\n});\n\ntest(\"Delete a private message\", async () => {\n  let pmRes = await createPrivateMessage(alpha, recipient_id);\n  let betaPms1 = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    m =>\n      !!m.items.find(\n        e =>\n          e.data.type_ == \"private_message\" &&\n          e.data.private_message.ap_id ===\n            pmRes.private_message_view.private_message.ap_id,\n      ),\n  );\n  let deletedPmRes = await deletePrivateMessage(\n    alpha,\n    true,\n    pmRes.private_message_view.private_message.id,\n  );\n  expect(deletedPmRes.private_message_view.private_message.deleted).toBe(true);\n\n  // The GetPrivateMessages filters out deleted,\n  // even though they are in the actual database.\n  // no reason to show them\n  let betaPms2 = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    p => p.items.length === betaPms1.items.length - 1,\n  );\n  expect(betaPms2.items.length).toBe(betaPms1.items.length - 1);\n\n  // Undelete\n  let undeletedPmRes = await deletePrivateMessage(\n    alpha,\n    false,\n    pmRes.private_message_view.private_message.id,\n  );\n  expect(undeletedPmRes.private_message_view.private_message.deleted).toBe(\n    false,\n  );\n\n  let betaPms3 = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    p => p.items.length === betaPms1.items.length,\n  );\n  expect(betaPms3.items.length).toBe(betaPms1.items.length);\n});\n\ntest(\"Create a private message report\", async () => {\n  let pmRes = await createPrivateMessage(alpha, recipient_id);\n  let betaPms1 = await waitUntil(\n    () => listNotifications(beta, \"private_message\"),\n    m =>\n      !!m.items.find(\n        e =>\n          e.data.type_ == \"private_message\" &&\n          e.data.private_message.ap_id ===\n            pmRes.private_message_view.private_message.ap_id,\n      ),\n  );\n  let betaPm = betaPms1.items[0].data as PrivateMessageView;\n  expect(betaPm).toBeDefined();\n\n  // Make sure that only the recipient can report it, so this should fail\n  await jestLemmyError(\n    () =>\n      reportPrivateMessage(\n        alpha,\n        pmRes.private_message_view.private_message.id,\n        \"a reason\",\n      ),\n    new LemmyError(\"couldnt_create\", statusBadRequest),\n  );\n\n  // This one should pass\n  let reason = \"another reason\";\n  let report = await reportPrivateMessage(\n    beta,\n    betaPm.private_message.id,\n    reason,\n  );\n\n  expect(report.private_message_report_view.private_message.id).toBe(\n    betaPm.private_message.id,\n  );\n  expect(report.private_message_report_view.private_message_report.reason).toBe(\n    reason,\n  );\n});\n"
  },
  {
    "path": "api_tests/src/shared.ts",
    "content": "import {\n  ApproveCommunityPendingFollower,\n  BlockCommunity,\n  CommunityId,\n  CommunityVisibility,\n  CreatePrivateMessageReport,\n  EditCommunity,\n  InstanceId,\n  LemmyHttp,\n  ListCommunityPendingFollows,\n  ListReports,\n  MyUserInfo,\n  DeleteImageParams,\n  PersonId,\n  PostView,\n  PrivateMessageReportResponse,\n  SuccessResponse,\n  ListPersonContent,\n  PersonContentType,\n  GetModlog,\n  CommunityView,\n  CommentView,\n  Comment,\n  PersonView,\n  UserBlockInstanceCommunitiesParams,\n  ListNotifications,\n  NotificationTypeFilter,\n  PersonResponse,\n  AdminAllowInstanceParams,\n  BanFromCommunity,\n  BanPerson,\n  CommentReportResponse,\n  CommentResponse,\n  CommunityReportResponse,\n  CommunityResponse,\n  CreateComment,\n  CreateCommentLike,\n  CreateCommentReport,\n  CreateCommunity,\n  CreateCommunityReport,\n  CreatePost,\n  CreatePostLike,\n  CreatePostReport,\n  CreatePrivateMessage,\n  DeleteAccount,\n  DeleteComment,\n  DeleteCommunity,\n  DeletePost,\n  DeletePrivateMessage,\n  EditComment,\n  EditPost,\n  EditPrivateMessage,\n  EditSite,\n  FeaturePost,\n  FollowCommunity,\n  GetComment,\n  GetComments,\n  GetCommunity,\n  GetCommunityResponse,\n  GetPersonDetails,\n  GetPersonDetailsResponse,\n  GetPost,\n  GetPostResponse,\n  GetPosts,\n  GetSiteResponse,\n  ListingType,\n  LockComment,\n  LockPost,\n  Login,\n  LoginResponse,\n  Post,\n  PostReportResponse,\n  PostResponse,\n  PrivateMessageResponse,\n  Register,\n  RemoveComment,\n  RemoveCommunity,\n  RemovePost,\n  ResolveObject,\n  SaveUserSettings,\n  Search,\n  PagedResponse,\n  NotificationView,\n  ReportCombinedView,\n  PendingFollowerView,\n  ModlogView,\n  LemmyError,\n  PostCommentCombinedView,\n  UnreadCountsResponse,\n} from \"lemmy-js-client\";\n\nexport const fetchFunction = fetch;\nexport const imageFetchLimit = 50;\nexport const statusNotFound = 404;\nexport const statusBadRequest = 400;\nexport const statusUnauthorized = 401;\nexport const sampleImage =\n  \"https://i.pinimg.com/originals/df/5f/5b/df5f5b1b174a2b4b6026cc6c8f9395c1.jpg\";\nexport const sampleSite = \"https://w3.org\";\n\nexport const alphaUrl = \"http://127.0.0.1:8541\";\nexport const betaUrl = \"http://127.0.0.1:8551\";\nexport const gammaUrl = \"http://127.0.0.1:8561\";\nexport const deltaUrl = \"http://127.0.0.1:8571\";\nexport const epsilonUrl = \"http://127.0.0.1:8581\";\n\nexport const alpha = new LemmyHttp(alphaUrl, { fetchFunction });\nexport const alphaImage = new LemmyHttp(alphaUrl);\nexport const beta = new LemmyHttp(betaUrl, { fetchFunction });\nexport const gamma = new LemmyHttp(gammaUrl, { fetchFunction });\nexport const delta = new LemmyHttp(deltaUrl, { fetchFunction });\nexport const epsilon = new LemmyHttp(epsilonUrl, { fetchFunction });\n\nexport const password = \"lemmylemmy\";\n\nexport async function setupLogins() {\n  let formAlpha: Login = {\n    username_or_email: \"lemmy_alpha\",\n    password,\n  };\n  let resAlpha = alpha.login(formAlpha);\n\n  let formBeta: Login = {\n    username_or_email: \"lemmy_beta\",\n    password,\n  };\n  let resBeta = beta.login(formBeta);\n\n  let formGamma: Login = {\n    username_or_email: \"lemmy_gamma\",\n    password,\n  };\n  let resGamma = gamma.login(formGamma);\n\n  let formDelta: Login = {\n    username_or_email: \"lemmy_delta\",\n    password,\n  };\n  let resDelta = delta.login(formDelta);\n\n  let formEpsilon: Login = {\n    username_or_email: \"lemmy_epsilon\",\n    password,\n  };\n  let resEpsilon = epsilon.login(formEpsilon);\n\n  let res = await Promise.all([\n    resAlpha,\n    resBeta,\n    resGamma,\n    resDelta,\n    resEpsilon,\n  ]);\n  alpha.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? \"\"}` });\n  alphaImage.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? \"\"}` });\n  beta.setHeaders({ Authorization: `Bearer ${res[1].jwt ?? \"\"}` });\n  gamma.setHeaders({ Authorization: `Bearer ${res[2].jwt ?? \"\"}` });\n  delta.setHeaders({ Authorization: `Bearer ${res[3].jwt ?? \"\"}` });\n  epsilon.setHeaders({ Authorization: `Bearer ${res[4].jwt ?? \"\"}` });\n\n  // Registration applications are now enabled by default, need to disable them\n  let editSiteForm: EditSite = {\n    registration_mode: \"open\",\n    rate_limit_message_max_requests: 999,\n    rate_limit_post_max_requests: 999,\n    rate_limit_comment_max_requests: 999,\n    rate_limit_register_max_requests: 999,\n    rate_limit_search_max_requests: 999,\n    rate_limit_image_max_requests: 999,\n  };\n  await alpha.editSite(editSiteForm);\n  await beta.editSite(editSiteForm);\n  await gamma.editSite(editSiteForm);\n  await delta.editSite(editSiteForm);\n  await epsilon.editSite(editSiteForm);\n\n  // Alpha and beta use image_mode StoreLinkPreviews\n  let imageModeForm: EditSite = { image_mode: \"store_link_previews\" };\n  await alpha.editSite(imageModeForm);\n  await beta.editSite(imageModeForm);\n\n  // Set the blocks for each\n  await allowInstance(alpha, \"lemmy-beta\");\n  await allowInstance(alpha, \"lemmy-gamma\");\n  await allowInstance(alpha, \"lemmy-delta\");\n  await allowInstance(alpha, \"lemmy-epsilon\");\n\n  await allowInstance(beta, \"lemmy-alpha\");\n  await allowInstance(beta, \"lemmy-gamma\");\n  await allowInstance(beta, \"lemmy-delta\");\n  await allowInstance(beta, \"lemmy-epsilon\");\n\n  await allowInstance(gamma, \"lemmy-alpha\");\n  await allowInstance(gamma, \"lemmy-beta\");\n  await allowInstance(gamma, \"lemmy-delta\");\n  await allowInstance(gamma, \"lemmy-epsilon\");\n\n  await allowInstance(delta, \"lemmy-beta\");\n\n  // Create the main alpha/beta communities\n  // Ignore thrown errors of duplicates\n  try {\n    await createCommunity(alpha, \"main\");\n    await createCommunity(beta, \"main\");\n    // wait for > INSTANCES_RECHECK_DELAY to ensure federation is initialized\n    // otherwise the first few federated events may be missed\n    // (because last_successful_id is set to current id when federation to an instance is first started)\n    // only needed the first time so do in this try\n    await delay(10_000);\n  } catch {\n    //console.log(\"Communities already exist\");\n  }\n}\n\nexport async function allowInstance(api: LemmyHttp, instance: string) {\n  const params: AdminAllowInstanceParams = {\n    instance,\n    allow: true,\n    reason: \"allow\",\n  };\n  // Ignore errors from duplicate allows (because setup gets called for each test file)\n  try {\n    await api.adminAllowInstance(params);\n  } catch {\n    // console.error(error);\n  }\n}\n\nexport async function createPost(\n  api: LemmyHttp,\n  community_id: number,\n  url: string = \"https://example.com/\",\n  body = randomString(10),\n  // use example.com for consistent title and embed description\n  name: string = randomString(5),\n  alt_text = randomString(10),\n  custom_thumbnail: string | undefined = undefined,\n): Promise<PostResponse> {\n  let form: CreatePost = {\n    name,\n    url,\n    body,\n    alt_text,\n    community_id,\n    custom_thumbnail,\n  };\n  return api.createPost(form);\n}\n\nexport async function editPost(\n  api: LemmyHttp,\n  post: Post,\n): Promise<PostResponse> {\n  let name = \"A jest test federated post, updated\";\n  let form: EditPost = {\n    name,\n    post_id: post.id,\n  };\n  return api.editPost(form);\n}\n\nexport async function createPostWithThumbnail(\n  api: LemmyHttp,\n  community_id: number,\n  url: string,\n  custom_thumbnail: string,\n): Promise<PostResponse> {\n  let form: CreatePost = {\n    name: randomString(10),\n    url,\n    community_id,\n    custom_thumbnail,\n  };\n  return api.createPost(form);\n}\n\nexport async function deletePost(\n  api: LemmyHttp,\n  deleted: boolean,\n  post: Post,\n): Promise<PostResponse> {\n  let form: DeletePost = {\n    post_id: post.id,\n    deleted: deleted,\n  };\n  return api.deletePost(form);\n}\n\nexport async function removePost(\n  api: LemmyHttp,\n  removed: boolean,\n  post: Post,\n): Promise<PostResponse> {\n  let form: RemovePost = {\n    post_id: post.id,\n    removed,\n    reason: \"remove\",\n  };\n  return api.removePost(form);\n}\n\nexport async function featurePost(\n  api: LemmyHttp,\n  featured: boolean,\n  post: Post,\n): Promise<PostResponse> {\n  let form: FeaturePost = {\n    post_id: post.id,\n    featured,\n    feature_type: \"community\",\n  };\n  return api.featurePost(form);\n}\n\nexport async function lockPost(\n  api: LemmyHttp,\n  locked: boolean,\n  post: Post,\n): Promise<PostResponse> {\n  let form: LockPost = {\n    post_id: post.id,\n    locked,\n    reason: \"lock\",\n  };\n  return api.lockPost(form);\n}\n\nexport async function resolvePost(\n  api: LemmyHttp,\n  post: Post,\n): Promise<PostView | undefined> {\n  let form: ResolveObject = {\n    q: post.ap_id,\n  };\n  return api\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"post\" ? a : undefined));\n}\n\nexport async function searchPostLocal(\n  api: LemmyHttp,\n  post: Post,\n): Promise<PostView | undefined> {\n  let form: Search = {\n    q: post.name,\n    type_: \"posts\",\n    listing_type: \"all\",\n  };\n  let res = await api.search(form);\n  let first = res.search.at(0);\n  return first?.type_ == \"post\" ? first : undefined;\n}\n\n/// wait for a post to appear locally without pulling it\nexport async function waitForPost(\n  api: LemmyHttp,\n  post: Post,\n  checker: (t: PostView | undefined) => boolean = p => !!p,\n) {\n  return waitUntil(\n    () => searchPostLocal(api, post),\n    checker,\n  ) as Promise<PostView>;\n}\n\nexport async function getPost(\n  api: LemmyHttp,\n  post_id: number,\n): Promise<GetPostResponse> {\n  let form: GetPost = {\n    id: post_id,\n  };\n  return api.getPost(form);\n}\n\nexport async function lockComment(\n  api: LemmyHttp,\n  locked: boolean,\n  comment: Comment,\n): Promise<CommentResponse> {\n  let form: LockComment = {\n    comment_id: comment.id,\n    locked,\n    reason: \"lock\",\n  };\n  return api.lockComment(form);\n}\n\nexport async function getComment(\n  api: LemmyHttp,\n  comment_id: number,\n): Promise<CommentResponse> {\n  let form: GetComment = {\n    id: comment_id,\n  };\n  return api.getComment(form);\n}\n\nexport async function getComments(\n  api: LemmyHttp,\n  post_id?: number,\n  listingType: ListingType = \"all\",\n): Promise<PagedResponse<CommentView>> {\n  let form: GetComments = {\n    post_id: post_id,\n    type_: listingType,\n    sort: \"new\",\n    limit: 50,\n  };\n  return api.getComments(form);\n}\n\nexport async function getUnreadCounts(\n  api: LemmyHttp,\n): Promise<UnreadCountsResponse> {\n  return api.getUnreadCounts();\n}\n\nexport async function listNotifications(\n  api: LemmyHttp,\n  type_?: NotificationTypeFilter,\n  unread_only: boolean = false,\n): Promise<PagedResponse<NotificationView>> {\n  let form: ListNotifications = {\n    unread_only,\n    type_,\n  };\n  return api.listNotifications(form);\n}\n\nexport async function resolveComment(\n  api: LemmyHttp,\n  comment: Comment,\n): Promise<CommentView | undefined> {\n  let form: ResolveObject = {\n    q: comment.ap_id,\n  };\n  return api\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"comment\" ? a : undefined));\n}\n\nexport async function resolveBetaCommunity(\n  api: LemmyHttp,\n): Promise<CommunityView | undefined> {\n  // Use short-hand search url\n  let form: ResolveObject = {\n    q: \"!main@lemmy-beta:8551\",\n  };\n  return api\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"community\" ? a : undefined));\n}\n\nexport async function resolveCommunity(\n  api: LemmyHttp,\n  q: string,\n): Promise<CommunityView | undefined> {\n  let form: ResolveObject = {\n    q,\n  };\n  return api\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"community\" ? a : undefined));\n}\n\nexport async function resolvePerson(\n  api: LemmyHttp,\n  apShortname: string,\n): Promise<PersonView | undefined> {\n  let form: ResolveObject = {\n    q: apShortname,\n  };\n  return api\n    .resolveObject(form)\n    .then(a => a.resolve)\n    .then(a => (a?.type_ == \"person\" ? a : undefined));\n}\n\nexport async function banPersonFromSite(\n  api: LemmyHttp,\n  person_id: number,\n  ban: boolean,\n  remove_or_restore_data: boolean,\n): Promise<PersonResponse> {\n  // Make sure lemmy-beta/c/main is cached on lemmy_alpha\n  let form: BanPerson = {\n    person_id,\n    ban,\n    remove_or_restore_data,\n    reason: \"ban\",\n  };\n  return api.banPerson(form);\n}\n\nexport async function banPersonFromCommunity(\n  api: LemmyHttp,\n  person_id: number,\n  community_id: number,\n  remove_or_restore_data: boolean,\n  ban: boolean,\n): Promise<PersonResponse> {\n  let form: BanFromCommunity = {\n    person_id,\n    community_id,\n    remove_or_restore_data,\n    ban,\n    reason: \"ban\",\n  };\n  return api.banFromCommunity(form);\n}\n\nexport async function followCommunity(\n  api: LemmyHttp,\n  follow: boolean,\n  community_id: number,\n): Promise<CommunityResponse> {\n  let form: FollowCommunity = {\n    community_id,\n    follow,\n  };\n  const res = await api.followCommunity(form);\n  await waitUntil(\n    () => getCommunity(api, res.community_view.community.id),\n    g => {\n      let followState = g.community_view.community_actions?.follow_state;\n      return follow ? followState === \"accepted\" : followState === undefined;\n    },\n  );\n  // wait FOLLOW_ADDITIONS_RECHECK_DELAY (there's no API to wait for this currently)\n  await delay(2000);\n  return res;\n}\n\nexport async function likePost(\n  api: LemmyHttp,\n  is_upvote: boolean | undefined,\n  post: Post,\n): Promise<PostResponse> {\n  let form: CreatePostLike = {\n    post_id: post.id,\n    is_upvote: is_upvote,\n  };\n\n  return api.likePost(form);\n}\n\nexport async function createComment(\n  api: LemmyHttp,\n  post_id: number,\n  parent_id?: number,\n  content = \"a jest test comment\",\n): Promise<CommentResponse> {\n  let form: CreateComment = {\n    content,\n    post_id,\n    parent_id,\n  };\n  return api.createComment(form);\n}\n\nexport async function editComment(\n  api: LemmyHttp,\n  comment_id: number,\n  content = \"A jest test federated comment update\",\n): Promise<CommentResponse> {\n  let form: EditComment = {\n    content,\n    comment_id,\n  };\n  return api.editComment(form);\n}\n\nexport async function deleteComment(\n  api: LemmyHttp,\n  deleted: boolean,\n  comment_id: number,\n): Promise<CommentResponse> {\n  let form: DeleteComment = {\n    comment_id,\n    deleted,\n  };\n  return api.deleteComment(form);\n}\n\nexport async function removeComment(\n  api: LemmyHttp,\n  removed: boolean,\n  comment_id: number,\n  remove_children?: boolean,\n): Promise<CommentResponse> {\n  let form: RemoveComment = {\n    comment_id,\n    removed,\n    reason: \"remove\",\n    remove_children,\n  };\n  return api.removeComment(form);\n}\n\nexport async function likeComment(\n  api: LemmyHttp,\n  is_upvote: boolean | undefined,\n  comment: Comment,\n): Promise<CommentResponse> {\n  let form: CreateCommentLike = {\n    comment_id: comment.id,\n    is_upvote,\n  };\n  return api.likeComment(form);\n}\n\nexport async function createCommunity(\n  api: LemmyHttp,\n  name_: string = randomString(10),\n  visibility: CommunityVisibility = \"public\",\n): Promise<CommunityResponse> {\n  let sidebar = \"a sample sidebar\";\n  let form: CreateCommunity = {\n    name: name_,\n    title: name_,\n    sidebar,\n    visibility,\n  };\n  return api.createCommunity(form);\n}\n\nexport async function editCommunity(\n  api: LemmyHttp,\n  form: EditCommunity,\n): Promise<CommunityResponse> {\n  return api.editCommunity(form);\n}\n\nexport async function getCommunity(\n  api: LemmyHttp,\n  id: number,\n): Promise<GetCommunityResponse> {\n  let form: GetCommunity = {\n    id,\n  };\n  return api.getCommunity(form);\n}\n\nexport async function getCommunityByName(\n  api: LemmyHttp,\n  name: string,\n): Promise<CommunityResponse> {\n  let form: GetCommunity = {\n    name,\n  };\n  return api.getCommunity(form);\n}\n\nexport async function deleteCommunity(\n  api: LemmyHttp,\n  deleted: boolean,\n  community_id: number,\n): Promise<CommunityResponse> {\n  let form: DeleteCommunity = {\n    community_id,\n    deleted,\n  };\n  return api.deleteCommunity(form);\n}\n\nexport async function removeCommunity(\n  api: LemmyHttp,\n  removed: boolean,\n  community_id: number,\n): Promise<CommunityResponse> {\n  let form: RemoveCommunity = {\n    community_id,\n    removed,\n    reason: \"remove\",\n  };\n  return api.removeCommunity(form);\n}\n\nexport async function createPrivateMessage(\n  api: LemmyHttp,\n  recipient_id: number,\n): Promise<PrivateMessageResponse> {\n  let content = \"A jest test federated private message\";\n  let form: CreatePrivateMessage = {\n    content,\n    recipient_id,\n  };\n  return api.createPrivateMessage(form);\n}\n\nexport async function editPrivateMessage(\n  api: LemmyHttp,\n  private_message_id: number,\n): Promise<PrivateMessageResponse> {\n  let updatedContent = \"A jest test federated private message edited\";\n  let form: EditPrivateMessage = {\n    content: updatedContent,\n    private_message_id,\n  };\n  return api.editPrivateMessage(form);\n}\n\nexport async function deletePrivateMessage(\n  api: LemmyHttp,\n  deleted: boolean,\n  private_message_id: number,\n): Promise<PrivateMessageResponse> {\n  let form: DeletePrivateMessage = {\n    deleted,\n    private_message_id,\n  };\n  return api.deletePrivateMessage(form);\n}\n\nexport async function registerUser(\n  api: LemmyHttp,\n  url: string,\n  username: string = randomString(5),\n): Promise<LemmyHttp> {\n  let form: Register = {\n    username,\n    password,\n    password_verify: password,\n    show_nsfw: true,\n  };\n  let login_response = await api.register(form);\n\n  expect(login_response.jwt).toBeDefined();\n  let lemmyHttp = new LemmyHttp(url, {\n    headers: { Authorization: `Bearer ${login_response.jwt ?? \"\"}` },\n  });\n  return lemmyHttp;\n}\n\nexport async function loginUser(\n  api: LemmyHttp,\n  username: string,\n): Promise<LoginResponse> {\n  let form: Login = {\n    username_or_email: username,\n    password: password,\n  };\n  return api.login(form);\n}\n\nexport async function saveUserSettingsBio(\n  api: LemmyHttp,\n): Promise<SuccessResponse> {\n  let form: SaveUserSettings = {\n    show_nsfw: true,\n    blur_nsfw: false,\n    theme: \"darkly\",\n    default_post_sort_type: \"active\",\n    default_listing_type: \"all\",\n    interface_language: \"en\",\n    show_avatars: true,\n    send_notifications_to_email: false,\n    bio: \"a changed bio\",\n  };\n  return saveUserSettings(api, form);\n}\n\nexport async function saveUserSettingsFederated(\n  api: LemmyHttp,\n): Promise<SuccessResponse> {\n  let bio = \"a changed bio\";\n  let form: SaveUserSettings = {\n    show_nsfw: false,\n    blur_nsfw: true,\n    default_post_sort_type: \"hot\",\n    default_listing_type: \"all\",\n    interface_language: \"\",\n    display_name: \"user321\",\n    show_avatars: false,\n    send_notifications_to_email: false,\n    bio,\n  };\n  return await saveUserSettings(api, form);\n}\n\nexport async function saveUserSettings(\n  api: LemmyHttp,\n  form: SaveUserSettings,\n): Promise<SuccessResponse> {\n  return api.saveUserSettings(form);\n}\n\nexport async function getPersonDetails(\n  api: LemmyHttp,\n  person_id: number,\n): Promise<GetPersonDetailsResponse> {\n  let form: GetPersonDetails = {\n    person_id: person_id,\n  };\n  return api.getPersonDetails(form);\n}\n\nexport async function listPersonContent(\n  api: LemmyHttp,\n  person_id: number,\n  type_?: PersonContentType,\n): Promise<PagedResponse<PostCommentCombinedView>> {\n  let form: ListPersonContent = {\n    person_id,\n    type_,\n  };\n  return api.listPersonContent(form);\n}\n\nexport async function deleteUser(\n  api: LemmyHttp,\n  delete_content: boolean = true,\n): Promise<SuccessResponse> {\n  let form: DeleteAccount = {\n    delete_content,\n    password,\n  };\n  return api.deleteAccount(form);\n}\n\nexport async function getSite(api: LemmyHttp): Promise<GetSiteResponse> {\n  return api.getSite();\n}\n\nexport async function getMyUser(api: LemmyHttp): Promise<MyUserInfo> {\n  return api.getMyUser();\n}\n\nexport async function unfollowRemotes(api: LemmyHttp): Promise<MyUserInfo> {\n  // Unfollow all remote communities\n  let my_user = await getMyUser(api);\n  let remoteFollowed =\n    my_user.follows.filter(c => c.community.local == false) ?? [];\n  await Promise.allSettled(\n    remoteFollowed.map(cu => followCommunity(api, false, cu.community.id)),\n  );\n\n  return await getMyUser(api);\n}\n\nexport async function followBeta(api: LemmyHttp): Promise<CommunityResponse> {\n  let betaCommunity = await resolveBetaCommunity(api);\n  if (betaCommunity) {\n    let follow = await followCommunity(api, true, betaCommunity.community.id);\n    return follow;\n  } else {\n    return Promise.reject(\"no community worked\");\n  }\n}\n\nexport async function reportPost(\n  api: LemmyHttp,\n  post_id: number,\n  reason: string,\n): Promise<PostReportResponse> {\n  let form: CreatePostReport = {\n    post_id,\n    reason,\n  };\n  return api.createPostReport(form);\n}\n\nexport async function reportCommunity(\n  api: LemmyHttp,\n  community_id: number,\n  reason: string,\n): Promise<CommunityReportResponse> {\n  let form: CreateCommunityReport = {\n    community_id,\n    reason,\n  };\n  return api.createCommunityReport(form);\n}\n\nexport async function listReports(\n  api: LemmyHttp,\n  show_community_rule_violations: boolean = false,\n): Promise<PagedResponse<ReportCombinedView>> {\n  let form: ListReports = { show_community_rule_violations };\n  return api.listReports(form);\n}\n\nexport async function reportComment(\n  api: LemmyHttp,\n  comment_id: number,\n  reason: string,\n): Promise<CommentReportResponse> {\n  let form: CreateCommentReport = {\n    comment_id,\n    reason,\n  };\n  return api.createCommentReport(form);\n}\n\nexport async function reportPrivateMessage(\n  api: LemmyHttp,\n  private_message_id: number,\n  reason: string,\n): Promise<PrivateMessageReportResponse> {\n  let form: CreatePrivateMessageReport = {\n    private_message_id,\n    reason,\n  };\n  return api.createPrivateMessageReport(form);\n}\n\nexport function getPosts(\n  api: LemmyHttp,\n  listingType?: ListingType,\n  community_id?: number,\n): Promise<PagedResponse<PostView>> {\n  let form: GetPosts = {\n    type_: listingType,\n    limit: 50,\n    community_id,\n  };\n  return api.getPosts(form);\n}\n\nexport function userBlockInstanceCommunities(\n  api: LemmyHttp,\n  instance_id: InstanceId,\n  block: boolean,\n): Promise<SuccessResponse> {\n  let form: UserBlockInstanceCommunitiesParams = {\n    instance_id,\n    block,\n  };\n  return api.userBlockInstanceCommunities(form);\n}\n\nexport function blockCommunity(\n  api: LemmyHttp,\n  community_id: CommunityId,\n  block: boolean,\n): Promise<CommunityResponse> {\n  let form: BlockCommunity = {\n    community_id,\n    block,\n  };\n  return api.blockCommunity(form);\n}\n\nexport function listCommunityPendingFollows(\n  api: LemmyHttp,\n): Promise<PagedResponse<PendingFollowerView>> {\n  let form: ListCommunityPendingFollows = {\n    unread_only: true,\n    all_communities: false,\n    limit: 50,\n  };\n  return api.listCommunityPendingFollows(form);\n}\n\nexport function approveCommunityPendingFollow(\n  api: LemmyHttp,\n  community_id: CommunityId,\n  follower_id: PersonId,\n  approve: boolean = true,\n): Promise<SuccessResponse> {\n  let form: ApproveCommunityPendingFollower = {\n    community_id,\n    follower_id,\n    approve,\n  };\n  return api.approveCommunityPendingFollow(form);\n}\nexport function getModlog(api: LemmyHttp): Promise<PagedResponse<ModlogView>> {\n  let form: GetModlog = {};\n  return api.getModlog(form);\n}\n\nexport function wrapper(form: any): string {\n  return JSON.stringify(form);\n}\n\nexport function randomString(length: number): string {\n  let result = \"\";\n  let characters =\n    \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_\";\n  let charactersLength = characters.length;\n  for (let i = 0; i < length; i++) {\n    result += characters.charAt(Math.floor(Math.random() * charactersLength));\n  }\n  return result;\n}\n\nexport async function deleteAllMedia(api: LemmyHttp) {\n  const imagesRes = await api.listMediaAdmin({\n    limit: imageFetchLimit,\n  });\n  Promise.allSettled(\n    imagesRes.items\n      .map(image => {\n        const form: DeleteImageParams = {\n          filename: image.local_image.pictrs_alias,\n        };\n        return form;\n      })\n      .map(form => api.deleteMediaAdmin(form)),\n  );\n}\n\nexport async function unfollows() {\n  await Promise.allSettled([\n    unfollowRemotes(alpha),\n    unfollowRemotes(beta),\n    unfollowRemotes(gamma),\n    unfollowRemotes(delta),\n    unfollowRemotes(epsilon),\n  ]);\n  await Promise.allSettled([\n    purgeAllPosts(alpha),\n    purgeAllPosts(beta),\n    purgeAllPosts(gamma),\n    purgeAllPosts(delta),\n    purgeAllPosts(epsilon),\n  ]);\n}\n\nexport async function purgeAllPosts(api: LemmyHttp) {\n  // The best way to get all federated items, is to find the posts\n  let res = await api.getPosts({ type_: \"all\", limit: 50 });\n  await Promise.allSettled(\n    Array.from(new Set(res.items.map(p => p.post.id)))\n      .map(post_id => api.purgePost({ post_id, reason: \"purge\" }))\n      // Ignore errors\n      .map(p => p.catch(e => e)),\n  );\n}\n\nexport function getCommentParentId(comment: Comment): number | undefined {\n  let split = comment.path.split(\".\");\n  // remove the 0\n  split.shift();\n\n  if (split.length > 1) {\n    return Number(split[split.length - 2]);\n  } else {\n    console.error(`Failed to extract comment parent id from ${comment.path}`);\n    return undefined;\n  }\n}\n\nexport async function waitUntil<T>(\n  fetcher: () => Promise<T>,\n  checker: (t: T) => boolean,\n  retries = 10,\n  delaySeconds = [0.2, 0.5, 1, 2, 3],\n) {\n  let retry = 0;\n  let result;\n  while (retry++ < retries) {\n    try {\n      result = await fetcher();\n      if (checker(result)) return result;\n    } catch (error) {\n      console.error(error);\n    }\n    await delay(delaySeconds[(retry - 1) % delaySeconds.length] * 1000);\n  }\n  console.error(\"result\", result);\n  throw Error(\n    `Failed \"${fetcher}\": \"${checker}\" did not return true after ${retries} retries (delayed ${delaySeconds}s each)`,\n  );\n}\n\n// Do not use this function directly, only use `waitUntil()`\nfunction delay(millis = 500) {\n  return new Promise(resolve => setTimeout(resolve, millis));\n}\n\nexport function assertCommunityFederation(\n  communityOne?: CommunityView,\n  communityTwo?: CommunityView,\n) {\n  expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_id);\n  expect(communityOne?.community.name).toBe(communityTwo?.community.name);\n  expect(communityOne?.community.title).toBe(communityTwo?.community.title);\n  expect(communityOne?.community.sidebar).toBe(communityTwo?.community.sidebar);\n  expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);\n  expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);\n  expect(communityOne?.community.published_at).toBe(\n    communityTwo?.community.published_at,\n  );\n  expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);\n  expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);\n  expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);\n}\n\n/**\n * Jest officially doesn't support deep checking custom errors,\n * so we have to check each field manually.\n *\n * https://github.com/jestjs/jest/issues/15378\n **/\nexport async function jestLemmyError<T>(\n  fetcher: () => Promise<T>,\n  err: LemmyError,\n  checkMessage = true,\n) {\n  try {\n    await fetcher();\n  } catch (e) {\n    expect(e.name).toBe(err.name);\n    expect(e.status).toBe(err.status);\n\n    if (checkMessage) {\n      expect(e.message).toBe(err.message);\n    }\n  }\n}\n"
  },
  {
    "path": "api_tests/src/speed.spec.ts",
    "content": "// This is meant to be used with an already-filled / production db with lots of history.\n// Requires env vars:\n//\n// LEMMY_SERVER_URL (ex http://localhost:8536)\n// LEMMY_LOGIN\n// LEMMY_PASSWORD\n\njest.setTimeout(120000);\n\nimport {\n  CommentId,\n  CommentSortType,\n  CommunitySortType,\n  LemmyHttp,\n  LikeType,\n  ListingType,\n  Login,\n  ModlogKindFilter,\n  MultiCommunitySortType,\n  NotificationTypeFilter,\n  PersonContentType,\n  PostId,\n  PostSortType,\n  SearchSortType,\n  SearchType,\n} from \"lemmy-js-client\";\nimport { fetchFunction } from \"./shared\";\nimport * as fs from \"fs\";\n\nconst defaultServerUrl = \"http://localhost:8536\";\nconst defaultLogin = \"lemmy\";\nconst defaultPassword = \"lemmylemmy\";\nconst postCommentsMaxDepth = 8;\n\nconst samplePerson = \"dessalines\";\nconst sampleCommunity = \"memes\";\n/// A multicommunity with several high-volume communities in it.\nconst sampleMultiCommunity = \"test_1\";\nconst searchTerm = \"test\";\n\n// Post without a url\nconst textPost: PostId = 43615136;\n\n// Post with a url\nconst postWithUrl: PostId = 43614333;\n\n// A post with ~2.2k comments\nconst postWithLotsOfComments: PostId = 3192572;\n\nconst sampleComment: CommentId = 24109064;\n\nlet api: LemmyHttp;\nlet report: string[] = [];\n\nbeforeAll(async () => {\n  api = new LemmyHttp(process.env.LEMMY_SERVER_URL ?? defaultServerUrl, {\n    fetchFunction,\n  });\n  const login: Login = {\n    username_or_email: process.env.LEMMY_LOGIN ?? defaultLogin,\n    password: process.env.LEMMY_PASSWORD ?? defaultPassword,\n  };\n  const res = await api.login(login);\n  api.setHeaders({ Authorization: `Bearer ${res.jwt ?? \"\"}` });\n});\nafterAll(() => {\n  const reportMd = report.join(\"\\n\");\n  fs.writeFileSync(\"speed_test_report.md\", reportMd);\n  console.log(reportMd);\n});\n\ntest(\"List posts with different sorts\", async () => {\n  report.push(\"\\n# List posts with different sorts \\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const sortTypes: PostSortType[] = [\n    \"active\",\n    \"hot\",\n    \"new\",\n    \"top\",\n    \"controversial\",\n  ];\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() => api.getPosts({ sort }));\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List posts for a community with different sorts\", async () => {\n  report.push(\"\\n# List posts for a community with different sorts \\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const sortTypes: PostSortType[] = [\n    \"active\",\n    \"hot\",\n    \"new\",\n    \"top\",\n    \"controversial\",\n  ];\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() =>\n      api.getPosts({ sort, community_name: sampleCommunity }),\n    );\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List posts with different listing types\", async () => {\n  report.push(\"\\n# List posts with different listing types \\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n  const listingTypes: ListingType[] = [\n    \"all\",\n    \"local\",\n    \"subscribed\",\n    \"moderator_view\",\n    \"suggested\",\n  ];\n  for (let type_ of listingTypes) {\n    const time = await timeApiCalls(() => api.getPosts({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List posts with show hidden\", async () => {\n  report.push(\"\\n# List posts with show hidden \\n\");\n  const time = await timeApiCalls(() => api.getPosts({ show_hidden: true }));\n  report.push(`show hidden: ${formatMs(time)}`);\n});\n\ntest(\"List posts with hide read\", async () => {\n  report.push(\"\\n# List posts with hide read \\n\");\n  const time = await timeApiCalls(() => api.getPosts({ show_read: false }));\n  report.push(`show read : ${formatMs(time)}`);\n});\n\ntest(\"List posts with higher pages\", async () => {\n  report.push(\"\\n# List posts with higher pages\\n\");\n  report.push(\"page # | time\");\n  report.push(\"--- | ---\");\n  let page_cursor: string | undefined = undefined;\n\n  let diffs = [];\n  for (let i = 0; i < 10; i++) {\n    const res = await timeApiCall(() =>\n      api.getPosts({ sort: \"new\", page_cursor }),\n    );\n    page_cursor = res.res.next_page;\n    diffs.push(res.diff);\n    report.push(`${i} | ${formatMs(res.diff)}`);\n  }\n\n  const avg = average(diffs);\n  report.push(`avg | ${formatMs(avg)}`);\n});\n\ntest(\"List posts for a multi-community with different sorts\", async () => {\n  report.push(\"\\n# List posts for a multi-community with different sorts \\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const sortTypes: PostSortType[] = [\n    \"active\",\n    \"hot\",\n    \"new\",\n    \"top\",\n    \"controversial\",\n  ];\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() =>\n      api.getPosts({ sort, multi_community_name: sampleMultiCommunity }),\n    );\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List communities with different sorts\", async () => {\n  report.push(\"\\n# List communities with different sorts \\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const sortTypes: CommunitySortType[] = [\n    \"active_six_months\",\n    \"active_monthly\",\n    \"active_weekly\",\n    \"active_daily\",\n    \"hot\",\n    \"new\",\n    \"old\",\n    \"name_asc\",\n    \"name_desc\",\n    \"comments\",\n    \"posts\",\n    \"subscribers\",\n    \"subscribers_local\",\n  ];\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() => api.listCommunities({ sort }));\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List communities with different listing types\", async () => {\n  report.push(\"\\n# List communities with different listing types \\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n  const listingTypes: ListingType[] = [\n    \"all\",\n    \"local\",\n    \"subscribed\",\n    \"moderator_view\",\n    \"suggested\",\n  ];\n  for (let type_ of listingTypes) {\n    const time = await timeApiCalls(() => api.listCommunities({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List multi-communities with different sorts\", async () => {\n  report.push(\"\\n# List multi-communities with different sorts \\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const sortTypes: MultiCommunitySortType[] = [\n    \"new\",\n    \"old\",\n    \"name_asc\",\n    \"name_desc\",\n    \"communities\",\n    \"subscribers\",\n    \"subscribers_local\",\n  ];\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() => api.listMultiCommunities({ sort }));\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"Get a community\", async () => {\n  report.push(\"\\n# Get a community \\n\");\n  const time = await timeApiCalls(() =>\n    api.getCommunity({ name: sampleCommunity }),\n  );\n  report.push(`get community: ${formatMs(time)}`);\n});\n\ntest.skip(\"Get a post\", async () => {\n  report.push(\"\\n# Get a post\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const getUrlPost = await timeApiCalls(() => api.getPost({ id: postWithUrl }));\n  report.push(`url post | ${formatMs(getUrlPost)}`);\n\n  const getTextPost = await timeApiCalls(() => api.getPost({ id: textPost }));\n  report.push(`text post | ${formatMs(getTextPost)}`);\n});\n\ntest(\"Get comments for a post with different sorts\", async () => {\n  report.push(\"\\n# Get comments for a post with different sorts\\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n\n  const sortTypes: CommentSortType[] = [\n    \"hot\",\n    \"new\",\n    \"old\",\n    \"top\",\n    \"controversial\",\n  ];\n\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() =>\n      api.getComments({\n        post_id: postWithLotsOfComments,\n        sort,\n        max_depth: postCommentsMaxDepth,\n      }),\n    );\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"Get comments for a post slim\", async () => {\n  report.push(\"\\n# Get comments for a post slim\\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n  const getCommentsSlim = await timeApiCalls(() =>\n    api.getCommentsSlim({\n      post_id: postWithLotsOfComments,\n      max_depth: postCommentsMaxDepth,\n    }),\n  );\n  report.push(`getCommentsSlim: ${formatMs(getCommentsSlim)}`);\n});\n\ntest(\"Get all comments with different sorts\", async () => {\n  report.push(\"\\n# Get all comments with different sorts\\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n\n  const sortTypes: CommentSortType[] = [\n    \"hot\",\n    \"new\",\n    \"old\",\n    \"top\",\n    \"controversial\",\n  ];\n\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() => api.getComments({ sort }));\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"Get comments with different types\", async () => {\n  report.push(\"\\n# Get comments with different types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const listingTypes: ListingType[] = [\n    \"all\",\n    \"local\",\n    \"subscribed\",\n    \"moderator_view\",\n    \"suggested\",\n  ];\n\n  for (let type_ of listingTypes) {\n    const time = await timeApiCalls(() => api.getComments({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List person content with types\", async () => {\n  report.push(\"\\n# List person content with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const contentTypes: PersonContentType[] = [\"all\", \"comments\", \"posts\"];\n\n  for (let type_ of contentTypes) {\n    const time = await timeApiCalls(() =>\n      api.listPersonContent({ username: samplePerson, type_ }),\n    );\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List person saved with types\", async () => {\n  report.push(\"\\n# List person saved with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const contentTypes: PersonContentType[] = [\"all\", \"comments\", \"posts\"];\n\n  for (let type_ of contentTypes) {\n    const time = await timeApiCalls(() => api.listPersonSaved({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List person liked with types\", async () => {\n  report.push(\"\\n# List person liked with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const contentTypes: PersonContentType[] = [\"all\", \"comments\", \"posts\"];\n\n  for (let type_ of contentTypes) {\n    const time = await timeApiCalls(() => api.listPersonLiked({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n\n  const likeType: LikeType[] = [\"all\", \"liked_only\", \"disliked_only\"];\n\n  for (let like_type of likeType) {\n    const time = await timeApiCalls(() => api.listPersonLiked({ like_type }));\n    report.push(`${like_type} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"List person read\", async () => {\n  report.push(\"\\n# List person read\\n\");\n\n  const time = await timeApiCalls(() => api.listPersonRead({}));\n  report.push(`list person read: ${formatMs(time)}`);\n});\n\ntest(\"List person hidden\", async () => {\n  report.push(\"\\n# List person hidden\\n\");\n\n  const time = await timeApiCalls(() => api.listPersonHidden({}));\n  report.push(`list person hidden: ${formatMs(time)}`);\n});\n\ntest(\"List registration applications\", async () => {\n  report.push(\"\\n# List registration applications\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const unreadOnly = await timeApiCalls(() =>\n    api.listRegistrationApplications({ unread_only: true }),\n  );\n  const all = await timeApiCalls(() => api.listRegistrationApplications({}));\n  report.push(`unread only | ${formatMs(unreadOnly)}`);\n  report.push(`all | ${formatMs(all)}`);\n});\n\ntest(\"List reports\", async () => {\n  report.push(\"\\n# List reports\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const unresolvedOnly = await timeApiCalls(() =>\n    api.listReports({ unresolved_only: true }),\n  );\n  const all = await timeApiCalls(() => api.listReports({}));\n  report.push(`unresolved only | ${formatMs(unresolvedOnly)}`);\n  report.push(`all | ${formatMs(all)}`);\n});\n\ntest.skip(\"Search with types\", async () => {\n  report.push(\"\\n# Search with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const searchTypes: SearchType[] = [\n    \"all\",\n    \"comments\",\n    \"posts\",\n    \"communities\",\n    \"users\",\n    \"multi_communities\",\n  ];\n\n  for (let type_ of searchTypes) {\n    const time = await timeApiCalls(() => api.search({ q: searchTerm, type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest.skip(\"Search with sorts\", async () => {\n  report.push(\"\\n# Search with sorts\\n\");\n  report.push(\"sort | time\");\n  report.push(\"--- | ---\");\n\n  const sortTypes: SearchSortType[] = [\"new\", \"old\", \"top\"];\n\n  for (let sort of sortTypes) {\n    const time = await timeApiCalls(() => api.search({ q: searchTerm, sort }));\n    report.push(`${sort} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"Notifications with types\", async () => {\n  report.push(\"\\n# Notifications with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const notificationTypes: NotificationTypeFilter[] = [\n    \"all\",\n    \"mention\",\n    \"reply\",\n    \"subscribed\",\n    \"private_message\",\n    \"mod_action\",\n  ];\n\n  for (let type_ of notificationTypes) {\n    const time = await timeApiCalls(() => api.listNotifications({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\ntest(\"Notifications with unread only\", async () => {\n  report.push(\"\\n# Notifications with unread only\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const unreadOnly = await timeApiCalls(() =>\n    api.listNotifications({ unread_only: true }),\n  );\n  const all = await timeApiCalls(() => api.listNotifications({}));\n  report.push(`all | ${formatMs(all)}`);\n  report.push(`unread_only | ${formatMs(unreadOnly)}`);\n});\n\ntest(\"Liking a comment / post\", async () => {\n  report.push(\"\\n# Liking a comment / post\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const commentLike = await timeApiCall(() =>\n    api.likeComment({ comment_id: sampleComment }),\n  );\n  const postLike = await timeApiCall(() =>\n    api.likePost({ post_id: postWithUrl }),\n  );\n  report.push(`comment | ${formatMs(commentLike.diff)}`);\n  report.push(`post | ${formatMs(postLike.diff)}`);\n});\n\ntype Result<T> = {\n  diff: number;\n  res: T;\n};\n\ntest(\"Get modlog with types\", async () => {\n  report.push(\"\\n# Get modlog with types\\n\");\n  report.push(\"type | time\");\n  report.push(\"--- | ---\");\n\n  const modlogKinds: ModlogKindFilter[] = [\n    \"all\",\n    \"admin_add\",\n    \"admin_ban\",\n    \"admin_allow_instance\",\n    \"admin_block_instance\",\n    \"admin_purge_comment\",\n    \"admin_purge_community\",\n    \"admin_purge_person\",\n    \"admin_purge_post\",\n    \"mod_add_to_community\",\n    \"mod_ban_from_community\",\n    \"admin_feature_post_site\",\n    \"mod_feature_post_community\",\n    \"mod_change_community_visibility\",\n    \"mod_lock_post\",\n    \"mod_remove_comment\",\n    \"admin_remove_community\",\n    \"mod_remove_post\",\n    \"mod_transfer_community\",\n    \"mod_lock_comment\",\n  ];\n\n  for (let type_ of modlogKinds) {\n    const time = await timeApiCalls(() => api.getModlog({ type_ }));\n    report.push(`${type_} | ${formatMs(time)}`);\n  }\n});\n\nasync function timeApiCall<T>(promise: () => Promise<T>): Promise<Result<T>> {\n  const start = performance.now();\n  const res = await promise();\n  const end = performance.now();\n  const diff = timeDiff(start, end);\n  return {\n    diff,\n    res,\n  };\n}\n\nasync function timeApiCalls<T>(promise: () => Promise<T>, times = 10) {\n  let diffs = [];\n  for (let i = 0; i < times; i++) {\n    const diff = (await timeApiCall(promise)).diff;\n    diffs.push(diff);\n  }\n  return average(diffs);\n}\n\nfunction timeDiff(start: number, end: number) {\n  return end - start;\n}\n\nfunction average(arr: number[]) {\n  return arr.reduce((p, c) => p + c, 0) / arr.length;\n}\n\nfunction formatMs(time: number): string {\n  return `${time.toFixed(0)}ms`;\n}\n"
  },
  {
    "path": "api_tests/src/tags.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport {\n  alpha,\n  beta,\n  setupLogins,\n  createCommunity,\n  unfollows,\n  randomString,\n  followCommunity,\n  resolveCommunity,\n  waitUntil,\n  assertCommunityFederation,\n  waitForPost,\n  gamma,\n  resolvePerson,\n  getCommunity,\n} from \"./shared\";\nimport { CreateCommunityTag } from \"lemmy-js-client/dist/types/CreateCommunityTag\";\nimport { DeleteCommunityTag } from \"lemmy-js-client/dist/types/DeleteCommunityTag\";\nimport { AddModToCommunity } from \"lemmy-js-client\";\n\nbeforeAll(setupLogins);\nafterAll(unfollows);\n\ntest(\"Create, delete and restore a community tag\", async () => {\n  // Create a community first\n  const communityRes = await createCommunity(alpha);\n  let alphaCommunity = communityRes.community_view;\n  let betaCommunity = (await resolveCommunity(\n    beta,\n    alphaCommunity.community.ap_id,\n  ))!;\n  await followCommunity(beta, true, betaCommunity.community.id);\n  await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g?.community_actions!.follow_state == \"accepted\",\n  );\n  const communityId = alphaCommunity.community.id;\n\n  // Create a tag\n  const tagName = randomString(10);\n  let createForm: CreateCommunityTag = {\n    name: tagName,\n    community_id: communityId,\n  };\n  let createRes = await alpha.createCommunityTag(createForm);\n  expect(createRes.id).toBeDefined();\n  expect(createRes.name).toBe(tagName);\n  expect(createRes.community_id).toBe(communityId);\n\n  alphaCommunity = (await alpha.getCommunity({ id: communityId }))\n    .community_view;\n  expect(alphaCommunity.tags.length).toBe(1);\n  // verify tag federated\n\n  betaCommunity = (await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g!.tags.length === 1,\n  ))!;\n  assertCommunityFederation(alphaCommunity, betaCommunity);\n\n  // List tags\n  alphaCommunity = (await alpha.getCommunity({ id: communityId }))\n    .community_view;\n  expect(alphaCommunity.tags.length).toBe(1);\n  expect(alphaCommunity.tags.find(t => t.id === createRes.id)?.name).toBe(\n    tagName,\n  );\n\n  // Verify tag update federated\n  betaCommunity = (await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g!.tags.find(t => t.ap_id === createRes.ap_id)?.name === tagName,\n  ))!;\n  assertCommunityFederation(alphaCommunity, betaCommunity);\n\n  // Delete the tag\n  let deleteForm: DeleteCommunityTag = {\n    tag_id: createRes.id,\n    delete: true,\n  };\n  let deleteRes = await alpha.deleteCommunityTag(deleteForm);\n  expect(deleteRes.id).toBe(createRes.id);\n\n  // Verify tag is deleted\n  alphaCommunity = (await alpha.getCommunity({ id: communityId }))\n    .community_view;\n  expect(\n    alphaCommunity.tags.find(t => t.id === createRes.id)!.deleted,\n  ).toBeTruthy();\n  // It should still list one tag\n  expect(alphaCommunity.tags.length).toBe(1);\n\n  // Verify tag deletion federated\n  betaCommunity = (await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g!.tags.at(0)?.deleted === true,\n  ))!;\n  assertCommunityFederation(alphaCommunity, betaCommunity);\n\n  // Restore the tag\n  let deleteFormRestoration: DeleteCommunityTag = {\n    tag_id: createRes.id,\n    delete: false,\n  };\n  let deleteRestorationRes = await alpha.deleteCommunityTag(\n    deleteFormRestoration,\n  );\n  expect(deleteRestorationRes.id).toBe(createRes.id);\n\n  // Verify tag is restored\n  alphaCommunity = (await alpha.getCommunity({ id: communityId }))\n    .community_view;\n  expect(alphaCommunity.tags.length).toBe(1);\n  // verify tag federated\n\n  betaCommunity = (await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g!.tags.length === 1,\n  ))!;\n  assertCommunityFederation(alphaCommunity, betaCommunity);\n\n  // List tags\n  alphaCommunity = (await alpha.getCommunity({ id: communityId }))\n    .community_view;\n  expect(alphaCommunity.tags.length).toBe(1);\n  expect(alphaCommunity.tags.find(t => t.id === createRes.id)?.name).toBe(\n    tagName,\n  );\n});\n\ntest(\"Remote mod creates and updates post tag\", async () => {\n  // Create a community\n  let communityRes = await createCommunity(alpha);\n  let alphaCommunity = communityRes.community_view;\n\n  // add gamma as remote mod\n  let gammaOnAlpha = await resolvePerson(alpha, \"lemmy_gamma@lemmy-gamma:8561\");\n\n  let form: AddModToCommunity = {\n    community_id: communityRes.community_view.community.id,\n    person_id: gammaOnAlpha?.person.id as number,\n    added: true,\n  };\n  alpha.addModToCommunity(form);\n\n  let gammaCommunity = await resolveCommunity(\n    gamma,\n    alphaCommunity.community.ap_id,\n  );\n\n  // Remote mod gamma creates tag\n  const tag1Name = \"news\";\n  let createForm1: CreateCommunityTag = {\n    name: tag1Name,\n    community_id: gammaCommunity!.community.id,\n  };\n  let tag1Res = await gamma.createCommunityTag(createForm1);\n  expect(tag1Res.id).toBeDefined();\n\n  await waitUntil(\n    () => getCommunity(alpha, communityRes.community_view.community.id),\n    c => c.community_view.tags.length == 1,\n  );\n\n  let betaCommunity = await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    c => c?.tags.length == 1,\n  );\n\n  // follow from beta\n  await followCommunity(beta, true, betaCommunity!.community.id);\n  await waitUntil(\n    () => resolveCommunity(beta, alphaCommunity.community.ap_id),\n    g => g!.community_actions?.follow_state == \"accepted\",\n  );\n\n  // Create a post with tag\n  let postRes = await beta.createPost({\n    name: randomString(10),\n    community_id: betaCommunity!.community.id,\n    tags: [betaCommunity!.tags[0].id],\n  });\n  expect(postRes.post_view.post.id).toBeDefined();\n  expect(postRes.post_view.post.id).toBe(postRes.post_view.post.id);\n  expect(postRes.post_view.tags?.length).toBe(1);\n  expect(postRes.post_view.tags?.map(t => t.id)).toEqual([\n    betaCommunity!.tags[0].id,\n  ]);\n\n  // wait post tags federated\n  let alphaPost = await waitForPost(\n    alpha,\n    postRes.post_view.post,\n    p => (p?.tags.length ?? 0) > 0,\n  );\n  expect(alphaPost?.tags.length).toBe(1);\n  expect(alphaPost?.tags.map(t => t.ap_id)).toEqual([tag1Res.ap_id]);\n\n  // Mod on alpha updates post to remove one tag\n  communityRes = await getCommunity(\n    alpha,\n    communityRes.community_view.community.id,\n  );\n  alphaCommunity = communityRes.community_view;\n  let updateRes = await alpha.modEditPost({\n    post_id: alphaPost.post.id,\n    tags: [alphaCommunity!.tags[0].id],\n  });\n  expect(updateRes.post_view.post.ap_id).toBe(postRes.post_view.post.ap_id);\n  expect(updateRes.post_view.tags?.length).toBe(1);\n  expect(updateRes.post_view.tags?.[0].id).toBe(alphaCommunity!.tags[0].id);\n\n  // wait post tags federated\n  let betaPost = await waitForPost(beta, postRes.post_view.post, p => {\n    return (p?.tags.length ?? 0) === 1;\n  });\n  expect(betaPost?.tags.map(t => t.ap_id)).toEqual([tag1Res.ap_id]);\n});\n"
  },
  {
    "path": "api_tests/src/user.spec.ts",
    "content": "jest.setTimeout(120000);\n\nimport { PersonView } from \"lemmy-js-client/dist/types/PersonView\";\nimport {\n  alpha,\n  beta,\n  registerUser,\n  resolvePerson,\n  getSite,\n  createPost,\n  resolveCommunity,\n  createComment,\n  resolveBetaCommunity,\n  deleteUser,\n  saveUserSettingsFederated,\n  setupLogins,\n  alphaUrl,\n  betaUrl,\n  saveUserSettings,\n  getPost,\n  getComments,\n  fetchFunction,\n  alphaImage,\n  unfollows,\n  getMyUser,\n  getPersonDetails,\n  banPersonFromSite,\n  statusNotFound,\n  statusUnauthorized,\n  listPersonContent,\n  waitUntil,\n  password,\n  jestLemmyError,\n  statusBadRequest,\n  randomString,\n} from \"./shared\";\nimport {\n  EditSite,\n  LemmyError,\n  LemmyHttp,\n  SaveUserSettings,\n  UploadImage,\n} from \"lemmy-js-client\";\nimport { GetPosts } from \"lemmy-js-client/dist/types/GetPosts\";\n\nbeforeAll(setupLogins);\nafterAll(unfollows);\n\nlet apShortname: string;\n\nfunction assertUserFederation(userOne?: PersonView, userTwo?: PersonView) {\n  expect(userOne?.person.name).toBe(userTwo?.person.name);\n  expect(userOne?.person.display_name).toBe(userTwo?.person.display_name);\n  expect(userOne?.person.bio).toBe(userTwo?.person.bio);\n  expect(userOne?.person.ap_id).toBe(userTwo?.person.ap_id);\n  expect(userOne?.person.avatar).toBe(userTwo?.person.avatar);\n  expect(userOne?.person.banner).toBe(userTwo?.person.banner);\n  expect(userOne?.person.published_at).toBe(userTwo?.person.published_at);\n}\n\ntest(\"Create user\", async () => {\n  let user = await registerUser(alpha, alphaUrl);\n\n  let myUser = await getMyUser(user);\n  expect(myUser).toBeDefined();\n  apShortname = `${myUser.local_user_view.person.name}@lemmy-alpha:8541`;\n});\n\ntest(\"Set some user settings, check that they are federated\", async () => {\n  await saveUserSettingsFederated(alpha);\n  let alphaPerson = await resolvePerson(alpha, apShortname);\n  let betaPerson = await resolvePerson(beta, apShortname);\n  assertUserFederation(alphaPerson, betaPerson);\n\n  // Catches a bug where when only the person or local_user changed\n  let form: SaveUserSettings = {\n    theme: \"test\",\n  };\n  await saveUserSettings(beta, form);\n\n  let my_user = await getMyUser(beta);\n  expect(my_user.local_user_view.local_user.theme).toBe(\"test\");\n});\n\ntest(\"Delete user\", async () => {\n  let user = await registerUser(alpha, alphaUrl);\n  let user_profile = await getMyUser(user);\n  let person_id = user_profile.local_user_view.person.id;\n\n  // make a local post and comment\n  let alphaCommunity = await resolveCommunity(user, \"main@lemmy-alpha:8541\");\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n  let localPost = (await createPost(user, alphaCommunity.community.id))\n    .post_view.post;\n  expect(localPost).toBeDefined();\n  let localComment = (await createComment(user, localPost.id)).comment_view\n    .comment;\n  expect(localComment).toBeDefined();\n\n  // make a remote post and comment\n  let betaCommunity = await resolveBetaCommunity(user);\n  if (!betaCommunity) {\n    throw \"Missing beta community\";\n  }\n  let remotePost = (await createPost(user, betaCommunity.community.id))\n    .post_view.post;\n  expect(remotePost).toBeDefined();\n  let remoteComment = (await createComment(user, remotePost.id)).comment_view\n    .comment;\n  expect(remoteComment).toBeDefined();\n\n  await deleteUser(user);\n\n  // Wait, in order to make sure it federates\n  await jestLemmyError(\n    () => getMyUser(user),\n    new LemmyError(\"incorrect_login\", statusUnauthorized),\n  );\n\n  await jestLemmyError(\n    () => getPersonDetails(user, person_id),\n    new LemmyError(\"not_found\", statusNotFound),\n  );\n\n  // check that posts and comments are marked as deleted on other instances.\n  // use get methods to avoid refetching from origin instance\n  expect((await getPost(alpha, localPost.id)).post_view.post.deleted).toBe(\n    true,\n  );\n  // Make sure the remote post is deleted.\n  // TODO this fails occasionally\n  // Probably because it could return a not_found\n  // await waitUntil(\n  //   () => getPost(alpha, remotePost.id),\n  //   p => p.post_view.post.deleted === true || p.post_view.post === undefined,\n  // );\n  await waitUntil(\n    () => getComments(alpha, localComment.post_id),\n    c => c.items[0].comment.deleted,\n  );\n  await waitUntil(\n    () => alpha.getComment({ id: remoteComment.id }),\n    c => c.comment_view.comment.deleted,\n  );\n  await jestLemmyError(\n    () => getPersonDetails(user, remoteComment.creator_id),\n    new LemmyError(\"not_found\", statusNotFound),\n  );\n});\n\ntest(\"Requests with invalid auth should be treated as unauthenticated\", async () => {\n  let invalid_auth = new LemmyHttp(alphaUrl, {\n    headers: { Authorization: \"Bearer foobar\" },\n    fetchFunction,\n  });\n  await jestLemmyError(\n    () => getMyUser(invalid_auth),\n    new LemmyError(\"incorrect_login\", statusUnauthorized),\n  );\n  let site = await getSite(invalid_auth);\n  expect(site.site_view).toBeDefined();\n\n  let form: GetPosts = {};\n  let posts = invalid_auth.getPosts(form);\n  expect((await posts).items).toBeDefined();\n});\n\ntest(\"Create user with Arabic name\", async () => {\n  // less than actor_name_max_length\n  const name = \"تجريب\" + Math.random().toString().slice(2, 10);\n  let user = await registerUser(alpha, alphaUrl, name);\n\n  let my_user = await getMyUser(user);\n  expect(my_user).toBeDefined();\n  apShortname = `${my_user.local_user_view.person.name}@lemmy-alpha:8541`;\n\n  let betaPerson1 = await resolvePerson(beta, apShortname);\n  expect(betaPerson1!.person.name).toBe(name);\n\n  let betaPerson2 = await getPersonDetails(beta, betaPerson1!.person.id);\n  expect(betaPerson2!.person_view.person.name).toBe(name);\n});\n\ntest(\"Create user with accept-language\", async () => {\n  const edit: EditSite = {\n    discussion_languages: [32],\n  };\n  await alpha.editSite(edit);\n\n  let lemmy_http = new LemmyHttp(alphaUrl, {\n    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language#syntax\n    headers: { \"Accept-Language\": \"fr-CH, en;q=0.8, *;q=0.5\" },\n  });\n  let user = await registerUser(lemmy_http, alphaUrl);\n\n  let my_user = await getMyUser(user);\n  expect(my_user).toBeDefined();\n  expect(my_user?.local_user_view.local_user.interface_language).toBe(\"fr\");\n  let site = await getSite(user);\n  let langs = site.all_languages\n    .filter(a => my_user.discussion_languages.includes(a.id))\n    .map(l => l.code)\n    .sort();\n  // should have languages from accept header, as well as \"undetermined\"\n  // which is automatically enabled by backend\n  expect(langs).toStrictEqual([\"de\", \"en\", \"fr\"]);\n});\n\ntest(\"Set a new avatar, old avatar is deleted\", async () => {\n  const listMediaRes = await alphaImage.listMedia();\n  expect(listMediaRes.items.length).toBe(0);\n  const upload_form1: UploadImage = {\n    image: Buffer.from(\"test1\"),\n  };\n  await alpha.uploadUserAvatar(upload_form1);\n  const listMediaRes1 = await alphaImage.listMedia();\n  expect(listMediaRes1.items.length).toBe(1);\n\n  let my_user1 = await alpha.getMyUser();\n  expect(my_user1.local_user_view.person.avatar).toBeDefined();\n\n  const upload_form2: UploadImage = {\n    image: Buffer.from(\"test2\"),\n  };\n  await alpha.uploadUserAvatar(upload_form2);\n  // make sure only the new avatar is kept\n  const listMediaRes2 = await alphaImage.listMedia();\n  expect(listMediaRes2.items.length).toBe(1);\n\n  // Upload that same form2 avatar, make sure it isn't replaced / deleted\n  await alpha.uploadUserAvatar(upload_form2);\n  // make sure only the new avatar is kept\n  const listMediaRes3 = await alphaImage.listMedia();\n  expect(listMediaRes3.items.length).toBe(1);\n\n  // make sure only the new avatar is kept\n  const listMediaRes4 = await alphaImage.listMedia();\n  expect(listMediaRes4.items.length).toBe(1);\n\n  // delete the avatar\n  await alpha.deleteUserAvatar();\n  // make sure only the new avatar is kept\n  const listMediaRes5 = await alphaImage.listMedia();\n  expect(listMediaRes5.items.length).toBe(0);\n  let my_user2 = await alpha.getMyUser();\n  expect(my_user2.local_user_view.person.avatar).toBeUndefined();\n});\n\ntest(\"Make sure banned user can delete their account\", async () => {\n  let user = await registerUser(alpha, alphaUrl);\n  let myUser = await getMyUser(user);\n\n  // make a local post\n  let alphaCommunity = await resolveCommunity(user, \"main@lemmy-alpha:8541\");\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n  let localPost = (await createPost(user, alphaCommunity.community.id))\n    .post_view.post;\n  let postId = localPost.id;\n  expect(localPost).toBeDefined();\n\n  // Ban the user, keep data\n  let banUser = await banPersonFromSite(\n    alpha,\n    myUser.local_user_view.person.id,\n    true,\n    false,\n  );\n  expect(banUser.person_view.banned).toBe(true);\n\n  // Make sure post is there\n  let postAfterBan = await getPost(alpha, postId);\n  expect(postAfterBan.post_view.post.deleted).toBe(false);\n\n  // Delete account\n  let deleteAccount = await deleteUser(user);\n  expect(deleteAccount).toBeDefined();\n\n  // Make sure post is gone\n  let postAfterDelete = await getPost(alpha, postId);\n  expect(postAfterDelete.post_view.post.deleted).toBe(true);\n  expect(postAfterDelete.post_view.post.name).toBe(\"*Permanently Deleted*\");\n});\n\ntest(\"Admins can view and ban deleted accounts\", async () => {\n  let user = await registerUser(beta, betaUrl);\n  let myUser = await getMyUser(user);\n  let apShortname = `${myUser.local_user_view.person.name}@lemmy-beta:8551`;\n  let userOnAlpha = await resolvePerson(alpha, apShortname);\n\n  let alphaCommunity = await resolveCommunity(user, \"main@lemmy-alpha:8541\");\n  if (!alphaCommunity) {\n    throw \"Missing alpha community\";\n  }\n\n  // Make a post and then delete the account\n  let postRes = await createPost(user, alphaCommunity.community.id);\n  let deletedUser = await deleteUser(user, false);\n  expect(deletedUser).toBeDefined();\n  // Make sure the post is still visible\n  let postAfterDelete = await getPost(beta, postRes.post_view.post.id);\n  expect(postAfterDelete.post_view.post.deleted).toBe(false);\n\n  // Ensure admins can still resolve the user\n  let getDeletedUser = await getPersonDetails(\n    beta,\n    myUser.local_user_view.person.id,\n  );\n  expect(getDeletedUser).toBeDefined();\n\n  // Make sure the delete federates\n  await waitUntil(\n    () => getPersonDetails(alpha, userOnAlpha!.person.id),\n    p => p.person_view.person.deleted,\n  );\n\n  // Ban the user\n  let banUser = await banPersonFromSite(\n    beta,\n    myUser.local_user_view.person.id,\n    true,\n    true,\n  );\n  expect(banUser.person_view.banned).toBe(true);\n  // Make sure the post is removed\n  let postAfterBan = await getPost(beta, postRes.post_view.post.id);\n  expect(postAfterBan.post_view.post.removed).toBe(true);\n\n  // Make sure the ban federates properly\n  let getDeletedUserAlpha = await waitUntil(\n    () => getPersonDetails(alpha, userOnAlpha!.person.id),\n    p => p.person_view.banned,\n  );\n  // Make sure content removal also went through\n  let userContent = await listPersonContent(\n    alpha,\n    getDeletedUserAlpha.person_view.person.id,\n    \"posts\",\n  );\n  expect(userContent.items[0].post.removed).toBe(true);\n});\n\ntest(\"Make sure a denied user is given denial reason\", async () => {\n  const username = randomString(10);\n  const appAnswer = \"My application answer\";\n  const denyReason = \"Bad application given\";\n\n  // Make registrations approval only\n  await alpha.editSite({ registration_mode: \"require_application\" });\n\n  // Create an account with an answer\n  const login = await alpha.register({\n    username,\n    password,\n    password_verify: password,\n    show_nsfw: true,\n    answer: appAnswer,\n  });\n  expect(login.registration_created).toBeTruthy();\n  expect(login.jwt).toBeUndefined();\n\n  // Try to login with a bad password first\n  await jestLemmyError(\n    () =>\n      alpha.login({ username_or_email: username, password: \"wrong_password\" }),\n    new LemmyError(\"incorrect_login\", statusUnauthorized),\n  );\n\n  // Try to login without approval yet, should return is pending\n  await jestLemmyError(\n    () => alpha.login({ username_or_email: username, password }),\n    new LemmyError(\"registration_application_is_pending\", statusBadRequest),\n  );\n\n  // Fetch the applications\n  const apps = await alpha.listRegistrationApplications({});\n  const app = apps.items[0];\n  expect(apps.items.length).toBeGreaterThanOrEqual(1);\n  expect(app.registration_application.answer).toBe(appAnswer);\n\n  // Deny the application\n  await alpha.approveRegistrationApplication({\n    id: app.registration_application.id,\n    approve: false,\n    deny_reason: denyReason,\n  });\n\n  // Should give the denial reason in the error.\n  await jestLemmyError(\n    () => alpha.login({ username_or_email: username, password }),\n    new LemmyError(\"registration_denied\", statusBadRequest, denyReason),\n  );\n\n  // Re-open alpha\n  await alpha.editSite({ registration_mode: \"open\" });\n});\n"
  },
  {
    "path": "api_tests/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"declarationDir\": \"./dist\",\n    \"module\": \"CommonJS\",\n    \"noImplicitAny\": true,\n    \"lib\": [\"es2022\", \"es7\", \"es6\", \"dom\"],\n    \"outDir\": \"./dist\",\n    \"target\": \"ES2020\",\n    \"strictNullChecks\": true,\n    \"moduleResolution\": \"Node\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "cliff.toml",
    "content": "# git-cliff ~ configuration file\n# https://git-cliff.org/docs/configuration\n\n[remote.github]\nowner = \"LemmyNet\"\nrepo = \"lemmy\"\n# token = \"\"\n\n[changelog]\n# A Tera template to be rendered for each release in the changelog.\n# See https://keats.github.io/tera/docs/#introduction\nbody = \"\"\"\n## What's Changed\n\n{%- if version %} in {{ version }}{%- endif -%}\n{% for commit in commits %}\n  {% if commit.remote.pr_title -%}\n    {%- set commit_message = commit.remote.pr_title -%}\n  {%- else -%}\n    {%- set commit_message = commit.message -%}\n  {%- endif -%}\n  * {{ commit_message | split(pat=\"\\n\") | first | trim }}\\\n    {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}\n    {% if commit.remote.pr_number %} in \\\n      [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \\\n    {%- endif %}\n{%- endfor -%}\n\n{%- if github -%}\n{% if github.contributors | filter(attribute=\"is_first_time\", value=true) | length != 0 %}\n  {% raw %}\\n{% endraw -%}\n  ## New Contributors\n{%- endif %}\\\n{% for contributor in github.contributors | filter(attribute=\"is_first_time\", value=true) %}\n  * @{{ contributor.username }} made their first contribution\n    {%- if contributor.pr_number %} in \\\n      [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \\\n    {%- endif %}\n{%- endfor -%}\n{%- endif -%}\n\n{% if version %}\n    {% if previous.version %}\n      **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}\n    {% endif %}\n{% else -%}\n  {% raw %}\\n{% endraw %}\n{% endif %}\n\n{%- macro remote_url() -%}\n  https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\n{%- endmacro -%}\n\"\"\"\n# Remove leading and trailing whitespaces from the changelog's body.\ntrim = true\n# A Tera template to be rendered as the changelog's footer.\n# See https://keats.github.io/tera/docs/#introduction\nfooter = \"\"\"\n<!-- generated by git-cliff -->\n\"\"\"\n# An array of regex based postprocessors to modify the changelog.\n# Replace the placeholder `<REPO>` with a URL.\npostprocessors = []\n\n[git]\n# Parse commits according to the conventional commits specification.\n# See https://www.conventionalcommits.org\nconventional_commits = false\n# Exclude commits that do not match the conventional commits specification.\nfilter_unconventional = true\n# Split commits on newlines, treating each line as an individual commit.\nsplit_commits = false\n# An array of regex based parsers to modify commit messages prior to further processing.\ncommit_preprocessors = [{ pattern = '\\((\\w+\\s)?#([0-9]+)\\)', replace = \"\" }]\n# Exclude commits that are not matched by any commit parser.\ncommit_parsers = [{ field = \"author.name\", pattern = \"renovate\", skip = true }]\nfilter_commits = false\n# Order releases topologically instead of chronologically.\ntopo_order = false\n# Order of commits in each group/release within the changelog.\n# Allowed values: newest, oldest\nsort_commits = \"newest\"\n"
  },
  {
    "path": "config/config.hjson",
    "content": "# See the documentation for available config fields and descriptions:\n# https://join-lemmy.org/docs/en/administration/configuration.html\n{\n  hostname: lemmy-alpha\n}\n"
  },
  {
    "path": "config/defaults.hjson",
    "content": "{\n  # settings related to the postgresql database\n  database: {\n    # Configure the database by specifying URI pointing to a postgres instance. This parameter can\n    # also be set by environment variable `LEMMY_DATABASE_URL`.\n    # \n    # For an explanation of how to use connection URIs, see PostgreSQL's documentation:\n    # https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6\n    connection: \"postgres://lemmy:password@localhost:5432/lemmy\"\n    # Maximum number of active sql connections\n    # \n    # A high value here can result in errors \"could not resize shared memory segment\". In this case\n    # it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077\n    pool_size: 30\n  }\n  # Pictrs image server configuration.\n  pictrs: {\n    # Address where pictrs is available (for image hosting)\n    url: \"http://localhost:8080/\"\n    # Set a custom pictrs API key. ( Required for deleting images )\n    api_key: \"string\"\n  }\n  # Email sending configuration. All options except login/password are mandatory\n  email: {\n    # https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url\n    connection: \"smtps://user:pass@hostname:port\"\n    # Address to send emails from, eg \"noreply@your-instance.com\"\n    smtp_from_address: \"noreply@example.com\"\n  }\n  # Parameters for automatic configuration of new instance (only used at first start)\n  setup: {\n    # Username for the admin user\n    admin_username: \"admin\"\n    # Password for the admin user. It must be between 10 and 60 characters.\n    admin_password: \"tf6HHDS4RolWfFhk4Rq9\"\n    # Name of the site, can be changed later. Maximum 20 characters.\n    site_name: \"My Lemmy Instance\"\n    # Email for the admin user (optional, can be omitted and set later through the website)\n    admin_email: \"user@example.com\"\n    # On first start Lemmy fetches the 50 most active communities from one of these instances,\n    # to provide some initial data. It tries the first list entry, and if it fails uses subsequent\n    # instances as fallback.\n    # Leave this empty to disable community bootstrap.\n    # TODO: remove voyager.lemmy.ml from defaults once Lemmy 1.0 is deployed to production\n    # instances.\n    bootstrap_instances: [\n      \"string\"\n      /* ... */\n    ]\n  }\n  # the domain name of your instance (mandatory)\n  hostname: \"unset\"\n  # Address where lemmy should listen for incoming requests\n  bind: \"0.0.0.0\"\n  # Port where lemmy should listen for incoming requests\n  port: 8536\n  # Whether the site is available over TLS. Needs to be true for federation to work.\n  tls_enabled: true\n  federation: {\n    # Limit to the number of concurrent outgoing federation requests per target instance.\n    # Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities\n    # per second) and if a receiving instance is not keeping up.\n    concurrent_sends_per_instance: 1\n  }\n  prometheus: {\n    bind: \"127.0.0.1\"\n    port: 10002\n  }\n  # Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment:\n  # `LEMMY_CORS_ORIGIN=example.org,site.com`\n  # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin\n  cors_origin: [\n    \"lemmy.tld\"\n    /* ... */\n  ]\n  # Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`.\n  json_logging: false\n  # Data for loading Lemmy plugins\n  plugins: [\n    {\n      # Where to load the .wasm file from, can be a local file path or URL\n      file: \"https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm\"\n      # SHA256 hash of the .wasm file\n      hash: \"37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12\"\n      # Which websites the plugin may connect to\n      allowed_hosts: [\n        \"lemmy.ml\"\n        /* ... */\n      ]\n      # Configuration options for the plugin\n      config: {\n        string: \"string\"\n        /* ... */\n      }\n    }\n    /* ... */\n  ]\n}\n"
  },
  {
    "path": "crates/api/api/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_api\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = []\n\n[dependencies]\nlemmy_db_views_comment = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_moderator = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_follower_approval = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_db_views_community_follower = { workspace = true, features = [\"full\"] }\nlemmy_apub_objects = { workspace = true, features = [\"full\"] }\nlemmy_db_views_post = { workspace = true, features = [\"full\"] }\nlemmy_db_views_vote = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_views_search_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_image = { workspace = true, features = [\"full\"] }\nlemmy_db_views_notification = { workspace = true, features = [\"full\"] }\nlemmy_db_views_modlog = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person_saved_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person_liked_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_post_comment_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person_content_combined = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_db_views_report_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_db_views_registration_applications = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_utils = { workspace = true }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_email = { workspace = true }\nactivitypub_federation = { workspace = true }\ntracing = { workspace = true }\nbcrypt = { workspace = true }\nactix-web = { workspace = true }\nanyhow = { workspace = true }\nchrono = { workspace = true }\nurl = { workspace = true }\nregex = { workspace = true }\nsitemap-rs = \"0.4.0\"\ntotp-rs = { version = \"5.7.0\", features = [\"gen_secret\", \"otpauth\"] }\ndiesel-async = { workspace = true, features = [\"deadpool\", \"postgres\"] }\neither = { workspace = true }\nfutures = { workspace = true }\nserde = { workspace = true }\nitertools = { workspace = true }\nserde_json = { workspace = true }\ndiesel = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\nelementtree = \"1.2.3\"\npretty_assertions = { workspace = true }\nlemmy_api_crud = { workspace = true }\n"
  },
  {
    "path": "crates/api/api/src/comment/distinguish.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_mod_action, check_community_user_action},\n};\nuse lemmy_db_schema::source::comment::{Comment, CommentUpdateForm};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, DistinguishComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn distinguish_comment(\n  Json(data): Json<DistinguishComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let local_instance_id = local_user_view.person.instance_id;\n\n  let orig_comment = CommentView::read(\n    &mut context.pool(),\n    data.comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  check_community_user_action(\n    &local_user_view,\n    &orig_comment.community,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Verify that only the creator can distinguish\n  if local_user_view.person.id != orig_comment.creator.id {\n    return Err(LemmyErrorType::NoCommentEditAllowed.into());\n  }\n\n  // Verify that only a mod or admin can distinguish a comment\n  check_community_mod_action(\n    &local_user_view,\n    &orig_comment.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Update the Comment\n  let form = CommentUpdateForm {\n    distinguished: Some(data.distinguished),\n    ..Default::default()\n  };\n\n  let comment = Comment::update(&mut context.pool(), data.comment_id, &form).await?;\n  ActivityChannel::submit_activity(SendActivityData::UpdateComment(comment), &context)?;\n\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    data.comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  Ok(Json(CommentResponse { comment_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/comment/like.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_bot_account,\n    check_community_user_action,\n    check_local_user_valid,\n    check_local_vote_mode,\n  },\n};\nuse lemmy_db_schema::{\n  newtypes::PostOrCommentId,\n  source::{\n    comment::{CommentActions, CommentLikeForm},\n    notification::Notification,\n    person::PersonActions,\n  },\n  traits::Likeable,\n};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, CreateCommentLike},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\nuse std::ops::Deref;\n\npub async fn like_comment(\n  Json(data): Json<CreateCommentLike>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let local_instance_id = local_user_view.person.instance_id;\n  let comment_id = data.comment_id;\n  let my_person_id = local_user_view.person.id;\n\n  check_local_vote_mode(\n    data.is_upvote,\n    PostOrCommentId::Comment(comment_id),\n    &local_site,\n    my_person_id,\n    &mut context.pool(),\n  )\n  .await?;\n  check_bot_account(&local_user_view.person)?;\n\n  let orig_comment = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n  let previous_is_upvote = orig_comment.comment_actions.and_then(|p| p.vote_is_upvote);\n\n  check_community_user_action(\n    &local_user_view,\n    &orig_comment.community,\n    &mut context.pool(),\n  )\n  .await?;\n\n  let mut like_form = CommentLikeForm::new(data.comment_id, my_person_id, data.is_upvote);\n  like_form = plugin_hook_before(\"comment_before_vote\", like_form).await?;\n  let like = CommentActions::like(&mut context.pool(), &like_form).await?;\n  PersonActions::like(\n    &mut context.pool(),\n    my_person_id,\n    orig_comment.creator.id,\n    previous_is_upvote,\n    data.is_upvote,\n  )\n  .await?;\n\n  plugin_hook_after(\"comment_after_vote\", &like);\n\n  // Mark any notification as read\n  Notification::mark_read_by_comment_and_recipient(\n    &mut context.pool(),\n    comment_id,\n    my_person_id,\n    true,\n  )\n  .await\n  .ok();\n\n  ActivityChannel::submit_activity(\n    SendActivityData::LikePostOrComment {\n      object_id: orig_comment.comment.ap_id,\n      actor: local_user_view.person.clone(),\n      community: orig_comment.community,\n      previous_is_upvote,\n      new_is_upvote: data.is_upvote,\n    },\n    &context,\n  )?;\n\n  Ok(Json(\n    build_comment_response(\n      context.deref(),\n      comment_id,\n      Some(local_user_view),\n      local_instance_id,\n    )\n    .await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api/src/comment/list_comment_likes.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin};\nuse lemmy_db_views_comment::{CommentView, api::ListCommentLikes};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_vote::VoteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\n/// Lists likes for a comment\npub async fn list_comment_likes(\n  Query(data): Query<ListCommentLikes>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<VoteView>>> {\n  let local_instance_id = local_user_view.person.instance_id;\n\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    data.comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  is_mod_or_admin(\n    &mut context.pool(),\n    &local_user_view,\n    comment_view.community.id,\n  )\n  .await?;\n\n  let comment_likes = VoteView::list_for_comment(\n    &mut context.pool(),\n    data.comment_id,\n    data.page_cursor,\n    data.limit,\n    local_instance_id,\n  )\n  .await?;\n\n  Ok(Json(comment_likes))\n}\n"
  },
  {
    "path": "crates/api/api/src/comment/lock.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::source::{\n  comment::Comment,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, LockComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn lock_comment(\n  Json(data): Json<LockComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let locked = data.locked;\n\n  let orig_comment =\n    CommentView::read(&mut context.pool(), comment_id, None, local_instance_id).await?;\n\n  check_community_mod_action(\n    &local_user_view,\n    &orig_comment.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  let comments = Comment::update_locked_for_comment_and_children(\n    &mut context.pool(),\n    &orig_comment.comment.path,\n    locked,\n  )\n  .await?;\n  let comment = comments.first().ok_or(LemmyErrorType::NotFound)?;\n\n  let form = ModlogInsertForm::mod_lock_comment(\n    local_user_view.person.id,\n    comment,\n    orig_comment.community.id,\n    locked,\n    &data.reason,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action.clone(), &context);\n\n  ActivityChannel::submit_activity(\n    SendActivityData::LockComment(\n      comment.clone(),\n      local_user_view.person.clone(),\n      data.locked,\n      data.reason.clone(),\n    ),\n    &context,\n  )?;\n\n  build_comment_response(\n    &context,\n    comment_id,\n    local_user_view.into(),\n    local_instance_id,\n  )\n  .await\n  .map(Json)\n}\n"
  },
  {
    "path": "crates/api/api/src/comment/mod.rs",
    "content": "pub mod distinguish;\npub mod like;\npub mod list_comment_likes;\npub mod lock;\npub mod save;\npub mod warning;\n"
  },
  {
    "path": "crates/api/api/src/comment/save.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::{\n  source::comment::{CommentActions, CommentSavedForm},\n  traits::Saveable,\n};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, SaveComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn save_comment(\n  Json(data): Json<SaveComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let comment_saved_form = CommentSavedForm::new(local_user_view.person.id, data.comment_id);\n\n  if data.save {\n    CommentActions::save(&mut context.pool(), &comment_saved_form).await?;\n  } else {\n    CommentActions::unsave(&mut context.pool(), &comment_saved_form).await?;\n  }\n\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  Ok(Json(CommentResponse { comment_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/comment/warning.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{check_comment_deleted_or_removed, check_community_mod_action},\n};\nuse lemmy_db_schema::source::modlog::{Modlog, ModlogInsertForm};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, CreateCommentWarning},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::LemmyResult;\n\n/// Creates a warning against a comment and notifies the user\npub async fn create_comment_warning(\n  Json(data): Json<CreateCommentWarning>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let local_instance_id = local_user_view.person.instance_id;\n  let comment_id = data.comment_id;\n\n  let orig_comment =\n    CommentView::read(&mut context.pool(), comment_id, None, local_instance_id).await?;\n\n  check_community_mod_action(\n    &local_user_view,\n    &orig_comment.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Don't allow creating warnings for removed / deleted comments\n  check_comment_deleted_or_removed(&orig_comment.comment)?;\n\n  let form = ModlogInsertForm::mod_create_comment_warning(\n    local_user_view.person.id,\n    &orig_comment.comment,\n    orig_comment.community.id,\n    &data.reason,\n  );\n\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n\n  notify_mod_action(action, &context);\n\n  // TODO federate activity\n\n  Ok(Json(CommentResponse {\n    comment_view: orig_comment,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/add_mod.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse diesel_async::scoped_futures::ScopedFutureExt;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityActions, CommunityModeratorForm},\n  local_user::LocalUser,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_community::api::{AddModToCommunity, AddModToCommunityResponse};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{connection::get_conn, traits::Crud};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn add_mod_to_community(\n  Json(data): Json<AddModToCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<AddModToCommunityResponse>> {\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n  // Verify that only mods or admins can add mod\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  // If it's a mod removal, also check that you're a higher mod.\n  if !data.added {\n    LocalUser::is_higher_mod_or_admin_check(\n      &mut context.pool(),\n      community.id,\n      local_user_view.person.id,\n      vec![data.person_id],\n    )\n    .await?;\n\n    // Dont allow the last community mod to remove himself\n    let mods = CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;\n    if !local_user_view.local_user.admin && mods.len() == 1 {\n      return Err(LemmyErrorType::CannotLeaveMod.into());\n    }\n  }\n\n  // If user is admin and community is remote, explicitly check that he is a\n  // moderator. This is necessary because otherwise the action would be rejected\n  // by the community's home instance.\n  if local_user_view.local_user.admin && !community.local {\n    CommunityModeratorView::check_is_community_moderator(\n      &mut context.pool(),\n      community.id,\n      local_user_view.person.id,\n    )\n    .await?;\n  }\n\n  let pool = &mut context.pool();\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  let action = conn\n    .run_transaction(|conn| {\n      async move {\n        // Update in local database\n        let community_moderator_form =\n          CommunityModeratorForm::new(tx_data.community_id, tx_data.person_id);\n        if tx_data.added {\n          CommunityActions::join(&mut conn.into(), &community_moderator_form).await?;\n        } else {\n          CommunityActions::leave(&mut conn.into(), &community_moderator_form).await?;\n        }\n\n        // Mod tables\n        let form = ModlogInsertForm::mod_add_to_community(\n          local_user_view.person.id,\n          tx_data.community_id,\n          tx_data.person_id,\n          !tx_data.added,\n        );\n        Modlog::create(&mut conn.into(), &[form]).await\n      }\n      .scope_boxed()\n    })\n    .await?;\n  notify_mod_action(action.clone(), &context);\n\n  // Note: in case a remote mod is added, this returns the old moderators list, it will only get\n  //       updated once we receive an activity from the community (like `Announce/Add/Moderator`)\n  let community_id = data.community_id;\n  let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::AddModToCommunity {\n      moderator: local_user_view.person,\n      community_id: data.community_id,\n      target: data.person_id,\n      added: data.added,\n    },\n    &context,\n  )?;\n\n  Ok(Json(AddModToCommunityResponse { moderators }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/ban.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse diesel_async::scoped_futures::ScopedFutureExt;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_community_mod_action,\n    check_expire_time,\n    remove_or_restore_user_data_in_community,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    community::{Community, CommunityActions, CommunityPersonBanForm},\n    local_user::LocalUser,\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::{Bannable, Followable},\n};\nuse lemmy_db_views_community::api::BanFromCommunity;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{PersonView, api::PersonResponse};\nuse lemmy_diesel_utils::{connection::get_conn, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::is_valid_body_field,\n};\n\npub async fn ban_from_community(\n  Json(data): Json<BanFromCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PersonResponse>> {\n  let banned_person_id = data.person_id;\n  let my_person_id = local_user_view.person.id;\n  let expires_at = check_expire_time(data.expires_at)?;\n  let local_instance_id = local_user_view.person.instance_id;\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n\n  // Verify that only mods or admins can ban\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  LocalUser::is_higher_mod_or_admin_check(\n    &mut context.pool(),\n    data.community_id,\n    my_person_id,\n    vec![data.person_id],\n  )\n  .await?;\n\n  is_valid_body_field(&data.reason, false)?;\n\n  let community_user_ban_form = CommunityPersonBanForm {\n    ban_expires_at: Some(expires_at),\n    ..CommunityPersonBanForm::new(data.community_id, data.person_id)\n  };\n\n  let pool = &mut context.pool();\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  let action = conn\n    .run_transaction(|conn| {\n      async move {\n        if tx_data.ban {\n          CommunityActions::ban(&mut conn.into(), &community_user_ban_form).await?;\n\n          // Also unsubscribe them from the community, if they are subscribed\n          CommunityActions::unfollow(&mut conn.into(), banned_person_id, tx_data.community_id)\n            .await\n            .ok();\n        } else {\n          CommunityActions::unban(&mut conn.into(), &community_user_ban_form).await?;\n        }\n\n        // Mod tables - create ban entry first so bulk actions can reference it as parent\n        let form = ModlogInsertForm::mod_ban_from_community(\n          my_person_id,\n          tx_data.community_id,\n          tx_data.person_id,\n          tx_data.ban,\n          expires_at,\n          &tx_data.reason,\n        );\n        let action = Modlog::create(&mut conn.into(), &[form]).await?;\n\n        // Remove/Restore their data if that's desired\n        let ban_id = action.first().ok_or(LemmyErrorType::NotFound)?.id;\n        if tx_data.remove_or_restore_data.unwrap_or(false) {\n          let remove_data = tx_data.ban;\n          remove_or_restore_user_data_in_community(\n            tx_data.community_id,\n            my_person_id,\n            banned_person_id,\n            remove_data,\n            &tx_data.reason,\n            ban_id,\n            &mut conn.into(),\n          )\n          .await?;\n        };\n\n        Ok(action)\n      }\n      .scope_boxed()\n    })\n    .await?;\n  notify_mod_action(action.clone(), &context);\n\n  let person_view = PersonView::read(\n    &mut context.pool(),\n    data.person_id,\n    Some(my_person_id),\n    local_instance_id,\n    true,\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::BanFromCommunity {\n      moderator: local_user_view.person,\n      community_id: data.community_id,\n      target: person_view.person.clone(),\n      data: data.clone(),\n    },\n    &context,\n  )?;\n\n  Ok(Json(PersonResponse { person_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/block.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse diesel_async::scoped_futures::ScopedFutureExt;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_local_user_valid,\n};\nuse lemmy_db_schema::{\n  source::{\n    actor_language::CommunityLanguage,\n    community::{CommunityActions, CommunityBlockForm},\n  },\n  traits::{Blockable, Followable},\n};\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{BlockCommunity, CommunityResponse},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::connection::get_conn;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn user_block_community(\n  Json(data): Json<BlockCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let community_id = data.community_id;\n  let person_id = local_user_view.person.id;\n  let community_block_form = CommunityBlockForm::new(community_id, person_id);\n\n  let pool = &mut context.pool();\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  conn\n    .run_transaction(|conn| {\n      async move {\n        if tx_data.block {\n          CommunityActions::block(&mut conn.into(), &community_block_form).await?;\n\n          // Also, unfollow the community, and send a federated unfollow\n          CommunityActions::unfollow(&mut conn.into(), person_id, tx_data.community_id)\n            .await\n            .ok();\n        } else {\n          CommunityActions::unblock(&mut conn.into(), &community_block_form).await?;\n        }\n\n        Ok(())\n      }\n      .scope_boxed()\n    })\n    .await?;\n\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    Some(&local_user_view.local_user),\n    false,\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::FollowCommunity(\n      community_view.community.clone(),\n      local_user_view.person.clone(),\n      false,\n    ),\n    &context,\n  )?;\n\n  let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;\n\n  Ok(Json(CommunityResponse {\n    community_view,\n    discussion_languages,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/follow.rs",
    "content": "use crate::community::do_follow_community;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community};\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{CommunityResponse, FollowCommunity},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn follow_community(\n  Json(data): Json<FollowCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let community_id = data.community_id;\n  let community = Community::read(&mut context.pool(), community_id).await?;\n\n  do_follow_community(community, &local_user_view.person, data.follow, &context).await?;\n\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    Some(&local_user_view.local_user),\n    false,\n  )\n  .await?;\n\n  let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;\n\n  Ok(Json(CommunityResponse {\n    community_view,\n    discussion_languages,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/mod.rs",
    "content": "use activitypub_federation::config::Data;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_deleted_removed,\n};\nuse lemmy_db_schema::{\n  source::{\n    community::{Community, CommunityActions, CommunityFollowerForm},\n    person::Person,\n  },\n  traits::Followable,\n};\nuse lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};\nuse lemmy_db_views_community_moderator::CommunityPersonBanView;\nuse lemmy_utils::error::LemmyResult;\n\npub mod add_mod;\npub mod ban;\npub mod block;\npub mod follow;\npub mod multi_community_follow;\npub mod pending_follows;\npub mod random;\npub mod tag;\npub mod transfer;\npub mod update_notifications;\n\npub(super) async fn do_follow_community(\n  community: Community,\n  person: &Person,\n  follow: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  if follow {\n    // Only run these checks for local community, in case of remote community the local\n    // state may be outdated. Can't use check_community_user_action() here as it only allows\n    // actions from existing followers for private community (so following would be impossible).\n    if community.local {\n      check_community_deleted_removed(&community)?;\n      CommunityPersonBanView::check(&mut context.pool(), person.id, community.id).await?;\n    }\n\n    let follow_state = if community.visibility == CommunityVisibility::Private {\n      // Private communities require manual approval\n      CommunityFollowerState::ApprovalRequired\n    } else if community.local {\n      // Local follow is accepted immediately\n      CommunityFollowerState::Accepted\n    } else {\n      // remote follow needs to be federated first\n      CommunityFollowerState::Pending\n    };\n    let form = CommunityFollowerForm::new(community.id, person.id, follow_state);\n\n    // Write to db\n    CommunityActions::follow(&mut context.pool(), &form).await?;\n  } else {\n    CommunityActions::unfollow(&mut context.pool(), person.id, community.id).await?;\n  }\n\n  // Send the federated follow\n  if !community.local {\n    ActivityChannel::submit_activity(\n      SendActivityData::FollowCommunity(community, person.clone(), follow),\n      context,\n    )?;\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/api/api/src/community/multi_community_follow.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_local_user_valid,\n};\nuse lemmy_db_schema::source::multi_community::{MultiCommunity, MultiCommunityFollowForm};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_community::{\n  MultiCommunityView,\n  api::{FollowMultiCommunity, MultiCommunityResponse},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn follow_multi_community(\n  Json(data): Json<FollowMultiCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<MultiCommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let multi_community_id = data.multi_community_id;\n  let my_person_id = local_user_view.person.id;\n  let multi = MultiCommunity::read(&mut context.pool(), multi_community_id).await?;\n\n  let follow_state = if multi.local {\n    CommunityFollowerState::Accepted\n  } else {\n    CommunityFollowerState::Pending\n  };\n  let form = MultiCommunityFollowForm {\n    multi_community_id,\n    person_id: my_person_id,\n    follow_state,\n  };\n\n  if data.follow {\n    MultiCommunity::follow(&mut context.pool(), &form).await?;\n  } else {\n    MultiCommunity::unfollow(&mut context.pool(), my_person_id, multi_community_id).await?;\n  }\n\n  if !multi.local {\n    ActivityChannel::submit_activity(\n      SendActivityData::FollowMultiCommunity(multi, local_user_view.person.clone(), data.follow),\n      &context,\n    )?;\n  }\n\n  let multi_community_view =\n    MultiCommunityView::read(&mut context.pool(), multi_community_id, Some(my_person_id)).await?;\n\n  Ok(Json(MultiCommunityResponse {\n    multi_community_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/pending_follows/approve.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::is_mod_or_admin,\n};\nuse lemmy_db_schema::source::community::CommunityActions;\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_community::api::ApproveCommunityPendingFollower;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn post_pending_follows_approve(\n  Json(data): Json<ApproveCommunityPendingFollower>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?;\n\n  let (state, activity_data) = if data.approve {\n    (\n      CommunityFollowerState::Accepted,\n      SendActivityData::AcceptFollower(data.community_id, data.follower_id),\n    )\n  } else {\n    (\n      CommunityFollowerState::Denied,\n      SendActivityData::RejectFollower(data.community_id, data.follower_id),\n    )\n  };\n  CommunityActions::approve_private_community_follower(\n    &mut context.pool(),\n    data.community_id,\n    data.follower_id,\n    local_user_view.person.id,\n    state,\n  )\n  .await?;\n  ActivityChannel::submit_activity(activity_data, &context)?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/pending_follows/list.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_community_mod_of_any_or_admin_action};\nuse lemmy_db_views_community_follower_approval::{\n  PendingFollowerView,\n  api::ListCommunityPendingFollows,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_pending_follows_list(\n  Query(data): Query<ListCommunityPendingFollows>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<PendingFollowerView>>> {\n  check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;\n  let all_communities =\n    data.all_communities.unwrap_or_default() && local_user_view.local_user.admin;\n\n  let items = PendingFollowerView::list_approval_required(\n    &mut context.pool(),\n    local_user_view.person.id,\n    all_communities,\n    data.unread_only.unwrap_or_default(),\n    data.page_cursor,\n    data.limit,\n  )\n  .await?;\n\n  Ok(Json(items))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/pending_follows/mod.rs",
    "content": "pub mod approve;\npub mod list;\n"
  },
  {
    "path": "crates/api/api/src/community/random.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_private_instance, is_mod_or_admin_opt},\n};\nuse lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community};\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{CommunityResponse, GetRandomCommunity},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_random_community(\n  Query(data): Query<GetRandomCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<CommunityResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let local_user = local_user_view.as_ref().map(|u| &u.local_user);\n\n  let random_community_id =\n    Community::get_random_community_id(&mut context.pool(), &data.type_, data.show_nsfw).await?;\n\n  let is_mod_or_admin = is_mod_or_admin_opt(\n    &mut context.pool(),\n    local_user_view.as_ref(),\n    Some(random_community_id),\n  )\n  .await\n  .is_ok();\n\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    random_community_id,\n    local_user,\n    is_mod_or_admin,\n  )\n  .await?;\n\n  let discussion_languages =\n    CommunityLanguage::read(&mut context.pool(), random_community_id).await?;\n\n  Ok(Json(CommunityResponse {\n    community_view,\n    discussion_languages,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/tag.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_mod_action, slur_regex},\n};\nuse lemmy_db_schema::source::{\n  community::Community,\n  community_tag::{CommunityTag, CommunityTagInsertForm, CommunityTagUpdateForm},\n};\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{CreateCommunityTag, DeleteCommunityTag, EditCommunityTag},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update};\nuse lemmy_utils::{\n  error::LemmyResult,\n  utils::{\n    slurs::check_slurs,\n    validation::{check_api_elements_count, is_valid_actor_name, summary_length_check},\n  },\n};\nuse url::Url;\n\npub async fn create_community_tag(\n  Json(data): Json<CreateCommunityTag>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityTag>> {\n  is_valid_actor_name(&data.name)?;\n\n  let community_view =\n    CommunityView::read(&mut context.pool(), data.community_id, None, false).await?;\n  let community = community_view.community;\n\n  // Verify that only mods can create tags\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  check_api_elements_count(community_view.tags.0.len())?;\n  if let Some(summary) = &data.summary {\n    summary_length_check(summary)?;\n    check_slurs(summary, &slur_regex(&context).await?)?;\n  }\n\n  let ap_id = Url::parse(&format!(\"{}/tag/{}\", community.ap_id, &data.name))?;\n\n  // Create the tag\n  let tag_form = CommunityTagInsertForm {\n    name: data.name.clone(),\n    display_name: data.display_name.clone(),\n    summary: data.summary.clone(),\n    community_id: data.community_id,\n    ap_id: ap_id.into(),\n    deleted: Some(false),\n    color: data.color,\n  };\n\n  let tag = CommunityTag::create(&mut context.pool(), &tag_form).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),\n    &context,\n  )?;\n\n  Ok(Json(tag))\n}\n\npub async fn edit_community_tag(\n  Json(data): Json<EditCommunityTag>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityTag>> {\n  let tag = CommunityTag::read(&mut context.pool(), data.tag_id).await?;\n  let community = Community::read(&mut context.pool(), tag.community_id).await?;\n\n  // Verify that only mods can update tags\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  if let Some(summary) = &data.summary {\n    summary_length_check(summary)?;\n    check_slurs(summary, &slur_regex(&context).await?)?;\n  }\n\n  // Update the tag\n  let tag_form = CommunityTagUpdateForm {\n    display_name: diesel_string_update(data.display_name.as_deref()),\n    summary: diesel_string_update(data.summary.as_deref()),\n    updated_at: Some(Some(Utc::now())),\n    color: data.color,\n    ..Default::default()\n  };\n\n  let tag = CommunityTag::update(&mut context.pool(), data.tag_id, &tag_form).await?;\n  Ok(Json(tag))\n}\n\npub async fn delete_community_tag(\n  Json(data): Json<DeleteCommunityTag>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityTag>> {\n  let tag = CommunityTag::read(&mut context.pool(), data.tag_id).await?;\n  let community = Community::read(&mut context.pool(), tag.community_id).await?;\n\n  // Verify that only mods can delete tags\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  // Soft delete the tag\n  let tag_form = CommunityTagUpdateForm {\n    updated_at: Some(Some(Utc::now())),\n    deleted: Some(data.delete),\n    ..Default::default()\n  };\n\n  let tag = CommunityTag::update(&mut context.pool(), data.tag_id, &tag_form).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),\n    &context,\n  )?;\n\n  Ok(Json(tag))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/transfer.rs",
    "content": "use actix_web::web::{Data, Json};\nuse anyhow::Context;\nuse diesel_async::scoped_futures::ScopedFutureExt;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{check_community_user_action, is_admin, is_top_mod},\n};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityActions, CommunityModeratorForm},\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{GetCommunityResponse, TransferCommunity},\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{connection::get_conn, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  location_info,\n};\n\n// TODO: we don't do anything for federation here, it should be updated the next time the community\n//       gets fetched. i hope we can get rid of the community creator role soon.\n\npub async fn transfer_community(\n  Json(data): Json<TransferCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<GetCommunityResponse>> {\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n  let mut community_mods =\n    CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;\n\n  check_community_user_action(&local_user_view, &community, &mut context.pool()).await?;\n\n  // Make sure transferrer is either the top community mod, or an admin\n  if !(is_top_mod(&local_user_view, &community_mods).is_ok() || is_admin(&local_user_view).is_ok())\n  {\n    return Err(LemmyErrorType::NotAnAdmin.into());\n  }\n\n  // You have to re-do the community_moderator table, reordering it.\n  // Add the transferee to the top\n  let creator_index = community_mods\n    .iter()\n    .position(|r| r.moderator.id == data.person_id)\n    .context(location_info!())?;\n  let creator_person = community_mods.remove(creator_index);\n  community_mods.insert(0, creator_person);\n\n  // Delete all the mods\n  let community_id = data.community_id;\n\n  let pool = &mut context.pool();\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  let action = conn\n    .run_transaction(|conn| {\n      async move {\n        CommunityActions::delete_mods_for_community(&mut conn.into(), community_id).await?;\n\n        // TODO: this should probably be a bulk operation\n        // Re-add the mods, in the new order\n        for cmod in &community_mods {\n          let community_moderator_form =\n            CommunityModeratorForm::new(cmod.community.id, cmod.moderator.id);\n\n          CommunityActions::join(&mut conn.into(), &community_moderator_form).await?;\n        }\n\n        // Mod tables\n        let form = ModlogInsertForm::mod_transfer_community(\n          local_user_view.person.id,\n          tx_data.community_id,\n          tx_data.person_id,\n        );\n        Modlog::create(&mut conn.into(), &[form]).await\n      }\n      .scope_boxed()\n    })\n    .await?;\n  notify_mod_action(action.clone(), &context);\n\n  let community_id = data.community_id;\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    Some(&local_user_view.local_user),\n    false,\n  )\n  .await?;\n\n  let community_id = data.community_id;\n  let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;\n\n  // Return the jwt\n  Ok(Json(GetCommunityResponse {\n    community_view,\n    site: None,\n    moderators,\n    discussion_languages: vec![],\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/community/update_notifications.rs",
    "content": "use crate::community::do_follow_community;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::community::{Community, CommunityActions};\nuse lemmy_db_schema_file::enums::CommunityNotificationsMode;\nuse lemmy_db_views_community::api::EditCommunityNotifications;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn edit_community_notifications(\n  Json(data): Json<EditCommunityNotifications>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  CommunityActions::update_notification_state(\n    data.community_id,\n    local_user_view.person.id,\n    data.mode,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // To get notifications for a remote community, the user needs to follow it over federation.\n  // Do this automatically here to avoid confusion.\n  if data.mode == CommunityNotificationsMode::AllPostsAndComments\n    || data.mode == CommunityNotificationsMode::AllPosts\n  {\n    let community = Community::read(&mut context.pool(), data.community_id).await?;\n    if !community.local {\n      do_follow_community(community, &local_user_view.person, true, &context).await?;\n    }\n  }\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/fetcher.rs",
    "content": "use crate::federation::ApubPerson;\nuse activitypub_federation::{\n  config::Data,\n  fetch::webfinger::webfinger_resolve_actor,\n  traits::{Actor, Object},\n};\nuse diesel::NotFound;\nuse itertools::Itertools;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::{community::ApubCommunity, multi_community::ApubMultiCommunity};\nuse lemmy_db_schema::{\n  newtypes::{CommunityId, MultiCommunityId},\n  source::{community::Community, multi_community::MultiCommunity, person::Person},\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};\n\n/// Resolve actor identifier like `!news@example.com` to user or community object.\n///\n/// In case the requesting user is logged in and the object was not found locally, it is attempted\n/// to fetch via webfinger from the original instance.\nasync fn resolve_ap_identifier<ActorType, DbActor>(\n  identifier: &str,\n  context: &Data<LemmyContext>,\n  local_user_view: &Option<LocalUserView>,\n  include_deleted: bool,\n) -> LemmyResult<ActorType>\nwhere\n  ActorType: Object<DataType = LemmyContext, Error = LemmyError>\n    + Object\n    + Actor\n    + From<DbActor>\n    + Send\n    + Sync\n    + 'static,\n  for<'de2> <ActorType as Object>::Kind: serde::Deserialize<'de2>,\n  DbActor: ApubActor + Send + 'static,\n{\n  // remote actor\n  if identifier.contains('@') {\n    let (name, domain) = identifier\n      .splitn(2, '@')\n      .collect_tuple()\n      .ok_or(LemmyErrorType::InvalidUrl)?;\n    let actor = DbActor::read_from_name(&mut context.pool(), name, Some(domain), false)\n      .await\n      .ok()\n      .flatten();\n    if let Some(actor) = actor {\n      Ok(actor.into())\n    } else if local_user_view.is_some() {\n      // Fetch the actor from its home instance using webfinger\n      let actor: ActorType = webfinger_resolve_actor(&identifier.to_lowercase(), context).await?;\n      Ok(actor)\n    } else {\n      Err(NotFound.into())\n    }\n  }\n  // local actor\n  else {\n    let identifier = identifier.to_string();\n    Ok(\n      DbActor::read_from_name(&mut context.pool(), &identifier, None, include_deleted)\n        .await?\n        .ok_or(NotFound)?\n        .into(),\n    )\n  }\n}\n\npub(crate) async fn resolve_community_identifier(\n  name: &Option<String>,\n  id: Option<CommunityId>,\n  context: &Data<LemmyContext>,\n  local_user_view: &Option<LocalUserView>,\n) -> LemmyResult<Option<CommunityId>> {\n  Ok(if let Some(name) = name {\n    Some(\n      resolve_ap_identifier::<ApubCommunity, Community>(name, context, local_user_view, true)\n        .await?\n        .id,\n    )\n  } else {\n    id\n  })\n}\n\npub(crate) async fn resolve_person_identifier(\n  id: Option<PersonId>,\n  username: &Option<String>,\n  context: &Data<LemmyContext>,\n  local_user_view: &Option<LocalUserView>,\n) -> LemmyResult<PersonId> {\n  Ok(\n    if let Some(name) = username {\n      Some(\n        resolve_ap_identifier::<ApubPerson, Person>(name, context, local_user_view, true)\n          .await?\n          .id,\n      )\n    } else {\n      id\n    }\n    .ok_or(LemmyErrorType::NoIdGiven)?,\n  )\n}\n\npub(crate) async fn resolve_multi_community_identifier(\n  name: &Option<String>,\n  id: Option<MultiCommunityId>,\n  context: &Data<LemmyContext>,\n  local_user_view: &Option<LocalUserView>,\n) -> LemmyResult<Option<MultiCommunityId>> {\n  Ok(if let Some(name) = name {\n    Some(\n      resolve_ap_identifier::<ApubMultiCommunity, MultiCommunity>(\n        name,\n        context,\n        local_user_view,\n        true,\n      )\n      .await?\n      .id,\n    )\n  } else {\n    id\n  })\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/list_comments.rs",
    "content": "use crate::federation::{\n  comment_sort_type_with_default,\n  fetch_limit_with_default,\n  fetcher::resolve_community_identifier,\n  listing_type_with_default,\n  post_time_range_seconds_with_default,\n};\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_schema::source::comment::Comment;\nuse lemmy_db_views_comment::{CommentSlimView, CommentView, api::GetComments, impls::CommentQuery};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{pagination::PagedResponse, traits::Crud};\nuse lemmy_utils::error::LemmyResult;\n\n/// A common fetcher for both the CommentView, and CommentSlimView.\nasync fn list_comments_common(\n  data: GetComments,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<PagedResponse<CommentView>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = &site_view.local_site;\n\n  check_private_instance(&local_user_view, local_site)?;\n\n  let community_id = resolve_community_identifier(\n    &data.community_name,\n    data.community_id,\n    &context,\n    &local_user_view,\n  )\n  .await?;\n  let local_user = local_user_view.as_ref().map(|u| &u.local_user);\n  let sort = Some(comment_sort_type_with_default(\n    data.sort, local_user, local_site,\n  ));\n  let time_range_seconds =\n    post_time_range_seconds_with_default(data.time_range_seconds, local_user, local_site);\n  let limit = Some(fetch_limit_with_default(data.limit, local_user, local_site));\n  let max_depth = data.max_depth;\n  let parent_id = data.parent_id;\n\n  let listing_type = Some(listing_type_with_default(\n    data.type_,\n    local_user_view.as_ref().map(|u| &u.local_user),\n    local_site,\n    community_id,\n  ));\n\n  // If a parent_id is given, fetch the comment to get the path\n  let parent_path_ = if let Some(parent_id) = parent_id {\n    Some(Comment::read(&mut context.pool(), parent_id).await?.path)\n  } else {\n    None\n  };\n\n  let parent_path = parent_path_.clone();\n  let post_id = data.post_id;\n  let local_user = local_user_view.as_ref().map(|l| &l.local_user);\n\n  CommentQuery {\n    listing_type,\n    sort,\n    time_range_seconds,\n    max_depth,\n    community_id,\n    parent_path,\n    post_id,\n    local_user,\n    page_cursor: data.page_cursor,\n    limit,\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await\n}\n\npub async fn list_comments(\n  Query(data): Query<GetComments>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<CommentView>>> {\n  let common = list_comments_common(data, context, local_user_view).await?;\n\n  Ok(Json(common))\n}\n\npub async fn list_comments_slim(\n  Query(data): Query<GetComments>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<CommentSlimView>>> {\n  let common = list_comments_common(data, context, local_user_view).await?;\n\n  let data = common\n    .items\n    .into_iter()\n    .map(CommentView::map_to_slim)\n    .collect();\n  let res = PagedResponse {\n    items: data,\n    next_page: common.next_page,\n    prev_page: common.prev_page,\n  };\n\n  Ok(Json(res))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/list_person_content.rs",
    "content": "use crate::federation::fetcher::resolve_person_identifier;\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person_content_combined::{\n  ListPersonContent,\n  impls::PersonContentCombinedQuery,\n};\nuse lemmy_db_views_post_comment_combined::PostCommentCombinedView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_person_content(\n  Query(data): Query<ListPersonContent>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<PostCommentCombinedView>>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n  let local_instance_id = site_view.site.instance_id;\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let person_details_id =\n    resolve_person_identifier(data.person_id, &data.username, &context, &local_user_view).await?;\n\n  let res = PersonContentCombinedQuery {\n    creator_id: person_details_id,\n    type_: data.type_,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    no_limit: None,\n  }\n  .list(\n    &mut context.pool(),\n    local_user_view.as_ref(),\n    local_instance_id,\n  )\n  .await?;\n\n  Ok(Json(res))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/list_posts.rs",
    "content": "use crate::federation::{\n  fetch_limit_with_default,\n  fetcher::{resolve_community_identifier, resolve_multi_community_identifier},\n  listing_type_with_default,\n  post_sort_type_with_default,\n  post_time_range_seconds_with_default,\n};\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_schema::{\n  newtypes::PostId,\n  source::{keyword_block::LocalUserKeywordBlock, post::PostActions},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{PostView, api::GetPosts, impls::PostQuery};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\nuse std::cmp::min;\n\npub async fn list_posts(\n  Query(data): Query<GetPosts>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<PostView>>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = &site_view.local_site;\n\n  check_private_instance(&local_user_view, &site_view.local_site)?;\n\n  let community_id = resolve_community_identifier(\n    &data.community_name,\n    data.community_id,\n    &context,\n    &local_user_view,\n  )\n  .await?;\n\n  let multi_community_id = resolve_multi_community_identifier(\n    &data.multi_community_name,\n    data.multi_community_id,\n    &context,\n    &local_user_view,\n  )\n  .await?;\n\n  let show_hidden = data.show_hidden;\n  let show_read = data.show_read;\n  // Show nsfw content if param is true, or if content_warning exists\n  let show_nsfw = data.show_nsfw;\n  let hide_media = data.hide_media;\n  let no_comments_only = data.no_comments_only;\n  let page_cursor = data.page_cursor;\n\n  let local_user = local_user_view.as_ref().map(|u| &u.local_user);\n  let listing_type = Some(listing_type_with_default(\n    data.type_,\n    local_user,\n    local_site,\n    community_id,\n  ));\n\n  let sort = Some(post_sort_type_with_default(\n    data.sort, local_user, local_site,\n  ));\n  let time_range_seconds =\n    post_time_range_seconds_with_default(data.time_range_seconds, local_user, local_site);\n  let limit = Some(fetch_limit_with_default(data.limit, local_user, local_site));\n\n  let keyword_blocks = if let Some(local_user) = local_user {\n    Some(LocalUserKeywordBlock::read(&mut context.pool(), local_user.id).await?)\n  } else {\n    None\n  };\n  // dont allow more than page 10 for performance reasons\n  let page = data.page.map(|p| min(p, 10));\n\n  let posts = PostQuery {\n    local_user,\n    listing_type,\n    sort,\n    time_range_seconds,\n    community_id,\n    multi_community_id,\n    page,\n    limit,\n    show_hidden,\n    show_read,\n    show_nsfw,\n    hide_media,\n    no_comments_only,\n    keyword_blocks,\n    page_cursor,\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await?;\n\n  // If in their user settings (or as part of the API request), auto-mark fetched posts as read\n  if let Some(local_user) = local_user\n    && data\n      .mark_as_read\n      .unwrap_or(local_user.auto_mark_fetched_posts_as_read)\n  {\n    let post_ids = posts.iter().map(|p| p.post.id).collect::<Vec<PostId>>();\n    PostActions::mark_as_read(&mut context.pool(), local_user.person_id, &post_ids).await?;\n  }\n\n  Ok(Json(posts))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/mod.rs",
    "content": "use lemmy_apub_objects::objects::person::ApubPerson;\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{local_site::LocalSite, local_user::LocalUser},\n};\nuse lemmy_db_schema_file::enums::{CommentSortType, ListingType, PostSortType};\n\nmod fetcher;\npub mod list_comments;\npub mod list_person_content;\npub mod list_posts;\npub mod read_community;\npub mod read_multi_community;\npub mod read_person;\npub mod resolve_object;\npub mod search;\npub mod user_settings_backup;\n\n/// Returns default listing type, depending if the query is for frontpage or community.\nfn listing_type_with_default(\n  type_: Option<ListingType>,\n  local_user: Option<&LocalUser>,\n  local_site: &LocalSite,\n  community_id: Option<CommunityId>,\n) -> ListingType {\n  // On frontpage use listing type from param or admin configured default\n  if community_id.is_none() {\n    type_.unwrap_or(\n      local_user\n        .map(|u| u.default_listing_type)\n        .unwrap_or(local_site.default_post_listing_type),\n    )\n  } else {\n    // inside of community show everything\n    ListingType::All\n  }\n}\n\n/// Returns a default instance-level post sort type, if none is given by the user.\n/// Order is type, local user default, then site default.\nfn post_sort_type_with_default(\n  type_: Option<PostSortType>,\n  local_user: Option<&LocalUser>,\n  local_site: &LocalSite,\n) -> PostSortType {\n  type_.unwrap_or(\n    local_user\n      .map(|u| u.default_post_sort_type)\n      .unwrap_or(local_site.default_post_sort_type),\n  )\n}\n\n/// Returns a default post_time_range.\n/// Order is the given, then local user default, then site default.\n/// If zero is given, then the output is None.\nfn post_time_range_seconds_with_default(\n  secs: Option<i32>,\n  local_user: Option<&LocalUser>,\n  local_site: &LocalSite,\n) -> Option<i32> {\n  let out = secs\n    .or(local_user.and_then(|u| u.default_post_time_range_seconds))\n    .or(local_site.default_post_time_range_seconds);\n\n  // A zero is an override to None\n  if out.is_some_and(|o| o == 0) {\n    None\n  } else {\n    out\n  }\n}\n\n/// Returns a default instance-level comment sort type, if none is given by the user.\n/// Order is type, local user default, then site default.\nfn comment_sort_type_with_default(\n  type_: Option<CommentSortType>,\n  local_user: Option<&LocalUser>,\n  local_site: &LocalSite,\n) -> CommentSortType {\n  type_.unwrap_or(\n    local_user\n      .map(|u| u.default_comment_sort_type)\n      .unwrap_or(local_site.default_comment_sort_type),\n  )\n}\n\n/// Returns a default page fetch limit.\n/// Order is the given, then local user default, then site default.\nfn fetch_limit_with_default(\n  limit: Option<i64>,\n  local_user: Option<&LocalUser>,\n  local_site: &LocalSite,\n) -> i64 {\n  limit.unwrap_or(\n    local_user\n      .map(|u| i64::from(u.default_items_per_page))\n      .unwrap_or(i64::from(local_site.default_items_per_page)),\n  )\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/read_community.rs",
    "content": "use crate::federation::fetcher::resolve_community_identifier;\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_private_instance, is_mod_or_admin_opt, read_site_for_actor},\n};\nuse lemmy_db_schema::source::actor_language::CommunityLanguage;\nuse lemmy_db_views_community::{\n  CommunityView,\n  api::{GetCommunity, GetCommunityResponse},\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn get_community(\n  Query(data): Query<GetCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetCommunityResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  if data.name.is_none() && data.id.is_none() {\n    return Err(LemmyErrorType::NoIdGiven.into());\n  }\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let local_user = local_user_view.as_ref().map(|u| &u.local_user);\n\n  let community_id = resolve_community_identifier(&data.name, data.id, &context, &local_user_view)\n    .await?\n    .ok_or(LemmyErrorType::NoIdGiven)?;\n\n  let is_mod_or_admin = is_mod_or_admin_opt(\n    &mut context.pool(),\n    local_user_view.as_ref(),\n    Some(community_id),\n  )\n  .await\n  .is_ok();\n\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    local_user,\n    is_mod_or_admin,\n  )\n  .await?;\n\n  let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;\n\n  let site = read_site_for_actor(community_view.community.ap_id.clone(), &context).await?;\n\n  let community_id = community_view.community.id;\n  let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;\n\n  Ok(Json(GetCommunityResponse {\n    community_view,\n    site,\n    moderators,\n    discussion_languages,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/read_multi_community.rs",
    "content": "use crate::federation::fetcher::resolve_multi_community_identifier;\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_community::{\n  MultiCommunityView,\n  api::{GetMultiCommunity, GetMultiCommunityResponse},\n  impls::CommunityQuery,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn read_multi_community(\n  Query(data): Query<GetMultiCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetMultiCommunityResponse>> {\n  let my_person_id = local_user_view.as_ref().map(|l| l.person.id);\n  let id = resolve_multi_community_identifier(&data.name, data.id, &context, &local_user_view)\n    .await?\n    .ok_or(LemmyErrorType::NoIdGiven)?;\n  let multi_community_view =\n    MultiCommunityView::read(&mut context.pool(), id, my_person_id).await?;\n\n  let local_site = SiteView::read_local(&mut context.pool()).await?;\n  let communities = CommunityQuery {\n    multi_community_id: Some(id),\n    ..Default::default()\n  }\n  .list(&local_site.site, &mut context.pool())\n  .await?\n  .items;\n\n  Ok(Json(GetMultiCommunityResponse {\n    multi_community_view,\n    communities,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/read_person.rs",
    "content": "use crate::federation::fetcher::resolve_person_identifier;\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_private_instance, is_admin, read_site_for_actor},\n};\nuse lemmy_db_schema::MultiCommunitySortType;\nuse lemmy_db_views_community::impls::MultiCommunityQuery;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{\n  PersonView,\n  api::{GetPersonDetails, GetPersonDetailsResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn read_person(\n  Query(data): Query<GetPersonDetails>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetPersonDetailsResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n  let local_instance_id = site_view.site.instance_id;\n  let my_person_id = local_user_view.as_ref().map(|l| l.person.id);\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let person_details_id =\n    resolve_person_identifier(data.person_id, &data.username, &context, &local_user_view).await?;\n\n  // You don't need to return settings for the user, since this comes back with GetSite\n  // `my_user`\n  let is_admin = local_user_view\n    .as_ref()\n    .map(|l| is_admin(l).is_ok())\n    .unwrap_or_default();\n\n  let person_view = PersonView::read(\n    &mut context.pool(),\n    person_details_id,\n    my_person_id,\n    local_instance_id,\n    is_admin,\n  )\n  .await?;\n\n  let moderates = CommunityModeratorView::for_person(\n    &mut context.pool(),\n    person_details_id,\n    local_user_view.map(|l| l.local_user).as_ref(),\n  )\n  .await?;\n\n  let multi_communities_created = MultiCommunityQuery {\n    creator_id: Some(person_details_id),\n    my_person_id,\n    sort: Some(MultiCommunitySortType::NameAsc),\n    no_limit: Some(true),\n    ..Default::default()\n  }\n  .list(&mut context.pool())\n  .await?\n  .items;\n\n  let site = read_site_for_actor(person_view.person.ap_id.clone(), &context).await?;\n\n  Ok(Json(GetPersonDetailsResponse {\n    person_view,\n    site,\n    moderates,\n    multi_communities_created,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/resolve_object.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},\n};\nuse actix_web::web::{Json, Query};\nuse either::Either::*;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_is_mod_or_admin, check_private_instance},\n};\nuse lemmy_apub_objects::objects::{SearchableObjects, UserOrCommunity};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_comment::CommentView;\nuse lemmy_db_views_community::{CommunityView, MultiCommunityView};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::PersonView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_search_combined::{SearchCombinedView, SearchResponse};\nuse lemmy_db_views_site::{SiteView, api::ResolveObject};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse url::Url;\n\npub async fn resolve_object(\n  Query(data): Query<ResolveObject>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<SearchResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let resolve = Some(resolve_object_internal(&data.q, &local_user_view, &context).await?);\n  Ok(Json(SearchResponse {\n    resolve,\n    ..Default::default()\n  }))\n}\n\npub(super) async fn resolve_object_internal(\n  query: &str,\n  local_user_view: &Option<LocalUserView>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<SearchCombinedView> {\n  use SearchCombinedView::*;\n\n  let is_authenticated = local_user_view.as_ref().is_some_and(|l| !l.banned);\n\n  let object = if is_authenticated || cfg!(debug_assertions) {\n    // user is fully authenticated; allow remote lookups as well.\n    search_query_to_object_id(query.to_string(), context).await\n  } else {\n    // user isn't authenticated only allow a local search.\n    search_query_to_object_id_local(query, context).await\n  }\n  .map_err(|e| LemmyErrorType::ResolveObjectFailed(e.cause.to_string()))?;\n\n  let my_person_id_opt = local_user_view.as_ref().map(|l| l.person.id);\n  let my_person_id = my_person_id_opt.unwrap_or(PersonId(-1));\n  let local_user = local_user_view.as_ref().map(|l| l.local_user.clone());\n  let is_admin = local_user.as_ref().map(|l| l.admin).unwrap_or_default();\n  let pool = &mut context.pool();\n  let local_instance_id = SiteView::read_local(pool).await?.site.instance_id;\n\n  Ok(match object {\n    Left(Left(Left(p))) => {\n      let is_mod = check_is_mod_or_admin(pool, my_person_id, p.community_id)\n        .await\n        .is_ok();\n      Post(PostView::read(pool, p.id, local_user.as_ref(), local_instance_id, is_mod).await?)\n    }\n    Left(Left(Right(c))) => {\n      Comment(CommentView::read(pool, c.id, local_user.as_ref(), local_instance_id).await?)\n    }\n    Left(Right(Left(u))) => {\n      Person(PersonView::read(pool, u.id, my_person_id_opt, local_instance_id, is_admin).await?)\n    }\n    Left(Right(Right(c))) => {\n      Community(CommunityView::read(pool, c.id, local_user.as_ref(), is_admin).await?)\n    }\n    Right(multi) => {\n      MultiCommunity(MultiCommunityView::read(pool, multi.id, my_person_id_opt).await?)\n    }\n  })\n}\n\n/// Converts search query to object id. The query can either be an URL, which will be treated as\n/// ObjectId directly, or a webfinger identifier (@user@example.com or !community@example.com)\n/// which gets resolved to an URL.\nasync fn search_query_to_object_id(\n  mut query: String,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<SearchableObjects> {\n  Ok(match Url::parse(&query) {\n    Ok(url) => {\n      // its already an url, just go with it\n      ObjectId::from(url).dereference(context).await?\n    }\n    Err(_) => {\n      // not an url, try to resolve via webfinger\n      if query.starts_with('!') || query.starts_with('@') {\n        query.remove(0);\n      }\n      Left(Right(\n        webfinger_resolve_actor::<LemmyContext, UserOrCommunity>(&query, context).await?,\n      ))\n    }\n  })\n}\n\n/// Converts a search query to an object id.  The query MUST bbe a URL which will bbe treated\n/// as the ObjectId directly.  If the query is a webfinger identifier (@user@example.com or\n/// !community@example.com) this method will return an error.\nasync fn search_query_to_object_id_local(\n  query: &str,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<SearchableObjects> {\n  let url = Url::parse(query)?;\n  ObjectId::from(url).dereference_local(context).await\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use lemmy_db_schema::{\n    source::{\n      community::{Community, CommunityInsertForm},\n      local_site::LocalSite,\n      post::{Post, PostInsertForm, PostUpdateForm},\n    },\n    test_data::TestData,\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyErrorType;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_object_visibility() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = TestData::create(pool).await?;\n\n    let bio = \"test_local_user_bio\";\n\n    let creator =\n      LocalUserView::create_test_user(pool, \"test_local_user_name_1\", bio, false).await?;\n    let regular_user =\n      LocalUserView::create_test_user(pool, \"test_local_user_name_2\", bio, false).await?;\n    let admin_user =\n      LocalUserView::create_test_user(pool, \"test_local_user_name_3\", bio, true).await?;\n\n    let community = Community::create(\n      pool,\n      &CommunityInsertForm::new(\n        data.instance.id,\n        \"test\".to_string(),\n        \"test\".to_string(),\n        \"pubkey\".to_string(),\n      ),\n    )\n    .await?;\n\n    let post_insert_form = PostInsertForm::new(\"Test\".to_string(), creator.person.id, community.id);\n    let post = Post::create(pool, &post_insert_form).await?;\n\n    let query = post.ap_id.to_string();\n\n    // Objects should be resolvable without authentication\n    let res = resolve_object_internal(&query, &None, &context).await?;\n    assert_response(res, &post);\n    // Objects should be resolvable by regular users\n    let res = resolve_object_internal(&query, &Some(regular_user.clone()), &context).await?;\n    assert_response(res, &post);\n    // Objects should be resolvable by admins\n    let res = resolve_object_internal(&query, &Some(admin_user.clone()), &context).await?;\n    assert_response(res, &post);\n\n    Post::update(\n      pool,\n      post.id,\n      &PostUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    // Deleted objects should not be resolvable without authentication\n    let res = resolve_object_internal(&query, &None, &context).await;\n    assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound));\n    // Deleted objects should not be resolvable by regular users\n    let res = resolve_object_internal(&query, &Some(regular_user.clone()), &context).await;\n    assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound));\n    // Deleted objects should be resolvable by admins\n    let res = resolve_object_internal(&query, &Some(admin_user.clone()), &context).await?;\n    assert_response(res, &post);\n\n    LocalSite::delete(pool).await?;\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n\n  fn assert_response(res: SearchCombinedView, expected_post: &Post) {\n    if let SearchCombinedView::Post(v) = res {\n      assert_eq!(expected_post.ap_id, v.post.ap_id);\n    } else {\n      panic!(\"invalid resolve object response\");\n    }\n  }\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/search.rs",
    "content": "use crate::federation::{\n  fetcher::resolve_community_identifier,\n  resolve_object::resolve_object_internal,\n};\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse futures::future::join;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_conflicting_like_filters, check_private_instance},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_search_combined::{Search, SearchResponse, impls::SearchCombinedQuery};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn search(\n  Query(data): Query<Search>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<SearchResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n\n  check_private_instance(&local_user_view, &local_site)?;\n  check_conflicting_like_filters(data.liked_only, data.disliked_only)?;\n\n  let community_id = resolve_community_identifier(\n    &data.community_name,\n    data.community_id,\n    &context,\n    &local_user_view,\n  )\n  .await?;\n\n  let pool = &mut context.pool();\n  let search_fut = SearchCombinedQuery {\n    search_term: Some(data.q.clone()),\n    community_id,\n    creator_id: data.creator_id,\n    type_: data.type_,\n    sort: data.sort,\n    time_range_seconds: data.time_range_seconds,\n    listing_type: data.listing_type,\n    title_only: data.title_only,\n    post_url_only: data.post_url_only,\n    liked_only: data.liked_only,\n    disliked_only: data.disliked_only,\n    show_nsfw: data.show_nsfw,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n  }\n  .list(pool, &local_user_view, &site_view.site);\n\n  let resolve_fut = resolve_object_internal(&data.q, &local_user_view, &context);\n  let (search, resolve) = join(search_fut, resolve_fut).await;\n  let search = search?;\n\n  Ok(Json(SearchResponse {\n    search: search.items,\n    // ignore errors as this may not be an apub url\n    resolve: resolve.ok(),\n    next_page: search.next_page,\n    prev_page: search.prev_page,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/federation/user_settings_backup.rs",
    "content": "use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Object};\nuse actix_web::web::Json;\nuse futures::{StreamExt, future::try_join_all};\nuse itertools::Itertools;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_apub_objects::objects::{\n  comment::ApubComment,\n  community::ApubCommunity,\n  person::ApubPerson,\n  post::ApubPost,\n};\nuse lemmy_db_schema::{\n  source::{\n    actor_language::LocalUserLanguage,\n    comment::{CommentActions, CommentSavedForm},\n    community::{CommunityActions, CommunityBlockForm, CommunityFollowerForm},\n    instance::{Instance, InstanceActions, InstanceCommunitiesBlockForm, InstancePersonsBlockForm},\n    keyword_block::LocalUserKeywordBlock,\n    language::Language,\n    local_user::{LocalUser, LocalUserUpdateForm},\n    person::{Person, PersonActions, PersonBlockForm, PersonUpdateForm},\n    post::{PostActions, PostSavedForm},\n  },\n  traits::{Blockable, Followable, Saveable},\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  api::{SuccessResponse, UserSettingsBackup},\n  impls::user_backup_list_to_user_settings_backup,\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::LemmyResult,\n  spawn_try_task,\n  utils::validation::{check_api_elements_count, check_blocking_keywords_are_valid},\n};\nuse serde::Deserialize;\nuse std::{collections::HashMap, future::Future};\nuse tracing::info;\n\nconst PARALLELISM: usize = 10;\n\npub async fn export_settings(\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UserSettingsBackup>> {\n  let settings =\n    user_backup_list_to_user_settings_backup(local_user_view, &mut context.pool()).await?;\n\n  Ok(Json(settings))\n}\n\npub async fn import_settings(\n  Json(data): Json<UserSettingsBackup>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let person_form = PersonUpdateForm {\n    display_name: data.display_name.clone().map(Some),\n    bio: data.bio.clone().map(Some),\n    matrix_user_id: data.matrix_id.clone().map(Some),\n    bot_account: data.bot_account,\n    ..Default::default()\n  };\n  // ignore error in case form is empty\n  Person::update(&mut context.pool(), local_user_view.person.id, &person_form)\n    .await\n    .ok();\n\n  let local_user_form = LocalUserUpdateForm {\n    show_nsfw: data.settings.as_ref().map(|s| s.show_nsfw),\n    theme: data.settings.clone().map(|s| s.theme.clone()),\n    default_post_sort_type: data.settings.as_ref().map(|s| s.default_post_sort_type),\n    default_comment_sort_type: data.settings.as_ref().map(|s| s.default_comment_sort_type),\n    default_listing_type: data.settings.as_ref().map(|s| s.default_listing_type),\n    interface_language: data.settings.clone().map(|s| s.interface_language),\n    show_avatars: data.settings.as_ref().map(|s| s.show_avatars),\n    send_notifications_to_email: data\n      .settings\n      .as_ref()\n      .map(|s| s.send_notifications_to_email),\n    show_bot_accounts: data.settings.as_ref().map(|s| s.show_bot_accounts),\n    show_read_posts: data.settings.as_ref().map(|s| s.show_read_posts),\n    open_links_in_new_tab: data.settings.as_ref().map(|s| s.open_links_in_new_tab),\n    blur_nsfw: data.settings.as_ref().map(|s| s.blur_nsfw),\n    infinite_scroll_enabled: data.settings.as_ref().map(|s| s.infinite_scroll_enabled),\n    post_listing_mode: data.settings.as_ref().map(|s| s.post_listing_mode),\n    show_score: data.settings.as_ref().map(|s| s.show_score),\n    show_upvotes: data.settings.as_ref().map(|s| s.show_upvotes),\n    show_downvotes: data.settings.as_ref().map(|s| s.show_downvotes),\n    show_upvote_percentage: data.settings.as_ref().map(|s| s.show_upvote_percentage),\n    ..Default::default()\n  };\n  let local_user_id = local_user_view.local_user.id;\n  LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;\n\n  if !data.discussion_languages.is_empty() {\n    let all_languages: HashMap<_, _> = Language::read_all(&mut context.pool())\n      .await?\n      .into_iter()\n      .map(|l| (l.code, l.id))\n      .collect();\n    let discussion_languages = data\n      .discussion_languages\n      .iter()\n      .flat_map(|d| all_languages.get(d).copied())\n      .collect();\n    LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?;\n  }\n\n  if !data.blocking_keywords.is_empty() {\n    let trimmed_blocking_keywords = data\n      .blocking_keywords\n      .iter()\n      .map(|blocking_keyword| blocking_keyword.trim().to_string())\n      .collect();\n    check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?;\n    LocalUserKeywordBlock::update(\n      &mut context.pool(),\n      trimmed_blocking_keywords,\n      local_user_id,\n    )\n    .await?;\n  }\n  let url_count = data.followed_communities.len()\n    + data.blocked_communities.len()\n    + data.blocked_users.len()\n    + data.blocked_instances_communities.len()\n    + data.blocked_instances_persons.len()\n    + data.saved_posts.len()\n    + data.saved_comments.len();\n  check_api_elements_count(url_count)?;\n\n  spawn_try_task(async move {\n    let person_id = local_user_view.person.id;\n\n    info!(\n      \"Starting settings import for {}\",\n      local_user_view.person.name\n    );\n\n    let failed_followed_communities = fetch_and_import(\n      data\n        .followed_communities\n        .clone()\n        .into_iter()\n        .map(Into::into)\n        .collect::<Vec<ObjectId<ApubCommunity>>>(),\n      &context,\n      |(followed, context)| async move {\n        let community = followed.dereference(&context).await?;\n        let form =\n          CommunityFollowerForm::new(community.id, person_id, CommunityFollowerState::Pending);\n        CommunityActions::follow(&mut context.pool(), &form).await?;\n        LemmyResult::Ok(())\n      },\n    )\n    .await?;\n\n    let failed_saved_posts = fetch_and_import(\n      data\n        .saved_posts\n        .clone()\n        .into_iter()\n        .map(Into::into)\n        .collect::<Vec<ObjectId<ApubPost>>>(),\n      &context,\n      |(saved, context)| async move {\n        let post = saved.dereference(&context).await?;\n        let form = PostSavedForm::new(post.id, person_id);\n        PostActions::save(&mut context.pool(), &form).await?;\n        LemmyResult::Ok(())\n      },\n    )\n    .await?;\n\n    let failed_saved_comments = fetch_and_import(\n      data\n        .saved_comments\n        .clone()\n        .into_iter()\n        .map(Into::into)\n        .collect::<Vec<ObjectId<ApubComment>>>(),\n      &context,\n      |(saved, context)| async move {\n        let comment = saved.dereference(&context).await?;\n        let form = CommentSavedForm::new(person_id, comment.id);\n        CommentActions::save(&mut context.pool(), &form).await?;\n        LemmyResult::Ok(())\n      },\n    )\n    .await?;\n\n    let failed_community_blocks = fetch_and_import(\n      data\n        .blocked_communities\n        .clone()\n        .into_iter()\n        .map(Into::into)\n        .collect::<Vec<ObjectId<ApubCommunity>>>(),\n      &context,\n      |(blocked, context)| async move {\n        let community = blocked.dereference(&context).await?;\n        let form = CommunityBlockForm::new(community.id, person_id);\n        CommunityActions::block(&mut context.pool(), &form).await?;\n        LemmyResult::Ok(())\n      },\n    )\n    .await?;\n\n    let failed_user_blocks = fetch_and_import(\n      data\n        .blocked_users\n        .clone()\n        .into_iter()\n        .map(Into::into)\n        .collect::<Vec<ObjectId<ApubPerson>>>(),\n      &context,\n      |(blocked, context)| async move {\n        let target = blocked.dereference(&context).await?;\n        let form = PersonBlockForm::new(person_id, target.id);\n        PersonActions::block(&mut context.pool(), &form).await?;\n        LemmyResult::Ok(())\n      },\n    )\n    .await?;\n\n    try_join_all(\n      data\n        .blocked_instances_communities\n        .iter()\n        .map(|domain| async {\n          let instance = Instance::read_or_create(&mut context.pool(), domain).await?;\n          let form = InstanceCommunitiesBlockForm::new(person_id, instance.id);\n          InstanceActions::block_communities(&mut context.pool(), &form).await?;\n          LemmyResult::Ok(())\n        }),\n    )\n    .await?;\n\n    try_join_all(data.blocked_instances_persons.iter().map(|domain| async {\n      let instance = Instance::read_or_create(&mut context.pool(), domain).await?;\n      let form = InstancePersonsBlockForm::new(person_id, instance.id);\n      InstanceActions::block_persons(&mut context.pool(), &form).await?;\n      LemmyResult::Ok(())\n    }))\n    .await?;\n\n    info!(\n      \"Settings import completed for {}, the following items failed: {failed_followed_communities}, {failed_saved_posts}, {failed_saved_comments}, {failed_community_blocks}, {failed_user_blocks}\",\n      local_user_view.person.name\n    );\n\n    Ok(())\n  });\n\n  Ok(Json(Default::default()))\n}\n\nasync fn fetch_and_import<Kind, Fut>(\n  objects: Vec<ObjectId<Kind>>,\n  context: &Data<LemmyContext>,\n  import_fn: impl FnMut((ObjectId<Kind>, Data<LemmyContext>)) -> Fut,\n) -> LemmyResult<String>\nwhere\n  Kind: Object + Send + Sync + 'static,\n  for<'de2> <Kind as Object>::Kind: Deserialize<'de2>,\n  Fut: Future<Output = LemmyResult<()>>,\n{\n  let mut failed_items = vec![];\n  futures::stream::iter(\n    objects\n      .clone()\n      .into_iter()\n      // need to reset outgoing request count to avoid running into limit\n      .map(|s| (s, context.reset_request_count()))\n      .map(import_fn),\n  )\n  .buffer_unordered(PARALLELISM)\n  .collect::<Vec<_>>()\n  .await\n  .into_iter()\n  .enumerate()\n  .for_each(|(i, r): (usize, LemmyResult<()>)| {\n    if r.is_err()\n      && let Some(object) = objects.get(i)\n    {\n      failed_items.push(object.inner().clone());\n    }\n  });\n  Ok(failed_items.into_iter().join(\",\"))\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\npub(crate) mod tests {\n  use super::*;\n  use crate::federation::user_settings_backup::{export_settings, import_settings};\n  use actix_web::web::Json;\n  use lemmy_api_utils::context::LemmyContext;\n  use lemmy_db_schema::{\n    newtypes::LanguageId,\n    source::{\n      community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm},\n      person::Person,\n    },\n    test_data::TestData,\n    traits::Followable,\n  };\n  use lemmy_db_views_community_follower::CommunityFollowerView;\n  use lemmy_db_views_local_user::LocalUserView;\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::{LemmyErrorType, LemmyResult};\n  use serial_test::serial;\n  use std::time::Duration;\n  use tokio::time::sleep;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_settings_export_import() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = TestData::create(pool).await?;\n\n    let export_user = LocalUserView::create_test_user(pool, \"hanna\", \"my bio\", false).await?;\n\n    let community_form = CommunityInsertForm::new(\n      export_user.person.instance_id,\n      \"testcom\".to_string(),\n      \"testcom\".to_string(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n    let follower_form = CommunityFollowerForm::new(\n      community.id,\n      export_user.person.id,\n      CommunityFollowerState::Accepted,\n    );\n    CommunityActions::follow(pool, &follower_form).await?;\n    let discussion_langs_before = vec![LanguageId(1), LanguageId(2), LanguageId(3)];\n    LocalUserLanguage::update(\n      &mut context.pool(),\n      discussion_langs_before.clone(),\n      export_user.local_user.id,\n    )\n    .await?;\n    let keyword_blocks_before = vec![\"blocking_1\".to_string(), \"blocking_2\".to_string()];\n    LocalUserKeywordBlock::update(\n      &mut context.pool(),\n      keyword_blocks_before.clone(),\n      export_user.local_user.id,\n    )\n    .await?;\n\n    let backup = export_settings(export_user.clone(), context.clone()).await?;\n\n    let import_user =\n      LocalUserView::create_test_user(pool, \"charles\", \"charles bio\", false).await?;\n\n    import_settings(backup, import_user.clone(), context.clone()).await?;\n\n    // wait for background task to finish\n    sleep(Duration::from_millis(1000)).await;\n\n    let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?;\n\n    assert_eq!(\n      export_user.person.display_name,\n      import_user_updated.person.display_name\n    );\n    assert_eq!(export_user.person.bio, import_user_updated.person.bio);\n\n    let follows = CommunityFollowerView::for_person(pool, import_user.person.id).await?;\n    assert_eq!(follows.len(), 1);\n    assert_eq!(follows[0].community.ap_id, community.ap_id);\n    let discussion_langs_after =\n      LocalUserLanguage::read(&mut context.pool(), export_user.local_user.id).await?;\n    assert_eq!(discussion_langs_before, discussion_langs_after);\n    let keyword_blocks_after =\n      LocalUserKeywordBlock::read(&mut context.pool(), export_user.local_user.id).await?;\n    assert_eq!(keyword_blocks_before, keyword_blocks_after);\n\n    Person::delete(pool, export_user.person.id).await?;\n    Person::delete(pool, import_user.person.id).await?;\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn disallow_large_backup() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = TestData::create(pool).await?;\n\n    let export_user = LocalUserView::create_test_user(pool, \"harry\", \"harry bio\", false).await?;\n\n    let mut backup = export_settings(export_user.clone(), context.clone()).await?;\n\n    for _ in 0..2501 {\n      backup\n        .followed_communities\n        .push(\"http://example.com\".parse()?);\n      backup\n        .blocked_communities\n        .push(\"http://example2.com\".parse()?);\n      backup.saved_posts.push(\"http://example3.com\".parse()?);\n      backup.saved_comments.push(\"http://example4.com\".parse()?);\n    }\n\n    let import_user = LocalUserView::create_test_user(pool, \"sally\", \"sally bio\", false).await?;\n\n    let imported = import_settings(backup, import_user.clone(), context.clone()).await;\n\n    assert_eq!(\n      imported.err().map(|e| e.error_type),\n      Some(LemmyErrorType::TooManyItems)\n    );\n\n    Person::delete(pool, export_user.person.id).await?;\n    Person::delete(pool, import_user.person.id).await?;\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn import_partial_backup() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = TestData::create(pool).await?;\n\n    let import_user = LocalUserView::create_test_user(pool, \"larry\", \"larry bio\", false).await?;\n\n    let backup =\n      serde_json::from_str(\"{\\\"bot_account\\\": true, \\\"settings\\\": {\\\"theme\\\": \\\"my_theme\\\"}}\")?;\n    import_settings(Json(backup), import_user.clone(), context.clone()).await?;\n\n    let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?;\n    // mark as bot account\n    assert!(import_user_updated.person.bot_account);\n    // dont remove existing bio\n    assert_eq!(import_user.person.bio, import_user_updated.person.bio);\n    // local_user can be deserialized without id/person_id fields\n    assert_eq!(\"my_theme\", import_user_updated.local_user.theme);\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api/src/lib.rs",
    "content": "use lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin_opt};\nuse lemmy_db_schema::newtypes::CommunityId;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  utils::slurs::check_slurs,\n};\nuse regex::Regex;\nuse totp_rs::{Secret, TOTP};\n\npub mod comment;\npub mod community;\npub mod federation;\npub mod local_user;\npub mod post;\npub mod reports;\npub mod site;\npub mod sitemap;\n\n/// Check size of report\npub(crate) fn check_report_reason(reason: &str, slur_regex: &Regex) -> LemmyResult<()> {\n  check_slurs(reason, slur_regex)?;\n  if reason.is_empty() {\n    Err(LemmyErrorType::ReportReasonRequired.into())\n  } else if reason.chars().count() > 1000 {\n    Err(LemmyErrorType::ReportTooLong.into())\n  } else {\n    Ok(())\n  }\n}\n\npub(crate) fn check_totp_2fa_valid(\n  local_user_view: &LocalUserView,\n  totp_token: &Option<String>,\n  site_name: &str,\n) -> LemmyResult<()> {\n  // Throw an error if their token is missing\n  let token = totp_token\n    .as_deref()\n    .ok_or(LemmyErrorType::MissingTotpToken)?;\n  let secret = local_user_view\n    .local_user\n    .totp_2fa_secret\n    .as_deref()\n    .ok_or(LemmyErrorType::MissingTotpSecret)?;\n\n  let totp = build_totp_2fa(site_name, &local_user_view.person.name, secret)?;\n\n  let check_passed = totp.check_current(token)?;\n  if !check_passed {\n    return Err(LemmyErrorType::IncorrectTotpToken.into());\n  }\n\n  Ok(())\n}\n\npub(crate) fn generate_totp_2fa_secret() -> String {\n  Secret::generate_secret().to_string()\n}\n\nfn build_totp_2fa(hostname: &str, username: &str, secret: &str) -> LemmyResult<TOTP> {\n  let sec = Secret::Raw(secret.as_bytes().to_vec());\n  let sec_bytes = sec\n    .to_bytes()\n    .with_lemmy_type(LemmyErrorType::CouldntParseTotpSecret)?;\n\n  TOTP::new(\n    totp_rs::Algorithm::SHA1,\n    6,\n    1,\n    30,\n    sec_bytes,\n    Some(hostname.to_string()),\n    username.to_string(),\n  )\n  .with_lemmy_type(LemmyErrorType::CouldntGenerateTotp)\n}\n\n/// Only show the modlog names if:\n/// You're an admin or\n/// You're fetching the modlog for a single community, and you're a mod\n/// (Alternatively !admin/mod)\nasync fn hide_modlog_names(\n  local_user_view: Option<&LocalUserView>,\n  community_id: Option<CommunityId>,\n  context: &LemmyContext,\n) -> bool {\n  if let Some(community_id) = community_id {\n    is_mod_or_admin_opt(&mut context.pool(), local_user_view, Some(community_id))\n      .await\n      .is_err()\n  } else {\n    !local_user_view\n      .map(|l| l.local_user.admin)\n      .unwrap_or_default()\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n\n  #[test]\n  fn test_build_totp() {\n    let generated_secret = generate_totp_2fa_secret();\n    let totp = build_totp_2fa(\"lemmy.ml\", \"my_name\", &generated_secret);\n    assert!(totp.is_ok());\n  }\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/add_admin.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action, utils::is_admin};\nuse lemmy_db_schema::source::{\n  local_user::{LocalUser, LocalUserUpdateForm},\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{\n  PersonView,\n  api::{AddAdmin, AddAdminResponse},\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn add_admin(\n  Json(data): Json<AddAdmin>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<AddAdminResponse>> {\n  let my_person_id = local_user_view.person.id;\n\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  // If its an admin removal, also check that you're a higher admin\n  if !data.added {\n    LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id])\n      .await?;\n\n    // Dont allow removing the last admin\n    let admins = PersonView::list_admins(\n      None,\n      local_user_view.person.instance_id,\n      &mut context.pool(),\n    )\n    .await?;\n    if admins.len() == 1 {\n      return Err(LemmyErrorType::CannotLeaveAdmin.into());\n    }\n  }\n\n  // Make sure that the person_id added is local\n  let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id).await?;\n\n  LocalUser::update(\n    &mut context.pool(),\n    added_local_user.local_user.id,\n    &LocalUserUpdateForm {\n      admin: Some(data.added),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::admin_add(\n    &local_user_view.person,\n    added_local_user.person.id,\n    !data.added,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action.clone(), &context);\n\n  let admins = PersonView::list_admins(\n    Some(my_person_id),\n    local_user_view.person.instance_id,\n    &mut context.pool(),\n  )\n  .await?;\n\n  Ok(Json(AddAdminResponse { admins }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/ban_person.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_expire_time, is_admin, remove_or_restore_user_data},\n};\nuse lemmy_db_schema::{\n  source::{\n    instance::{InstanceActions, InstanceBanForm},\n    local_user::LocalUser,\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::Bannable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{\n  PersonView,\n  api::{BanPerson, PersonResponse},\n};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::is_valid_body_field,\n};\n\npub async fn ban_from_site(\n  Json(data): Json<BanPerson>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PersonResponse>> {\n  let local_instance_id = local_user_view.person.instance_id;\n  let my_person_id = local_user_view.person.id;\n\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  // Also make sure you're a higher admin than the target\n  LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id]).await?;\n\n  is_valid_body_field(&data.reason, false)?;\n\n  let expires_at = check_expire_time(data.expires_at)?;\n\n  let form = InstanceBanForm::new(\n    data.person_id,\n    local_user_view.person.instance_id,\n    expires_at,\n  );\n  if data.ban {\n    InstanceActions::ban(&mut context.pool(), &form).await?;\n  } else {\n    InstanceActions::unban(&mut context.pool(), &form).await?;\n  }\n\n  // Mod tables - create ban entry first so bulk actions can reference it as parent\n  let form = ModlogInsertForm::admin_ban(\n    &local_user_view.person,\n    data.person_id,\n    data.ban,\n    expires_at,\n    &data.reason,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action.clone(), &context);\n\n  // Remove their data if that's desired\n  if data.remove_or_restore_data.unwrap_or(false) {\n    let removed = data.ban;\n    remove_or_restore_user_data(\n      my_person_id,\n      data.person_id,\n      removed,\n      &data.reason,\n      action.first().ok_or(LemmyErrorType::NotFound)?.id,\n      &context,\n    )\n    .await?;\n  };\n\n  let person_view = PersonView::read(\n    &mut context.pool(),\n    data.person_id,\n    Some(my_person_id),\n    local_instance_id,\n    true,\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::BanFromSite {\n      moderator: local_user_view.person,\n      banned_user: person_view.person.clone(),\n      reason: data.reason.clone(),\n      remove_or_restore_data: data.remove_or_restore_data,\n      ban: data.ban,\n      expires_at: data.expires_at,\n    },\n    &context,\n  )?;\n\n  Ok(Json(PersonResponse { person_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/block.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::{\n  source::person::{PersonActions, PersonBlockForm},\n  traits::Blockable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{\n  PersonView,\n  api::{BlockPerson, PersonResponse},\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn user_block_person(\n  Json(data): Json<BlockPerson>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PersonResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let target_id = data.person_id;\n  let my_person_id = local_user_view.person.id;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  // Don't let a person block themselves\n  if target_id == my_person_id {\n    return Err(LemmyErrorType::CantBlockYourself.into());\n  }\n\n  let person_block_form = PersonBlockForm::new(my_person_id, target_id);\n\n  let target_user = LocalUserView::read_person(&mut context.pool(), target_id)\n    .await\n    .ok();\n\n  if target_user.is_some_and(|t| t.local_user.admin) {\n    return Err(LemmyErrorType::CantBlockAdmin.into());\n  }\n\n  if data.block {\n    PersonActions::block(&mut context.pool(), &person_block_form).await?;\n  } else {\n    PersonActions::unblock(&mut context.pool(), &person_block_form).await?;\n  }\n\n  let person_view = PersonView::read(\n    &mut context.pool(),\n    target_id,\n    Some(my_person_id),\n    local_instance_id,\n    false,\n  )\n  .await?;\n  Ok(Json(PersonResponse { person_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/change_password.rs",
    "content": "use actix_web::{\n  HttpRequest,\n  web::{Data, Json},\n};\nuse bcrypt::verify;\nuse lemmy_api_utils::{\n  claims::Claims,\n  context::LemmyContext,\n  utils::{check_local_user_valid, password_length_check},\n};\nuse lemmy_db_schema::source::{local_user::LocalUser, login_token::LoginToken};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{ChangePassword, LoginResponse};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn change_password(\n  Json(data): Json<ChangePassword>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<LoginResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  password_length_check(&data.new_password)?;\n\n  // Make sure passwords match\n  if data.new_password != data.new_password_verify {\n    return Err(LemmyErrorType::PasswordsDoNotMatch.into());\n  }\n\n  // Check the old password\n  let valid: bool = if let Some(password_encrypted) = &local_user_view.local_user.password_encrypted\n  {\n    verify(&data.old_password, password_encrypted).unwrap_or(false)\n  } else {\n    data.old_password.is_empty()\n  };\n\n  if !valid {\n    return Err(LemmyErrorType::IncorrectLogin.into());\n  }\n\n  let local_user_id = local_user_view.local_user.id;\n  let new_password = data.new_password.clone();\n  let updated_local_user =\n    LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?;\n\n  LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;\n\n  // Return the jwt\n  Ok(Json(LoginResponse {\n    jwt: Some(Claims::generate(updated_local_user.id, data.stay_logged_in, req, &context).await?),\n    verify_email_sent: false,\n    registration_created: false,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/change_password_after_reset.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::password_length_check};\nuse lemmy_db_schema::source::{\n  local_user::LocalUser,\n  login_token::LoginToken,\n  password_reset_request::PasswordResetRequest,\n};\nuse lemmy_db_views_site::api::{PasswordChangeAfterReset, SuccessResponse};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn change_password_after_reset(\n  Json(data): Json<PasswordChangeAfterReset>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  // Fetch the user_id from the token\n  let token = data.token.clone();\n  let local_user_id = PasswordResetRequest::read_and_delete(&mut context.pool(), &token)\n    .await?\n    .local_user_id;\n\n  password_length_check(&data.password)?;\n\n  // Make sure passwords match\n  if data.password != data.password_verify {\n    return Err(LemmyErrorType::PasswordsDoNotMatch.into());\n  }\n\n  // Update the user with the new password\n  let password = data.password.clone();\n  LocalUser::update_password(&mut context.pool(), local_user_id, &password).await?;\n\n  LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/donation_dialog_shown.rs",
    "content": "use actix_web::web::{Data, Json};\nuse chrono::Utc;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn donation_dialog_shown(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let form = LocalUserUpdateForm {\n    last_donation_notification_at: Some(Utc::now()),\n    ..Default::default()\n  };\n  LocalUser::update(&mut context.pool(), local_user_view.local_user.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/export_data.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_notification::{NotificationData, impls::NotificationQuery};\nuse lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery;\nuse lemmy_db_views_person_liked_combined::impls::PersonLikedCombinedQuery;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_post_comment_combined::PostCommentCombinedView;\nuse lemmy_db_views_site::{\n  api::{ExportDataResponse, PostOrCommentOrPrivateMessage},\n  impls::user_backup_list_to_user_settings_backup,\n};\nuse lemmy_utils::{self, error::LemmyResult};\n\npub async fn export_data(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<ExportDataResponse>> {\n  use PostOrCommentOrPrivateMessage::*;\n\n  let local_instance_id = local_user_view.person.instance_id;\n  let my_person_id = local_user_view.person.id;\n  let my_person = &local_user_view.person;\n  let local_user = &local_user_view.local_user;\n\n  let pool = &mut context.pool();\n\n  let content = PersonContentCombinedQuery {\n    no_limit: Some(true),\n    ..PersonContentCombinedQuery::new(my_person_id)\n  }\n  .list(pool, Some(&local_user_view), local_instance_id)\n  .await?\n  .into_iter()\n  .map(|u| match u {\n    PostCommentCombinedView::Post(pv) => Post(pv.post),\n    PostCommentCombinedView::Comment(cv) => Comment(cv.comment),\n  })\n  .collect();\n\n  let notifications = NotificationQuery {\n    no_limit: Some(true),\n    show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts),\n    ..NotificationQuery::default()\n  }\n  .list(pool, &local_user_view.person)\n  .await?\n  .into_iter()\n  .flat_map(|u| match u.data {\n    NotificationData::Post(p) => Some(Post(p.post)),\n    NotificationData::Comment(c) => Some(Comment(c.comment)),\n    NotificationData::PrivateMessage(pm) => Some(PrivateMessage(pm.private_message)),\n    // skip modlog items\n    NotificationData::ModAction(_) => None,\n  })\n  .collect();\n\n  let liked = PersonLikedCombinedQuery {\n    no_limit: Some(true),\n    ..PersonLikedCombinedQuery::default()\n  }\n  .list(pool, &local_user_view)\n  .await?\n  .into_iter()\n  .map(|u| {\n    match u {\n      PostCommentCombinedView::Post(pv) => pv.post.ap_id,\n      PostCommentCombinedView::Comment(cv) => cv.comment.ap_id,\n    }\n    .into()\n  })\n  .collect();\n\n  let read_posts = PostView::list_read(pool, my_person, None, None, Some(true))\n    .await?\n    .into_iter()\n    .map(|pv| pv.post.ap_id.into())\n    .collect();\n\n  let moderates = CommunityModeratorView::for_person(pool, my_person_id, Some(local_user))\n    .await?\n    .into_iter()\n    .map(|cv| cv.community.ap_id.into())\n    .collect();\n\n  let settings =\n    user_backup_list_to_user_settings_backup(local_user_view, &mut context.pool()).await?;\n\n  Ok(Json(ExportDataResponse {\n    notifications,\n    content,\n    liked,\n    read_posts,\n    moderates,\n    settings,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/generate_totp_secret.rs",
    "content": "use crate::{build_totp_2fa, generate_totp_2fa_secret};\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{SiteView, api::GenerateTotpSecretResponse};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\n/// Generate a new secret for two-factor-authentication. Afterwards you need to call [toggle_totp]\n/// to enable it. This can only be called if 2FA is currently disabled.\npub async fn generate_totp_secret(\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<GenerateTotpSecretResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let site = SiteView::read_local(&mut context.pool()).await?.site;\n\n  if local_user_view.local_user.totp_2fa_enabled {\n    return Err(LemmyErrorType::TotpAlreadyEnabled.into());\n  }\n\n  let secret = generate_totp_2fa_secret();\n  let secret_url = build_totp_2fa(&site.name, &local_user_view.person.name, &secret)?.get_url();\n\n  let local_user_form = LocalUserUpdateForm {\n    totp_2fa_secret: Some(Some(secret)),\n    ..Default::default()\n  };\n  LocalUser::update(\n    &mut context.pool(),\n    local_user_view.local_user.id,\n    &local_user_form,\n  )\n  .await?;\n\n  Ok(Json(GenerateTotpSecretResponse {\n    totp_secret_url: secret_url.into(),\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/get_captcha.rs",
    "content": "use actix_web::{\n  HttpResponse,\n  HttpResponseBuilder,\n  http::{\n    StatusCode,\n    header::{CacheControl, CacheDirective},\n  },\n  web::Json,\n};\nuse lemmy_api_utils::plugins::{is_captcha_plugin_loaded, plugin_get_captcha};\nuse lemmy_db_views_site::api::GetCaptchaResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_captcha() -> LemmyResult<HttpResponse> {\n  let mut res = HttpResponseBuilder::new(StatusCode::OK);\n  res.insert_header(CacheControl(vec![CacheDirective::NoStore]));\n\n  if !is_captcha_plugin_loaded() {\n    return Ok(res.json(Json(GetCaptchaResponse { ok: None })));\n  }\n\n  let captcha = GetCaptchaResponse {\n    ok: Some(plugin_get_captcha().await?),\n  };\n  Ok(res.json(Json(captcha)))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_hidden.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person_content_combined::api::ListPersonHidden;\nuse lemmy_db_views_post::PostView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_person_hidden(\n  Query(data): Query<ListPersonHidden>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<PostView>>> {\n  let hidden = PostView::list_hidden(\n    &mut context.pool(),\n    &local_user_view.person,\n    data.page_cursor,\n    data.limit,\n    None,\n  )\n  .await?;\n\n  Ok(Json(hidden))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_liked.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person_liked_combined::{ListPersonLiked, impls::PersonLikedCombinedQuery};\nuse lemmy_db_views_post_comment_combined::PostCommentCombinedView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_person_liked(\n  Query(data): Query<ListPersonLiked>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<PostCommentCombinedView>>> {\n  let liked = PersonLikedCombinedQuery {\n    type_: data.type_,\n    like_type: data.like_type,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    no_limit: None,\n  }\n  .list(&mut context.pool(), &local_user_view)\n  .await?;\n\n  Ok(Json(liked))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_logins.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::login_token::LoginToken;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::ListLoginsResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_logins(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<ListLoginsResponse>> {\n  let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?;\n\n  Ok(Json(ListLoginsResponse { logins }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_media.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_local_image::{LocalImageView, api::ListMedia};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_media(\n  Query(data): Query<ListMedia>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<LocalImageView>>> {\n  let images = LocalImageView::get_all_paged_by_person_id(\n    &mut context.pool(),\n    local_user_view.person.id,\n    data.page_cursor,\n    data.limit,\n  )\n  .await?;\n  Ok(Json(images))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_read.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person_content_combined::api::ListPersonRead;\nuse lemmy_db_views_post::PostView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_person_read(\n  Query(data): Query<ListPersonRead>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<PostView>>> {\n  let read = PostView::list_read(\n    &mut context.pool(),\n    &local_user_view.person,\n    data.page_cursor,\n    data.limit,\n    None,\n  )\n  .await?;\n\n  Ok(Json(read))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/list_saved.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person_saved_combined::{ListPersonSaved, impls::PersonSavedCombinedQuery};\nuse lemmy_db_views_post_comment_combined::PostCommentCombinedView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_person_saved(\n  Query(data): Query<ListPersonSaved>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<PostCommentCombinedView>>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?;\n\n  check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?;\n\n  let saved = PersonSavedCombinedQuery {\n    type_: data.type_,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    no_limit: None,\n  }\n  .list(&mut context.pool(), &local_user_view)\n  .await?;\n\n  Ok(Json(saved))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/login.rs",
    "content": "use crate::check_totp_2fa_valid;\nuse actix_web::{\n  HttpRequest,\n  web::{Data, Json},\n};\nuse bcrypt::verify;\nuse lemmy_api_utils::{\n  claims::Claims,\n  context::LemmyContext,\n  utils::{check_email_verified, check_local_user_deleted, check_registration_application},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{Login, LoginResponse},\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn login(\n  Json(data): Json<Login>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<LoginResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n  // Fetch that username / email\n  let username_or_email = data.username_or_email.clone();\n  let local_user_view =\n    LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email).await?;\n\n  // Verify the password\n  let valid: bool = local_user_view\n    .local_user\n    .password_encrypted\n    .as_ref()\n    .and_then(|password_encrypted| verify(&data.password, password_encrypted).ok())\n    .unwrap_or(false);\n  if !valid {\n    return Err(LemmyErrorType::IncorrectLogin.into());\n  }\n  check_local_user_deleted(&local_user_view)?;\n  check_email_verified(&local_user_view, &site_view)?;\n\n  check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool())\n    .await?;\n\n  // Check the totp if enabled\n  if local_user_view.local_user.totp_2fa_enabled {\n    check_totp_2fa_valid(\n      &local_user_view,\n      &data.totp_2fa_token,\n      &context.settings().hostname,\n    )?;\n  }\n\n  let jwt = Claims::generate(\n    local_user_view.local_user.id,\n    data.stay_logged_in,\n    req,\n    &context,\n  )\n  .await?;\n\n  Ok(Json(LoginResponse {\n    jwt: Some(jwt.clone()),\n    verify_email_sent: false,\n    registration_created: false,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/logout.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::{HttpRequest, HttpResponse, cookie::Cookie};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{AUTH_COOKIE_NAME, read_auth_token},\n};\nuse lemmy_db_schema::source::login_token::LoginToken;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn logout(\n  req: HttpRequest,\n  // require login\n  _local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let jwt = read_auth_token(&req)?.ok_or(LemmyErrorType::NotLoggedIn)?;\n  LoginToken::invalidate(&mut context.pool(), &jwt).await?;\n\n  let mut res = HttpResponse::Ok().json(SuccessResponse::default());\n  let cookie = Cookie::new(AUTH_COOKIE_NAME, \"\");\n  res.add_removal_cookie(&cookie)?;\n  Ok(res)\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/mod.rs",
    "content": "pub mod add_admin;\npub mod ban_person;\npub mod block;\npub mod change_password;\npub mod change_password_after_reset;\npub mod donation_dialog_shown;\npub mod export_data;\npub mod generate_totp_secret;\npub mod get_captcha;\npub mod list_hidden;\npub mod list_liked;\npub mod list_logins;\npub mod list_media;\npub mod list_read;\npub mod list_saved;\npub mod login;\npub mod logout;\npub mod note_person;\npub mod notifications;\npub mod resend_verification_email;\npub mod reset_password;\npub mod save_settings;\npub mod unread_counts;\npub mod update_totp;\npub mod user_block_instance;\npub mod validate_auth;\npub mod verify_email;\n"
  },
  {
    "path": "crates/api/api/src/local_user/note_person.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_local_user_valid, get_url_blocklist, process_markdown, slur_regex},\n};\nuse lemmy_db_schema::source::person::{PersonActions, PersonNoteForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::{\n  PersonView,\n  api::{NotePerson, PersonResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::{slurs::check_slurs, validation::is_valid_body_field},\n};\n\npub async fn user_note_person(\n  Json(data): Json<NotePerson>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PersonResponse>> {\n  check_local_user_valid(&local_user_view)?;\n\n  let target_id = data.person_id;\n  let my_person_id = local_user_view.person.id;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n\n  // Don't let a person note themselves\n  if target_id == my_person_id {\n    return Err(LemmyErrorType::CantNoteYourself.into());\n  }\n\n  // If the note is empty, delete it\n  if data.note.is_empty() {\n    PersonActions::delete_note(&mut context.pool(), my_person_id, target_id).await?;\n  } else {\n    check_slurs(&data.note, &slur_regex)?;\n    is_valid_body_field(&data.note, false)?;\n\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    let note = process_markdown(\n      &data.note,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?;\n    let note_form = PersonNoteForm::new(my_person_id, target_id, note);\n\n    PersonActions::note(&mut context.pool(), &note_form).await?;\n  }\n\n  let person_view = PersonView::read(\n    &mut context.pool(),\n    target_id,\n    Some(my_person_id),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  Ok(Json(PersonResponse { person_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/notifications/list.rs",
    "content": "use crate::hide_modlog_names;\nuse actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_notification::{ListNotifications, NotificationView, impls::NotificationQuery};\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_notifications(\n  Query(data): Query<ListNotifications>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<NotificationView>>> {\n  let hide_modlog_names = hide_modlog_names(Some(&local_user_view), None, &context).await;\n  let notifications = NotificationQuery {\n    type_: data.type_,\n    unread_only: data.unread_only,\n    show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts),\n    page_cursor: data.page_cursor,\n    hide_modlog_names: Some(hide_modlog_names),\n    creator_id: data.creator_id,\n    limit: data.limit,\n    no_limit: None,\n  }\n  .list(&mut context.pool(), &local_user_view.person)\n  .await?;\n\n  Ok(Json(notifications))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/notifications/mark_all_read.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::notification::Notification;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn mark_all_notifications_read(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  Notification::mark_all_as_read(&mut context.pool(), local_user_view.person.id).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/notifications/mark_notification_read.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::notification::Notification;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_notification::api::MarkNotificationAsRead;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn mark_notification_as_read(\n  Json(data): Json<MarkNotificationAsRead>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  Notification::mark_read_by_id_and_person(\n    &mut context.pool(),\n    data.notification_id,\n    local_user_view.person.id,\n    data.read,\n  )\n  .await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/notifications/mod.rs",
    "content": "pub mod list;\npub mod mark_all_read;\npub mod mark_notification_read;\n"
  },
  {
    "path": "crates/api/api/src/local_user/resend_verification_email.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{ResendVerificationEmail, SuccessResponse},\n};\nuse lemmy_email::account::send_verification_email_if_required;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn resend_verification_email(\n  Json(data): Json<ResendVerificationEmail>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let email = data.email.to_string();\n\n  // Fetch that email\n  let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email).await?;\n  check_local_user_valid(&local_user_view)?;\n\n  send_verification_email_if_required(\n    &site_view.local_site,\n    &local_user_view,\n    &mut context.pool(),\n    context.settings(),\n  )\n  .await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/reset_password.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_email_verified, check_local_user_valid},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{PasswordReset, SuccessResponse},\n};\nuse lemmy_email::account::send_password_reset_email;\nuse lemmy_utils::error::LemmyResult;\nuse tracing::error;\n\npub async fn reset_password(\n  Json(data): Json<PasswordReset>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let email = data.email.to_lowercase();\n  // For security, errors are not returned.\n  // https://github.com/LemmyNet/lemmy/issues/5277\n  let _ = try_reset_password(&email, &context).await;\n  Ok(Json(SuccessResponse::default()))\n}\n\nasync fn try_reset_password(email: &str, context: &LemmyContext) -> LemmyResult<()> {\n  let local_user_view = LocalUserView::find_by_email(&mut context.pool(), email).await?;\n  check_local_user_valid(&local_user_view)?;\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n  check_email_verified(&local_user_view, &site_view)?;\n  if let Err(e) =\n    send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await\n  {\n    error!(\"Failed to send password reset email: {}\", e);\n  }\n\n  Ok(())\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/save_settings.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::{\n    actor_language::LocalUserLanguage,\n    keyword_block::LocalUserKeywordBlock,\n    local_user::{LocalUser, LocalUserUpdateForm},\n    person::{Person, PersonUpdateForm},\n  },\n  utils::limit_fetch_check,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{SaveUserSettings, SuccessResponse},\n};\nuse lemmy_diesel_utils::{\n  traits::Crud,\n  utils::{diesel_opt_number_update, diesel_string_update},\n};\nuse lemmy_email::account::send_verification_email;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::{\n    check_blocking_keywords_are_valid,\n    is_valid_bio_field,\n    is_valid_display_name,\n    is_valid_matrix_id,\n  },\n};\nuse std::ops::Deref;\n\npub async fn save_user_settings(\n  Json(data): Json<SaveUserSettings>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let bio = diesel_string_update(\n    process_markdown_opt(\n      &data.bio,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?\n    .as_deref(),\n  );\n\n  let display_name = diesel_string_update(data.display_name.as_deref().map(str::trim));\n  let matrix_user_id = diesel_string_update(data.matrix_user_id.as_deref());\n  let email_deref = data.email.as_deref().map(str::to_lowercase);\n  let email = diesel_string_update(email_deref.as_deref());\n\n  if let Some(Some(email)) = email.clone() {\n    let previous_email = local_user_view.local_user.email.clone().unwrap_or_default();\n    // if email was changed, check that it is not taken and send verification mail\n    if previous_email.deref() != email {\n      LocalUser::check_is_email_taken(&mut context.pool(), &email).await?;\n      send_verification_email(\n        &local_site,\n        &local_user_view,\n        email.into(),\n        &mut context.pool(),\n        context.settings(),\n      )\n      .await?;\n    }\n  }\n\n  // When the site requires email, make sure email is not Some(None). IE, an overwrite to a None\n  // value\n  if let Some(email) = &email\n    && email.is_none()\n    && local_site.require_email_verification\n  {\n    return Err(LemmyErrorType::EmailRequired.into());\n  }\n\n  if let Some(Some(bio)) = &bio {\n    is_valid_bio_field(bio)?;\n  }\n\n  if let Some(Some(display_name)) = &display_name {\n    is_valid_display_name(display_name)?;\n  }\n\n  if let Some(Some(matrix_user_id)) = &matrix_user_id {\n    is_valid_matrix_id(matrix_user_id)?;\n  }\n\n  if let Some(send_notifications_to_email) = data.send_notifications_to_email\n    && local_site.disable_email_notifications\n    && send_notifications_to_email\n  {\n    return Err(LemmyErrorType::EmailNotificationsDisabled.into());\n  }\n\n  let local_user_id = local_user_view.local_user.id;\n  let person_id = local_user_view.person.id;\n  let default_listing_type = data.default_listing_type;\n  let default_post_sort_type = data.default_post_sort_type;\n  let default_post_time_range_seconds =\n    diesel_opt_number_update(data.default_post_time_range_seconds);\n\n  let default_items_per_page = data.default_items_per_page;\n  if let Some(default_items_per_page) = default_items_per_page {\n    limit_fetch_check(default_items_per_page.into())?;\n  };\n\n  let default_comment_sort_type = data.default_comment_sort_type;\n\n  let person_form = PersonUpdateForm {\n    display_name,\n    bio,\n    matrix_user_id,\n    bot_account: data.bot_account,\n    ..Default::default()\n  };\n\n  // Ignore errors, because 'no fields updated' will return an error.\n  // https://github.com/LemmyNet/lemmy/issues/4076\n  Person::update(&mut context.pool(), person_id, &person_form)\n    .await\n    .ok();\n\n  if let Some(discussion_languages) = data.discussion_languages.clone() {\n    LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?;\n  }\n\n  if let Some(blocking_keywords) = data.blocking_keywords.clone() {\n    let trimmed_blocking_keywords = blocking_keywords\n      .iter()\n      .map(|blocking_keyword| blocking_keyword.trim().to_string())\n      .collect();\n    check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?;\n    LocalUserKeywordBlock::update(\n      &mut context.pool(),\n      trimmed_blocking_keywords,\n      local_user_id,\n    )\n    .await?;\n  }\n\n  let local_user_form = LocalUserUpdateForm {\n    email,\n    show_avatars: data.show_avatars,\n    show_read_posts: data.show_read_posts,\n    send_notifications_to_email: data.send_notifications_to_email,\n    show_nsfw: data.show_nsfw,\n    blur_nsfw: data.blur_nsfw,\n    show_bot_accounts: data.show_bot_accounts,\n    default_post_sort_type,\n    default_post_time_range_seconds,\n    default_comment_sort_type,\n    default_listing_type,\n    default_items_per_page,\n    theme: data.theme.clone(),\n    interface_language: data.interface_language.clone(),\n    open_links_in_new_tab: data.open_links_in_new_tab,\n    infinite_scroll_enabled: data.infinite_scroll_enabled,\n    post_listing_mode: data.post_listing_mode,\n    enable_animated_images: data.enable_animated_images,\n    enable_private_messages: data.enable_private_messages,\n    collapse_bot_comments: data.collapse_bot_comments,\n    auto_mark_fetched_posts_as_read: data.auto_mark_fetched_posts_as_read,\n    hide_media: data.hide_media,\n    // Update the vote display modes\n    show_score: data.show_score,\n    show_upvotes: data.show_upvotes,\n    show_downvotes: data.show_downvotes,\n    show_upvote_percentage: data.show_upvote_percentage,\n    show_person_votes: data.show_person_votes,\n    ..Default::default()\n  };\n\n  LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/unread_counts.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_community_mod_of_any_or_admin_action, is_admin},\n};\nuse lemmy_db_views_community_follower_approval::PendingFollowerView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_notification::NotificationView;\nuse lemmy_db_views_registration_applications::RegistrationApplicationView;\nuse lemmy_db_views_report_combined::ReportCombinedViewInternal;\nuse lemmy_db_views_site::{SiteView, api::UnreadCountsResponse};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_unread_counts(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<UnreadCountsResponse>> {\n  let person = &local_user_view.person;\n  let show_bot_accounts = local_user_view.local_user.show_bot_accounts;\n\n  let notification_count =\n    NotificationView::get_unread_count(&mut context.pool(), person, show_bot_accounts).await?;\n\n  // Community mods get additional counts for reports and pending follows for private communities.\n  let (report_count, pending_follow_count) =\n    if check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool())\n      .await\n      .is_ok()\n    {\n      (\n        Some(\n          ReportCombinedViewInternal::get_report_count(&mut context.pool(), &local_user_view)\n            .await?,\n        ),\n        Some(PendingFollowerView::count_approval_required(&mut context.pool(), person.id).await?),\n      )\n    } else {\n      (None, None)\n    };\n\n  // Admins also get the number of unread registration applications.\n  let registration_application_count = if is_admin(&local_user_view).is_ok() {\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n    let verified_email_only = local_site.require_email_verification;\n    Some(\n      RegistrationApplicationView::get_unread_count(&mut context.pool(), verified_email_only)\n        .await?,\n    )\n  } else {\n    None\n  };\n\n  Ok(Json(UnreadCountsResponse {\n    notification_count,\n    report_count,\n    pending_follow_count,\n    registration_application_count,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/update_totp.rs",
    "content": "use crate::check_totp_2fa_valid;\nuse actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{EditTotp, EditTotpResponse};\nuse lemmy_utils::error::LemmyResult;\n\n/// Enable or disable two-factor-authentication. The current setting is determined from\n/// [LocalUser.totp_2fa_enabled].\n///\n/// To enable, you need to first call [generate_totp_secret] and then pass a valid token to this\n/// function.\n///\n/// Disabling is only possible if 2FA was previously enabled. Again it is necessary to pass a valid\n/// token.\npub async fn edit_totp(\n  Json(data): Json<EditTotp>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<EditTotpResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  check_totp_2fa_valid(\n    &local_user_view,\n    &Some(data.totp_token.clone()),\n    &context.settings().hostname,\n  )?;\n\n  // toggle the 2fa setting\n  let local_user_form = LocalUserUpdateForm {\n    totp_2fa_enabled: Some(data.enabled),\n    // if totp is enabled, leave unchanged. otherwise clear secret\n    totp_2fa_secret: if data.enabled { None } else { Some(None) },\n    ..Default::default()\n  };\n\n  LocalUser::update(\n    &mut context.pool(),\n    local_user_view.local_user.id,\n    &local_user_form,\n  )\n  .await?;\n\n  Ok(Json(EditTotpResponse {\n    enabled: data.enabled,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/user_block_instance.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::instance::{\n  InstanceActions,\n  InstanceCommunitiesBlockForm,\n  InstancePersonsBlockForm,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{\n  SuccessResponse,\n  UserBlockInstanceCommunitiesParams,\n  UserBlockInstancePersonsParams,\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn user_block_instance_communities(\n  Json(data): Json<UserBlockInstanceCommunitiesParams>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let instance_id = data.instance_id;\n  let person_id = local_user_view.person.id;\n  if local_user_view.person.instance_id == instance_id {\n    return Err(LemmyErrorType::CantBlockLocalInstance.into());\n  }\n\n  let block_form = InstanceCommunitiesBlockForm::new(person_id, instance_id);\n\n  if data.block {\n    InstanceActions::block_communities(&mut context.pool(), &block_form).await?;\n  } else {\n    InstanceActions::unblock_communities(&mut context.pool(), &block_form).await?;\n  }\n\n  Ok(Json(SuccessResponse::default()))\n}\n\npub async fn user_block_instance_persons(\n  Json(data): Json<UserBlockInstancePersonsParams>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let instance_id = data.instance_id;\n  let person_id = local_user_view.person.id;\n  if local_user_view.person.instance_id == instance_id {\n    return Err(LemmyErrorType::CantBlockLocalInstance.into());\n  }\n\n  let block_form = InstancePersonsBlockForm::new(person_id, instance_id);\n\n  if data.block {\n    InstanceActions::block_persons(&mut context.pool(), &block_form).await?;\n  } else {\n    InstanceActions::unblock_persons(&mut context.pool(), &block_form).await?;\n  }\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/validate_auth.rs",
    "content": "use actix_web::{\n  HttpRequest,\n  web::{Data, Json},\n};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{local_user_view_from_jwt, read_auth_token},\n};\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\n/// Returns an error message if the auth token is invalid for any reason. Necessary because other\n/// endpoints silently treat any call with invalid auth as unauthenticated.\npub async fn validate_auth(\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let jwt = read_auth_token(&req)?;\n  if let Some(jwt) = jwt {\n    local_user_view_from_jwt(&jwt, &context).await?;\n  } else {\n    return Err(LemmyErrorType::NotLoggedIn.into());\n  }\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/local_user/verify_email.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::{\n  email_verification::EmailVerification,\n  local_user::{LocalUser, LocalUserUpdateForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{SuccessResponse, VerifyEmail},\n};\nuse lemmy_email::{account::send_email_verified_email, admin::send_new_applicant_email_to_admins};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn verify_email(\n  Json(data): Json<VerifyEmail>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let token = data.token.clone();\n  let verification = EmailVerification::read_for_token(&mut context.pool(), &token).await?;\n  let local_user_id = verification.local_user_id;\n  let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;\n  check_local_user_valid(&local_user_view)?;\n\n  // Check if their email has already been verified once, before this\n  let email_already_verified = local_user_view.local_user.email_verified;\n\n  let form = LocalUserUpdateForm {\n    // necessary in case this is a new signup\n    email_verified: Some(true),\n    // necessary in case email of an existing user was changed\n    email: Some(Some(verification.email)),\n    ..Default::default()\n  };\n\n  LocalUser::update(&mut context.pool(), local_user_id, &form).await?;\n\n  EmailVerification::delete_old_tokens_for_local_user(&mut context.pool(), local_user_id).await?;\n\n  // Send out notification about registration application to admins if enabled, and the user hasn't\n  // already been verified.\n  if site_view.local_site.application_email_admins && !email_already_verified {\n    send_new_applicant_email_to_admins(\n      &local_user_view.person.name,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n  }\n\n  send_email_verified_email(&local_user_view, context.settings())?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/feature.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_mod_action, is_admin},\n};\nuse lemmy_db_schema::{\n  PostFeatureType,\n  source::{\n    community::Community,\n    modlog::{Modlog, ModlogInsertForm},\n    post::{Post, PostUpdateForm},\n  },\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::{FeaturePost, PostResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn feature_post(\n  Json(data): Json<FeaturePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let post_id = data.post_id;\n  let orig_post = Post::read(&mut context.pool(), post_id).await?;\n\n  let community = Community::read(&mut context.pool(), orig_post.community_id).await?;\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  if data.feature_type == PostFeatureType::Local {\n    is_admin(&local_user_view)?;\n  }\n\n  // Update the post\n  let post_id = data.post_id;\n  let (post_form, modlog_form) = if data.feature_type == PostFeatureType::Community {\n    (\n      PostUpdateForm {\n        featured_community: Some(data.featured),\n        ..Default::default()\n      },\n      ModlogInsertForm::mod_feature_post_community(\n        local_user_view.person.id,\n        &orig_post,\n        data.featured,\n      ),\n    )\n  } else {\n    (\n      PostUpdateForm {\n        featured_local: Some(data.featured),\n        ..Default::default()\n      },\n      ModlogInsertForm::admin_feature_post_site(&local_user_view.person, &orig_post, data.featured),\n    )\n  };\n  let post = Post::update(&mut context.pool(), post_id, &post_form).await?;\n\n  // Mod tables\n  Modlog::create(&mut context.pool(), &[modlog_form]).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::FeaturePost(post, local_user_view.person.clone(), data.featured),\n    &context,\n  )?;\n\n  build_post_response(&context, orig_post.community_id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api/src/post/get_link_metadata.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, request::fetch_link_metadata};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::{GetSiteMetadata, GetSiteMetadataResponse};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse url::Url;\n\npub async fn get_link_metadata(\n  Query(data): Query<GetSiteMetadata>,\n  context: Data<LemmyContext>,\n  // Require an account for this API\n  _local_user_view: LocalUserView,\n) -> LemmyResult<Json<GetSiteMetadataResponse>> {\n  let url = Url::parse(&data.url).with_lemmy_type(LemmyErrorType::InvalidUrl)?;\n  let metadata = fetch_link_metadata(&url, &context, false).await?;\n\n  Ok(Json(GetSiteMetadataResponse { metadata }))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/hide.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::source::post::{PostActions, PostHideForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{HidePost, PostResponse},\n};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn hide_post(\n  Json(data): Json<HidePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let person_id = local_user_view.person.id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let post_id = data.post_id;\n\n  let hide_form = PostHideForm::new(post_id, person_id);\n\n  // Mark the post as hidden / unhidden\n  if data.hide {\n    PostActions::hide(&mut context.pool(), &hide_form).await?;\n  } else {\n    PostActions::unhide(&mut context.pool(), &hide_form).await?;\n  }\n\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  Ok(Json(PostResponse { post_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/like.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_bot_account,\n    check_community_user_action,\n    check_local_user_valid,\n    check_local_vote_mode,\n  },\n};\nuse lemmy_db_schema::{\n  newtypes::PostOrCommentId,\n  source::{\n    notification::Notification,\n    person::PersonActions,\n    post::{PostActions, PostLikeForm},\n  },\n  traits::Likeable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{CreatePostLike, PostResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\nuse std::ops::Deref;\n\npub async fn like_post(\n  Json(data): Json<CreatePostLike>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let local_instance_id = local_user_view.person.instance_id;\n  let post_id = data.post_id;\n  let my_person_id = local_user_view.person.id;\n\n  check_local_vote_mode(\n    data.is_upvote,\n    PostOrCommentId::Post(post_id),\n    &local_site,\n    my_person_id,\n    &mut context.pool(),\n  )\n  .await?;\n  check_bot_account(&local_user_view.person)?;\n\n  // Check for a community ban\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n  let previous_is_upvote = orig_post.post_actions.and_then(|p| p.vote_is_upvote);\n\n  check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?;\n\n  let mut like_form = PostLikeForm::new(data.post_id, my_person_id, data.is_upvote);\n  like_form = plugin_hook_before(\"post_before_vote\", like_form).await?;\n  let like = PostActions::like(&mut context.pool(), &like_form).await?;\n  PersonActions::like(\n    &mut context.pool(),\n    my_person_id,\n    orig_post.creator.id,\n    previous_is_upvote,\n    data.is_upvote,\n  )\n  .await?;\n\n  plugin_hook_after(\"post_after_vote\", &like);\n\n  // Mark Post Read\n  PostActions::mark_as_read(&mut context.pool(), my_person_id, &[post_id]).await?;\n\n  // Mark any notifications as read\n  Notification::mark_read_by_post_and_recipient(&mut context.pool(), post_id, my_person_id, true)\n    .await\n    .ok();\n\n  ActivityChannel::submit_activity(\n    SendActivityData::LikePostOrComment {\n      object_id: orig_post.post.ap_id,\n      actor: local_user_view.person.clone(),\n      community: orig_post.community.clone(),\n      previous_is_upvote,\n      new_is_upvote: data.is_upvote,\n    },\n    &context,\n  )?;\n\n  build_post_response(\n    context.deref(),\n    orig_post.community.id,\n    local_user_view,\n    post_id,\n  )\n  .await\n}\n"
  },
  {
    "path": "crates/api/api/src/post/list_post_likes.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin};\nuse lemmy_db_schema::source::post::Post;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::ListPostLikes;\nuse lemmy_db_views_vote::VoteView;\nuse lemmy_diesel_utils::{pagination::PagedResponse, traits::Crud};\nuse lemmy_utils::error::LemmyResult;\n\n/// Lists likes for a post\npub async fn list_post_likes(\n  Query(data): Query<ListPostLikes>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<VoteView>>> {\n  let post = Post::read(&mut context.pool(), data.post_id).await?;\n  is_mod_or_admin(&mut context.pool(), &local_user_view, post.community_id).await?;\n\n  let post_likes = VoteView::list_for_post(\n    &mut context.pool(),\n    data.post_id,\n    data.page_cursor,\n    data.limit,\n    local_user_view.person.instance_id,\n  )\n  .await?;\n\n  Ok(Json(post_likes))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/lock.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::source::{\n  modlog::{Modlog, ModlogInsertForm},\n  post::{Post, PostUpdateForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{LockPost, PostResponse},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn lock_post(\n  Json(data): Json<LockPost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let post_id = data.post_id;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  check_community_mod_action(\n    &local_user_view,\n    &orig_post.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Update the post\n  let post_id = data.post_id;\n  let locked = data.locked;\n  let post = Post::update(\n    &mut context.pool(),\n    post_id,\n    &PostUpdateForm {\n      locked: Some(locked),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::mod_lock_post(\n    local_user_view.person.id,\n    &orig_post.post,\n    locked,\n    &data.reason,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action.clone(), &context);\n\n  ActivityChannel::submit_activity(\n    SendActivityData::LockPost(\n      post,\n      local_user_view.person.clone(),\n      data.locked,\n      data.reason.clone(),\n    ),\n    &context,\n  )?;\n\n  build_post_response(&context, orig_post.community.id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api/src/post/mark_many_read.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::post::PostActions;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::MarkManyPostsAsRead;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::{error::LemmyResult, utils::validation::check_api_elements_count};\n\npub async fn mark_posts_as_read(\n  Json(data): Json<MarkManyPostsAsRead>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let post_ids = &data.post_ids;\n  check_api_elements_count(post_ids.len())?;\n\n  let person_id = local_user_view.person.id;\n\n  // Mark the posts as read / unread\n  if data.read {\n    PostActions::mark_as_read(&mut context.pool(), person_id, post_ids).await?;\n  } else {\n    PostActions::mark_as_unread(&mut context.pool(), person_id, post_ids).await?;\n  }\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/mark_read.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::post::PostActions;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{MarkPostAsRead, PostResponse},\n};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn mark_post_as_read(\n  Json(data): Json<MarkPostAsRead>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let person_id = local_user_view.person.id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let post_id = data.post_id;\n\n  // Mark the post as read / unread\n  if data.read {\n    PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?;\n  } else {\n    PostActions::mark_as_unread(&mut context.pool(), person_id, &[post_id]).await?;\n  }\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  Ok(Json(PostResponse { post_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/mod.rs",
    "content": "pub mod feature;\npub mod get_link_metadata;\npub mod hide;\npub mod like;\npub mod list_post_likes;\npub mod lock;\npub mod mark_many_read;\npub mod mark_read;\npub mod mod_update;\npub mod save;\npub mod update_notifications;\npub mod warning;\n"
  },
  {
    "path": "crates/api/api/src/post/mod_update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_community_user_action,\n    check_is_mod_or_admin,\n    check_nsfw_allowed,\n    update_post_tags,\n  },\n};\nuse lemmy_db_schema::source::post::{Post, PostUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{ModEditPost, PostResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\nuse std::ops::Deref;\n\npub async fn mod_edit_post(\n  Json(data): Json<ModEditPost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  check_nsfw_allowed(data.nsfw, Some(&local_site))?;\n\n  let post_id = data.post_id;\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n  let community = orig_post.community;\n\n  check_community_user_action(&local_user_view, &community, &mut context.pool()).await?;\n  check_is_mod_or_admin(&mut context.pool(), local_user_view.person.id, community.id).await?;\n\n  let mut post_form = PostUpdateForm {\n    nsfw: data.nsfw,\n    updated_at: Some(Some(Utc::now())),\n    ..Default::default()\n  };\n  post_form = plugin_hook_before(\"local_post_before_vote\", post_form).await?;\n\n  let post_id = data.post_id;\n  let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?;\n  plugin_hook_after(\"local_post_after_vote\", &post_form);\n\n  if let Some(tags) = &data.tags {\n    update_post_tags(&updated_post, tags, &context).await?;\n  }\n\n  ActivityChannel::submit_activity(SendActivityData::UpdatePost(updated_post.clone()), &context)?;\n\n  build_post_response(context.deref(), community.id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api/src/post/save.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid};\nuse lemmy_db_schema::{\n  source::post::{PostActions, PostSavedForm},\n  traits::Saveable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{PostResponse, SavePost},\n};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn save_post(\n  Json(data): Json<SavePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let post_saved_form = PostSavedForm::new(data.post_id, local_user_view.person.id);\n\n  if data.save {\n    PostActions::save(&mut context.pool(), &post_saved_form).await?;\n  } else {\n    PostActions::unsave(&mut context.pool(), &post_saved_form).await?;\n  }\n\n  let post_id = data.post_id;\n  let person_id = local_user_view.person.id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?;\n\n  Ok(Json(PostResponse { post_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/update_notifications.rs",
    "content": "use crate::community::do_follow_community;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::{\n  community::Community,\n  post::{Post, PostActions},\n};\nuse lemmy_db_schema_file::enums::PostNotificationsMode;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::EditPostNotifications;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn edit_post_notifications(\n  Json(data): Json<EditPostNotifications>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  PostActions::update_notification_state(\n    data.post_id,\n    local_user_view.person.id,\n    data.mode,\n    &mut context.pool(),\n  )\n  .await?;\n  let post = Post::read(&mut context.pool(), data.post_id).await?;\n\n  // To get notifications for a remote community, the user needs to follow it over federation.\n  // Do this automatically here to avoid confusion.\n  if data.mode == PostNotificationsMode::AllComments {\n    let community = Community::read(&mut context.pool(), post.community_id).await?;\n    if !community.local {\n      do_follow_community(community, &local_user_view.person, true, &context).await?;\n    }\n  }\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/post/warning.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::source::modlog::{Modlog, ModlogInsertForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{CreatePostWarning, PostResponse},\n};\nuse lemmy_utils::error::LemmyResult;\n\n/// Creates a warning against a post and notifies the user\npub async fn create_post_warning(\n  Json(data): Json<CreatePostWarning>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let post_id = data.post_id;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  check_community_mod_action(\n    &local_user_view,\n    &orig_post.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::mod_create_post_warning(\n    local_user_view.person.id,\n    &orig_post.post,\n    &data.reason,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action, &context);\n\n  // TODO federate activity\n\n  build_post_response(&context, orig_post.community.id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/comment_report/create.rs",
    "content": "use crate::check_report_reason;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::plugin_hook_after,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_comment_deleted_or_removed,\n    check_community_user_action,\n    check_local_user_valid,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::comment_report::{CommentReport, CommentReportForm},\n  traits::Reportable,\n};\nuse lemmy_db_views_comment::CommentView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CommentReportResponse, CreateCommentReport},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_email::admin::send_new_report_email_to_admins;\nuse lemmy_utils::error::LemmyResult;\n\n/// Creates a comment report and notifies the moderators of the community\npub async fn create_comment_report(\n  Json(data): Json<CreateCommentReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentReportResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let reason = data.reason.trim().to_string();\n  let slur_regex = slur_regex(&context).await?;\n  check_report_reason(&reason, &slur_regex)?;\n\n  let person = &local_user_view.person;\n  let local_instance_id = local_user_view.person.instance_id;\n  let comment_id = data.comment_id;\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  check_community_user_action(\n    &local_user_view,\n    &comment_view.community,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Don't allow creating reports for removed / deleted comments\n  check_comment_deleted_or_removed(&comment_view.comment)?;\n\n  let report_form = CommentReportForm {\n    creator_id: person.id,\n    comment_id,\n    original_comment_text: comment_view.comment.content,\n    reason,\n    violates_instance_rules: data.violates_instance_rules.unwrap_or_default(),\n  };\n\n  let report = CommentReport::report(&mut context.pool(), &report_form).await?;\n\n  let comment_report_view =\n    ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report.id, person).await?;\n  plugin_hook_after(\"comment_report_after_create\", &comment_report_view);\n\n  // Email the admins\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  if local_site.reports_email_admins {\n    send_new_report_email_to_admins(\n      &comment_report_view.creator.name,\n      &comment_report_view.comment_creator.name,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n  }\n\n  if !report.violates_instance_rules {\n    ActivityChannel::submit_activity(\n      SendActivityData::CreateReport {\n        object_id: comment_view.comment.ap_id.inner().clone(),\n        actor: local_user_view.person,\n        receiver: Either::Right(comment_view.community),\n        reason: data.reason.clone(),\n      },\n      &context,\n    )?;\n  }\n\n  Ok(Json(CommentReportResponse {\n    comment_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/comment_report/mod.rs",
    "content": "pub mod create;\npub mod resolve;\n"
  },
  {
    "path": "crates/api/api/src/reports/comment_report/resolve.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::{source::comment_report::CommentReport, traits::Reportable};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CommentReportResponse, ResolveCommentReport},\n};\nuse lemmy_utils::error::LemmyResult;\n\n/// Resolves or unresolves a comment report and notifies the moderators of the community\npub async fn resolve_comment_report(\n  Json(data): Json<ResolveCommentReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentReportResponse>> {\n  let report_id = data.report_id;\n  let person = &local_user_view.person;\n  let report =\n    ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report_id, person).await?;\n\n  let person_id = local_user_view.person.id;\n  check_community_mod_action(\n    &local_user_view,\n    &report.community,\n    true,\n    &mut context.pool(),\n  )\n  .await?;\n\n  CommentReport::update_resolved(&mut context.pool(), report_id, person_id, data.resolved).await?;\n\n  let report_id = data.report_id;\n  let comment_report_view =\n    ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report_id, person).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::SendResolveReport {\n      object_id: comment_report_view.comment.ap_id.inner().clone(),\n      actor: local_user_view.person,\n      report_creator: report.creator,\n      receiver: Either::Right(comment_report_view.community.clone()),\n    },\n    &context,\n  )?;\n\n  Ok(Json(CommentReportResponse {\n    comment_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/community_report/create.rs",
    "content": "use crate::check_report_reason;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::plugin_hook_after,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_local_user_valid, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::{\n    community::Community,\n    community_report::{CommunityReport, CommunityReportForm},\n    site::Site,\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CommunityReportResponse, CreateCommunityReport},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_email::admin::send_new_report_email_to_admins;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn create_community_report(\n  Json(data): Json<CreateCommunityReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityReportResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let reason = data.reason.trim().to_string();\n  let slur_regex = slur_regex(&context).await?;\n  check_report_reason(&reason, &slur_regex)?;\n\n  let person = &local_user_view.person;\n  let community_id = data.community_id;\n  let community = Community::read(&mut context.pool(), community_id).await?;\n  let site = Site::read_from_instance_id(&mut context.pool(), community.instance_id).await?;\n\n  let report_form = CommunityReportForm {\n    creator_id: person.id,\n    community_id,\n    original_community_banner: community.banner,\n    original_community_summary: community.summary,\n    original_community_icon: community.icon,\n    original_community_name: community.name,\n    original_community_sidebar: community.sidebar,\n    original_community_title: community.title,\n    reason,\n  };\n\n  let report = CommunityReport::report(&mut context.pool(), &report_form).await?;\n\n  let community_report_view =\n    ReportCombinedViewInternal::read_community_report(&mut context.pool(), report.id, person)\n      .await?;\n  plugin_hook_after(\"community_report_after_create\", &community_report_view);\n\n  // Email the admins\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  if local_site.reports_email_admins {\n    send_new_report_email_to_admins(\n      &community_report_view.creator.name,\n      // The argument here is normally the reported content's creator, but a community doesn't have\n      // a single person to be considered the creator or the person responsible for the bad thing,\n      // so the community name is used instead\n      &community_report_view.community.name,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n  }\n\n  ActivityChannel::submit_activity(\n    SendActivityData::CreateReport {\n      object_id: community.ap_id.inner().clone(),\n      actor: local_user_view.person,\n      receiver: Either::Left(site),\n      reason: data.reason.clone(),\n    },\n    &context,\n  )?;\n\n  Ok(Json(CommunityReportResponse {\n    community_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/community_report/mod.rs",
    "content": "pub mod create;\npub mod resolve;\n"
  },
  {
    "path": "crates/api/api/src/reports/community_report/resolve.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::is_admin,\n};\nuse lemmy_db_schema::{\n  source::{community_report::CommunityReport, site::Site},\n  traits::Reportable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CommunityReportResponse, ResolveCommunityReport},\n};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn resolve_community_report(\n  Json(data): Json<ResolveCommunityReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityReportResponse>> {\n  is_admin(&local_user_view)?;\n\n  let report_id = data.report_id;\n  let person = &local_user_view.person;\n  CommunityReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved)\n    .await?;\n\n  let community_report_view =\n    ReportCombinedViewInternal::read_community_report(&mut context.pool(), report_id, person)\n      .await?;\n  let site = Site::read_from_instance_id(\n    &mut context.pool(),\n    community_report_view.community.instance_id,\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::SendResolveReport {\n      object_id: community_report_view.community.ap_id.inner().clone(),\n      actor: local_user_view.person,\n      report_creator: community_report_view.creator.clone(),\n      receiver: Either::Left(site),\n    },\n    &context,\n  )?;\n\n  Ok(Json(CommunityReportResponse {\n    community_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/mod.rs",
    "content": "pub mod comment_report;\npub mod community_report;\npub mod post_report;\npub mod private_message_report;\npub mod report_combined;\n"
  },
  {
    "path": "crates/api/api/src/reports/post_report/create.rs",
    "content": "use crate::check_report_reason;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::plugin_hook_after,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_community_user_action,\n    check_local_user_valid,\n    check_post_deleted_or_removed,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::post_report::{PostReport, PostReportForm},\n  traits::Reportable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CreatePostReport, PostReportResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_email::admin::send_new_report_email_to_admins;\nuse lemmy_utils::error::LemmyResult;\n\n/// Creates a post report and notifies the moderators of the community\npub async fn create_post_report(\n  Json(data): Json<CreatePostReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostReportResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let reason = data.reason.trim().to_string();\n  let slur_regex = slur_regex(&context).await?;\n  check_report_reason(&reason, &slur_regex)?;\n\n  let person = &local_user_view.person;\n  let post_id = data.post_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?;\n\n  check_post_deleted_or_removed(&orig_post.post)?;\n\n  let report_form = PostReportForm {\n    creator_id: person.id,\n    post_id,\n    original_post_name: orig_post.post.name,\n    original_post_url: orig_post.post.url,\n    original_post_body: orig_post.post.body,\n    reason,\n    violates_instance_rules: data.violates_instance_rules.unwrap_or_default(),\n  };\n\n  let report = PostReport::report(&mut context.pool(), &report_form).await?;\n\n  let post_report_view =\n    ReportCombinedViewInternal::read_post_report(&mut context.pool(), report.id, person).await?;\n  plugin_hook_after(\"post_report_after_create\", &post_report_view);\n\n  // Email the admins\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  if local_site.reports_email_admins {\n    send_new_report_email_to_admins(\n      &post_report_view.creator.name,\n      &post_report_view.post_creator.name,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n  }\n\n  if !report.violates_instance_rules {\n    ActivityChannel::submit_activity(\n      SendActivityData::CreateReport {\n        object_id: orig_post.post.ap_id.inner().clone(),\n        actor: local_user_view.person,\n        receiver: Either::Right(orig_post.community),\n        reason: data.reason.clone(),\n      },\n      &context,\n    )?;\n  }\n\n  Ok(Json(PostReportResponse { post_report_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/post_report/mod.rs",
    "content": "pub mod create;\npub mod resolve;\n"
  },
  {
    "path": "crates/api/api/src/reports/post_report/resolve.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::{source::post_report::PostReport, traits::Reportable};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{PostReportResponse, ResolvePostReport},\n};\nuse lemmy_utils::error::LemmyResult;\n\n/// Resolves or unresolves a post report and notifies the moderators of the community\npub async fn resolve_post_report(\n  Json(data): Json<ResolvePostReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostReportResponse>> {\n  let report_id = data.report_id;\n  let person = &local_user_view.person;\n  let report =\n    ReportCombinedViewInternal::read_post_report(&mut context.pool(), report_id, person).await?;\n\n  let person = &local_user_view.person;\n  check_community_mod_action(\n    &local_user_view,\n    &report.community,\n    true,\n    &mut context.pool(),\n  )\n  .await?;\n\n  PostReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved).await?;\n\n  let post_report_view =\n    ReportCombinedViewInternal::read_post_report(&mut context.pool(), report_id, person).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::SendResolveReport {\n      object_id: post_report_view.post.ap_id.inner().clone(),\n      actor: local_user_view.person,\n      report_creator: report.creator,\n      receiver: Either::Right(post_report_view.community.clone()),\n    },\n    &context,\n  )?;\n\n  Ok(Json(PostReportResponse { post_report_view }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/private_message_report/create.rs",
    "content": "use crate::check_report_reason;\nuse actix_web::web::{Data, Json};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::plugin_hook_after,\n  utils::{check_local_user_valid, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::{\n    private_message::PrivateMessage,\n    private_message_report::{PrivateMessageReport, PrivateMessageReportForm},\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{CreatePrivateMessageReport, PrivateMessageReportResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_email::admin::send_new_report_email_to_admins;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn create_pm_report(\n  Json(data): Json<CreatePrivateMessageReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PrivateMessageReportResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let reason = data.reason.trim().to_string();\n  let slur_regex = slur_regex(&context).await?;\n  check_report_reason(&reason, &slur_regex)?;\n\n  let person = &local_user_view.person;\n  let private_message_id = data.private_message_id;\n  let private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?;\n\n  // Make sure that only the recipient of the private message can create a report\n  if person.id != private_message.recipient_id {\n    return Err(LemmyErrorType::CouldntCreate.into());\n  }\n\n  let report_form = PrivateMessageReportForm {\n    creator_id: person.id,\n    private_message_id,\n    original_pm_text: private_message.content,\n    reason,\n  };\n\n  let report = PrivateMessageReport::report(&mut context.pool(), &report_form).await?;\n\n  let private_message_report_view =\n    ReportCombinedViewInternal::read_private_message_report(&mut context.pool(), report.id, person)\n      .await?;\n  plugin_hook_after(\n    \"private_message_report_after_create\",\n    &private_message_report_view,\n  );\n\n  // Email the admins\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  if local_site.reports_email_admins {\n    send_new_report_email_to_admins(\n      &private_message_report_view.creator.name,\n      &private_message_report_view.private_message_creator.name,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n  }\n\n  // TODO: consider federating this\n\n  Ok(Json(PrivateMessageReportResponse {\n    private_message_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/private_message_report/mod.rs",
    "content": "pub mod create;\npub mod resolve;\n"
  },
  {
    "path": "crates/api/api/src/reports/private_message_report/resolve.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::{source::private_message_report::PrivateMessageReport, traits::Reportable};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedViewInternal,\n  api::{PrivateMessageReportResponse, ResolvePrivateMessageReport},\n};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn resolve_pm_report(\n  Json(data): Json<ResolvePrivateMessageReport>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PrivateMessageReportResponse>> {\n  is_admin(&local_user_view)?;\n\n  let report_id = data.report_id;\n  let person = &local_user_view.person;\n  PrivateMessageReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved)\n    .await?;\n\n  let private_message_report_view =\n    ReportCombinedViewInternal::read_private_message_report(&mut context.pool(), report_id, person)\n      .await?;\n\n  Ok(Json(PrivateMessageReportResponse {\n    private_message_report_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/report_combined/list.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_community_mod_of_any_or_admin_action};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_report_combined::{\n  ReportCombinedView,\n  api::ListReports,\n  impls::ReportCombinedQuery,\n};\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\n/// Lists reports for a community if an id is supplied\n/// or returns all reports for communities a user moderates\npub async fn list_reports(\n  Query(data): Query<ListReports>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<ReportCombinedView>>> {\n  let my_reports_only = data.my_reports_only;\n\n  // Only check mod or admin status when not viewing my reports\n  if !my_reports_only.unwrap_or_default() {\n    check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;\n  }\n\n  let reports = ReportCombinedQuery {\n    community_id: data.community_id,\n    post_id: data.post_id,\n    type_: data.type_,\n    unresolved_only: data.unresolved_only,\n    show_community_rule_violations: data.show_community_rule_violations,\n    my_reports_only,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n  }\n  .list(&mut context.pool(), &local_user_view)\n  .await?;\n\n  Ok(Json(reports))\n}\n"
  },
  {
    "path": "crates/api/api/src/reports/report_combined/mod.rs",
    "content": "pub mod list;\n"
  },
  {
    "path": "crates/api/api/src/site/admin_allow_instance.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::{\n  federation_allowlist::{FederationAllowList, FederationAllowListForm},\n  instance::Instance,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{FederatedInstanceView, api::AdminAllowInstanceParams};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn admin_allow_instance(\n  Json(data): Json<AdminAllowInstanceParams>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<FederatedInstanceView>> {\n  is_admin(&local_user_view)?;\n\n  let blocklist = Instance::blocklist(&mut context.pool()).await?;\n  if !blocklist.is_empty() {\n    return Err(LemmyErrorType::CannotCombineFederationBlocklistAndAllowlist.into());\n  }\n\n  let instance_id = Instance::read_or_create(&mut context.pool(), &data.instance)\n    .await?\n    .id;\n  let form = FederationAllowListForm::new(instance_id);\n  if data.allow {\n    FederationAllowList::allow(&mut context.pool(), &form).await?;\n  } else {\n    FederationAllowList::unallow(&mut context.pool(), instance_id).await?;\n  }\n\n  let form = ModlogInsertForm::admin_allow_instance(\n    local_user_view.person.id,\n    instance_id,\n    data.allow,\n    &data.reason,\n  );\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  Ok(Json(\n    FederatedInstanceView::read(&mut context.pool(), instance_id).await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/admin_block_instance.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_expire_time, is_admin},\n};\nuse lemmy_db_schema::source::{\n  federation_blocklist::{FederationBlockList, FederationBlockListForm},\n  instance::Instance,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{FederatedInstanceView, api::AdminBlockInstanceParams};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn admin_block_instance(\n  Json(data): Json<AdminBlockInstanceParams>,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<FederatedInstanceView>> {\n  is_admin(&local_user_view)?;\n\n  let expires_at = check_expire_time(data.expires_at)?;\n\n  let allowlist = Instance::allowlist(&mut context.pool()).await?;\n  if !allowlist.is_empty() {\n    return Err(LemmyErrorType::CannotCombineFederationBlocklistAndAllowlist.into());\n  }\n\n  let instance_id = Instance::read_or_create(&mut context.pool(), &data.instance)\n    .await?\n    .id;\n\n  let form = FederationBlockListForm::new(instance_id, expires_at);\n\n  if data.block {\n    FederationBlockList::block(&mut context.pool(), &form).await?;\n  } else {\n    FederationBlockList::unblock(&mut context.pool(), instance_id).await?;\n  }\n\n  let form = ModlogInsertForm::admin_block_instance(\n    local_user_view.person.id,\n    instance_id,\n    data.block,\n    &data.reason,\n  );\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  Ok(Json(\n    FederatedInstanceView::read(&mut context.pool(), instance_id).await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/admin_list_users.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_views_local_user::{LocalUserView, api::AdminListUsers, impls::LocalUserQuery};\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn admin_list_users(\n  Query(data): Query<AdminListUsers>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<LocalUserView>>> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let users = LocalUserQuery {\n    banned_only: data.banned_only,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    sort: data.sort,\n  }\n  .list(&mut context.pool())\n  .await?;\n\n  Ok(Json(users))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/federated_instances.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_site::{FederatedInstanceView, api::GetFederatedInstances};\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_federated_instances(\n  Query(data): Query<GetFederatedInstances>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<PagedResponse<FederatedInstanceView>>> {\n  let federated_instances = FederatedInstanceView::list(&mut context.pool(), data).await?;\n\n  // Return the jwt\n  Ok(Json(federated_instances))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/list_all_media.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_views_local_image::{LocalImageView, api::ListMedia};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_all_media(\n  Query(data): Query<ListMedia>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<LocalImageView>>> {\n  // Only let admins view all media\n  is_admin(&local_user_view)?;\n\n  let images =\n    LocalImageView::get_all_paged(&mut context.pool(), data.page_cursor, data.limit).await?;\n\n  Ok(Json(images))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/mod.rs",
    "content": "pub mod admin_allow_instance;\npub mod admin_block_instance;\npub mod admin_list_users;\npub mod federated_instances;\npub mod list_all_media;\npub mod mod_log;\npub mod purge;\npub mod registration_applications;\n"
  },
  {
    "path": "crates/api/api/src/site/mod_log.rs",
    "content": "use crate::hide_modlog_names;\nuse actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_modlog::{ModlogView, api::GetModlog, impls::ModlogQuery};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_mod_log(\n  Query(data): Query<GetModlog>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<ModlogView>>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let hide_modlog_names =\n    hide_modlog_names(local_user_view.as_ref(), data.community_id, &context).await;\n  // Only allow mod person id filters if its not hidden\n  let mod_person_id = if hide_modlog_names {\n    None\n  } else {\n    data.mod_person_id\n  };\n\n  let modlog = ModlogQuery {\n    type_: data.type_,\n    listing_type: data.listing_type,\n    community_id: data.community_id,\n    mod_person_id,\n    target_person_id: data.other_person_id,\n    local_user: local_user_view.as_ref().map(|u| &u.local_user),\n    post_id: data.post_id,\n    comment_id: data.comment_id,\n    hide_modlog_names: Some(hide_modlog_names),\n    show_bulk: data.show_bulk,\n    bulk_action_parent_id: data.bulk_action_parent_id,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n  }\n  .list(&mut context.pool())\n  .await?;\n\n  Ok(Json(modlog))\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use lemmy_api_utils::utils::remove_or_restore_user_data;\n  use lemmy_db_schema::{\n    ModlogKindFilter,\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm},\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      modlog::{Modlog, ModlogInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    },\n    traits::Likeable,\n  };\n  use lemmy_db_schema_file::enums::ModlogKind;\n  use lemmy_db_views_comment::CommentView;\n  use lemmy_db_views_modlog::ModlogView;\n  use lemmy_db_views_post::PostView;\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyErrorType;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_mod_remove_or_restore_data() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    // John is the mod\n    let john = PersonInsertForm::test_form(instance.id, \"john the modder\");\n    let john = Person::create(pool, &john).await?;\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let sara_local_user_form = LocalUserInsertForm::test_form(sara.id);\n    let sara_local_user = LocalUser::create(pool, &sara_local_user_form, Vec::new()).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"mod_community crepes\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let post_form_1 = PostInsertForm::new(\"A test post tubular\".into(), sara.id, community.id);\n    let post_1 = Post::create(pool, &post_form_1).await?;\n\n    let post_like_form_1 = PostLikeForm::new(post_1.id, sara.id, Some(true));\n    PostActions::like(pool, &post_like_form_1).await?;\n\n    let post_form_2 = PostInsertForm::new(\"A test post radical\".into(), sara.id, community.id);\n    let post_2 = Post::create(pool, &post_form_2).await?;\n\n    let comment_form_1 =\n      CommentInsertForm::new(sara.id, post_1.id, \"A test comment tubular\".into());\n    let comment_1 = Comment::create(pool, &comment_form_1, None).await?;\n\n    let comment_like_form_1 = CommentLikeForm::new(comment_1.id, sara.id, Some(true));\n    CommentActions::like(pool, &comment_like_form_1).await?;\n\n    let comment_form_2 =\n      CommentInsertForm::new(sara.id, post_2.id, \"A test comment radical\".into());\n    Comment::create(pool, &comment_form_2, None).await?;\n\n    // Read saras post to make sure it has a like\n    let post_view_1 =\n      PostView::read(pool, post_1.id, Some(&sara_local_user), instance.id, false).await?;\n    assert_eq!(1, post_view_1.post.score);\n    assert_eq!(\n      Some(true),\n      post_view_1.post_actions.and_then(|pa| pa.vote_is_upvote)\n    );\n\n    // Read saras comment to make sure it has a like\n    let comment_view_1 =\n      CommentView::read(pool, comment_1.id, Some(&sara_local_user), instance.id).await?;\n    assert_eq!(1, comment_view_1.post.score);\n    assert_eq!(\n      Some(true),\n      comment_view_1\n        .comment_actions\n        .and_then(|ca| ca.vote_is_upvote)\n    );\n\n    // Remove the user data\n    let ban_form = ModlogInsertForm::admin_ban(&john, sara.id, true, None, \"a remove reason\");\n    let ban_action = Modlog::create(pool, &[ban_form]).await?;\n    let ban_id = ban_action.first().ok_or(LemmyErrorType::NotFound)?.id;\n    remove_or_restore_user_data(john.id, sara.id, true, \"a remove reason\", ban_id, &context)\n      .await?;\n\n    // Verify that their posts and comments are removed.\n    // Posts\n    let post_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, post_modlog.len());\n\n    assert!(matches!(\n      &post_modlog[..],\n      [\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: true, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: true, .. }),\n          ..\n        },\n      ],\n    ));\n\n    // Comments\n    let comment_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, comment_modlog.len());\n\n    assert!(matches!(\n      &comment_modlog[..],\n      [\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: true, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: true, .. }),\n          ..\n        },\n      ],\n    ));\n\n    // Verify that the likes got removed\n    // post\n    let post_view_1 =\n      PostView::read(pool, post_1.id, Some(&sara_local_user), instance.id, false).await?;\n    assert_eq!(0, post_view_1.post.score);\n    assert_eq!(\n      None,\n      post_view_1.post_actions.and_then(|pa| pa.vote_is_upvote)\n    );\n\n    // comment\n    let comment_view_1 =\n      CommentView::read(pool, comment_1.id, Some(&sara_local_user), instance.id).await?;\n    assert_eq!(0, comment_view_1.post.score);\n    assert_eq!(\n      None,\n      comment_view_1\n        .comment_actions\n        .and_then(|ca| ca.vote_is_upvote)\n    );\n\n    // Now restore the content, and make sure it got appended\n    let unban_form = ModlogInsertForm::admin_ban(&john, sara.id, false, None, \"a restore reason\");\n    let unban_action = Modlog::create(pool, &[unban_form]).await?;\n    let unban_id = unban_action.first().ok_or(LemmyErrorType::NotFound)?.id;\n    remove_or_restore_user_data(\n      john.id,\n      sara.id,\n      false,\n      \"a restore reason\",\n      unban_id,\n      &context,\n    )\n    .await?;\n\n    // Posts\n    let post_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(4, post_modlog.len());\n\n    assert!(matches!(\n      &post_modlog[..],\n      [\n        ModlogView {\n          modlog: Modlog {\n            is_revert: true,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: true,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemovePost,\n            ..\n          },\n          target_post: Some(Post { removed: false, .. }),\n          ..\n        },\n      ],\n    ));\n\n    // Comments\n    let comment_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(4, comment_modlog.len());\n\n    assert!(matches!(\n      &comment_modlog[..],\n      [\n        ModlogView {\n          modlog: Modlog {\n            is_revert: true,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: true,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: false, .. }),\n          ..\n        },\n        ModlogView {\n          modlog: Modlog {\n            is_revert: false,\n            kind: ModlogKind::ModRemoveComment,\n            ..\n          },\n          target_comment: Some(Comment { removed: false, .. }),\n          ..\n        },\n      ],\n    ));\n\n    Instance::delete(pool, instance.id).await?;\n\n    Ok(())\n  }\n\n  /// Verifies that remove_or_restore_user_data sets bulk_action_parent_id on all child entries\n  /// when a real parent ModlogId is provided\n  #[tokio::test]\n  #[serial]\n  async fn test_bulk_parent_id_propagated() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let person_a_form = PersonInsertForm::test_form(instance.id, \"person_a_bulk_test\");\n    let person_a = Person::create(pool, &person_a_form).await?;\n\n    let person_b_form = PersonInsertForm::test_form(instance.id, \"person_b_bulk_test\");\n    let person_b = Person::create(pool, &person_b_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"bulk_parent_community\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let post_form_1 = PostInsertForm::new(\"Bulk test post 1\".into(), person_b.id, community.id);\n    Post::create(pool, &post_form_1).await?;\n\n    let post_form_2 = PostInsertForm::new(\"Bulk test post 2\".into(), person_b.id, community.id);\n    let post_2 = Post::create(pool, &post_form_2).await?;\n\n    let comment_form = CommentInsertForm::new(person_b.id, post_2.id, \"Bulk test comment\".into());\n    Comment::create(pool, &comment_form, None).await?;\n\n    // Create the ban entry first and capture its ID as the expected parent\n    let ban_form =\n      ModlogInsertForm::admin_ban(&person_a, person_b.id, true, None, \"banning for bulk test\");\n    let ban_action = Modlog::create(pool, &[ban_form]).await?;\n    let ban_id = ban_action.first().ok_or(LemmyErrorType::NotFound)?.id;\n\n    // Remove person_b's content as a bulk action triggered by the ban\n    remove_or_restore_user_data(\n      person_a.id,\n      person_b.id,\n      true,\n      \"bulk remove reason\",\n      ban_id,\n      &context,\n    )\n    .await?;\n\n    let post_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, post_modlog.len());\n    assert!(\n      post_modlog\n        .iter()\n        .all(|e| e.modlog.bulk_action_parent_id == Some(ban_id)),\n      \"all post removal entries should reference the ban as their parent\"\n    );\n\n    let comment_modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(1, comment_modlog.len());\n    let first_comment = comment_modlog.first().ok_or(LemmyErrorType::NotFound)?;\n    assert_eq!(\n      Some(ban_id),\n      first_comment.modlog.bulk_action_parent_id,\n      \"comment removal entry should reference the ban as its parent\"\n    );\n\n    Instance::delete(pool, instance.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api/src/site/purge/comment.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::is_admin,\n};\nuse lemmy_db_schema::source::{\n  comment::Comment,\n  local_user::LocalUser,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_comment::{CommentView, api::PurgeComment};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn purge_comment(\n  Json(data): Json<PurgeComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  // Only let admin purge an item\n  is_admin(&local_user_view)?;\n\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n\n  // Read the comment to get the post_id and community\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  // Also check that you're a higher admin\n  LocalUser::is_higher_admin_check(\n    &mut context.pool(),\n    local_user_view.person.id,\n    vec![comment_view.creator.id],\n  )\n  .await?;\n\n  // TODO read comments for pictrs images and purge them\n\n  Comment::delete(&mut context.pool(), comment_id).await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::admin_purge_comment(\n    local_user_view.person.id,\n    &comment_view.comment,\n    comment_view.community.id,\n    &data.reason,\n  );\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemoveComment {\n      comment: comment_view.comment,\n      moderator: local_user_view.person.clone(),\n      community: comment_view.community,\n      reason: data.reason.clone(),\n      with_replies: false,\n    },\n    &context,\n  )?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/purge/community.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::is_admin,\n};\nuse lemmy_db_schema::source::{\n  community::Community,\n  local_user::LocalUser,\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_community::api::PurgeCommunity;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn purge_community(\n  Json(data): Json<PurgeCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  // Only let admin purge an item\n  is_admin(&local_user_view)?;\n\n  // Read the community to get its images\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n\n  // Also check that you're a higher admin than all the mods\n  let community_mod_person_ids =\n    CommunityModeratorView::for_community(&mut context.pool(), community.id)\n      .await?\n      .iter()\n      .map(|cmv| cmv.moderator.id)\n      .collect::<Vec<PersonId>>();\n\n  LocalUser::is_higher_admin_check(\n    &mut context.pool(),\n    local_user_view.person.id,\n    community_mod_person_ids,\n  )\n  .await?;\n\n  Community::delete(&mut context.pool(), data.community_id).await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::admin_purge_community(local_user_view.person.id, &data.reason);\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemoveCommunity {\n      moderator: local_user_view.person.clone(),\n      community,\n      reason: data.reason.clone(),\n      removed: true,\n    },\n    &context,\n  )?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/purge/mod.rs",
    "content": "pub mod comment;\npub mod community;\npub mod person;\npub mod post;\n"
  },
  {
    "path": "crates/api/api/src/site/purge/person.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{is_admin, purge_user_account},\n};\nuse lemmy_db_schema::{\n  source::{\n    instance::{InstanceActions, InstanceBanForm},\n    local_user::LocalUser,\n    modlog::{Modlog, ModlogInsertForm},\n    person::Person,\n  },\n  traits::Bannable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::api::PurgePerson;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn purge_person(\n  Json(data): Json<PurgePerson>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let local_instance_id = local_user_view.person.instance_id;\n\n  // Only let admin purge an item\n  is_admin(&local_user_view)?;\n\n  // Also check that you're a higher admin\n  LocalUser::is_higher_admin_check(\n    &mut context.pool(),\n    local_user_view.person.id,\n    vec![data.person_id],\n  )\n  .await?;\n\n  let person = Person::read(&mut context.pool(), data.person_id).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::BanFromSite {\n      moderator: local_user_view.person.clone(),\n      banned_user: person,\n      reason: data.reason.clone(),\n      remove_or_restore_data: Some(true),\n      ban: true,\n      expires_at: None,\n    },\n    &context,\n  )?;\n\n  // Clear profile data.\n  purge_user_account(data.person_id, local_instance_id, &context).await?;\n\n  // Keep person record, but mark as banned to prevent login or refetching from home instance.\n  InstanceActions::ban(\n    &mut context.pool(),\n    &InstanceBanForm::new(data.person_id, local_instance_id, None),\n  )\n  .await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::admin_purge_person(local_user_view.person.id, &data.reason);\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/purge/post.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{is_admin, purge_post_images},\n};\nuse lemmy_db_schema::source::{\n  local_user::LocalUser,\n  modlog::{Modlog, ModlogInsertForm},\n  post::Post,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::PurgePost;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn purge_post(\n  Json(data): Json<PurgePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  // Only let admin purge an item\n  is_admin(&local_user_view)?;\n\n  // Read the post to get the community_id\n  let post = Post::read(&mut context.pool(), data.post_id).await?;\n\n  // Also check that you're a higher admin\n  LocalUser::is_higher_admin_check(\n    &mut context.pool(),\n    local_user_view.person.id,\n    vec![post.creator_id],\n  )\n  .await?;\n\n  purge_post_images(post.url.clone(), post.thumbnail_url.clone(), &context).await;\n\n  Post::delete(&mut context.pool(), data.post_id).await?;\n\n  // Mod tables\n  let form =\n    ModlogInsertForm::admin_purge_post(local_user_view.person.id, post.community_id, &data.reason);\n  Modlog::create(&mut context.pool(), &[form]).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemovePost {\n      post,\n      moderator: local_user_view.person.clone(),\n      reason: data.reason.clone(),\n      removed: true,\n      with_replies: false,\n    },\n    &context,\n  )?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/registration_applications/approve.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse diesel_async::scoped_futures::ScopedFutureExt;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::{\n  local_user::{LocalUser, LocalUserUpdateForm},\n  registration_application::{RegistrationApplication, RegistrationApplicationUpdateForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_registration_applications::{\n  RegistrationApplicationView,\n  api::{ApproveRegistrationApplication, RegistrationApplicationResponse},\n};\nuse lemmy_diesel_utils::{connection::get_conn, traits::Crud, utils::diesel_string_update};\nuse lemmy_email::account::{send_application_approved_email, send_application_denied_email};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn approve_registration_application(\n  Json(data): Json<ApproveRegistrationApplication>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<RegistrationApplicationResponse>> {\n  let app_id = data.id;\n\n  // Only let admins do this\n  is_admin(&local_user_view)?;\n\n  let pool = &mut context.pool();\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  let approved_user_id = conn\n    .run_transaction(|conn| {\n      async move {\n        // Update the registration with reason, admin_id\n        let deny_reason = diesel_string_update(tx_data.deny_reason.as_deref());\n        let app_form = RegistrationApplicationUpdateForm {\n          admin_id: Some(Some(local_user_view.person.id)),\n          deny_reason,\n          updated_at: Some(Some(Utc::now())),\n        };\n\n        let registration_application =\n          RegistrationApplication::update(&mut conn.into(), app_id, &app_form).await?;\n\n        // Update the local_user row\n        let local_user_form = LocalUserUpdateForm {\n          accepted_application: Some(tx_data.approve),\n          ..Default::default()\n        };\n\n        let approved_user_id = registration_application.local_user_id;\n        LocalUser::update(&mut conn.into(), approved_user_id, &local_user_form).await?;\n\n        Ok(approved_user_id)\n      }\n      .scope_boxed()\n    })\n    .await?;\n\n  let approved_local_user_view = LocalUserView::read(&mut context.pool(), approved_user_id).await?;\n  if approved_local_user_view.local_user.email.is_some() {\n    // Email sending may fail, but this won't revert the application approval\n    if data.approve {\n      send_application_approved_email(&approved_local_user_view, context.settings())?;\n    } else {\n      send_application_denied_email(\n        &approved_local_user_view,\n        data.deny_reason.clone(),\n        context.settings(),\n      )?;\n    }\n  }\n\n  // Read the view\n  let registration_application =\n    RegistrationApplicationView::read(&mut context.pool(), app_id).await?;\n\n  Ok(Json(RegistrationApplicationResponse {\n    registration_application,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/registration_applications/get.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_registration_applications::{\n  RegistrationApplicationView,\n  api::{GetRegistrationApplication, RegistrationApplicationResponse},\n};\nuse lemmy_utils::error::LemmyResult;\n\n/// Lists registration applications, filterable by undenied only.\npub async fn get_registration_application(\n  Query(data): Query<GetRegistrationApplication>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<RegistrationApplicationResponse>> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  // Read the view\n  let registration_application =\n    RegistrationApplicationView::read_by_person(&mut context.pool(), data.person_id).await?;\n\n  Ok(Json(RegistrationApplicationResponse {\n    registration_application,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/registration_applications/list.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_registration_applications::{\n  RegistrationApplicationView,\n  api::ListRegistrationApplications,\n  impls::RegistrationApplicationQuery,\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\n/// Lists registration applications, filterable by undenied only.\npub async fn list_registration_applications(\n  Query(data): Query<ListRegistrationApplications>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PagedResponse<RegistrationApplicationView>>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let registration_applications = RegistrationApplicationQuery {\n    unread_only: data.unread_only,\n    verified_email_only: Some(local_site.require_email_verification),\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n  }\n  .list(&mut context.pool())\n  .await?;\n\n  Ok(Json(registration_applications))\n}\n"
  },
  {
    "path": "crates/api/api/src/site/registration_applications/mod.rs",
    "content": "pub mod approve;\npub mod get;\npub mod list;\n#[cfg(test)]\nmod tests;\n"
  },
  {
    "path": "crates/api/api/src/site/registration_applications/tests.rs",
    "content": "use crate::{\n  local_user::unread_counts::get_unread_counts,\n  site::registration_applications::{\n    approve::approve_registration_application,\n    list::list_registration_applications,\n  },\n};\nuse activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_crud::site::update::edit_site;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  source::{\n    local_site::{LocalSite, LocalSiteUpdateForm},\n    local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n    person::{Person, PersonInsertForm},\n    registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},\n  },\n  test_data::TestData,\n};\nuse lemmy_db_schema_file::{InstanceId, enums::RegistrationMode};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_registration_applications::{\n  RegistrationApplicationView,\n  api::ApproveRegistrationApplication,\n};\nuse lemmy_db_views_site::api::EditSite;\nuse lemmy_diesel_utils::{connection::DbPool, traits::Crud};\nuse lemmy_utils::{CACHE_DURATION_API, error::LemmyResult};\nuse serial_test::serial;\n\nasync fn create_test_site(context: &Data<LemmyContext>) -> LemmyResult<(TestData, LocalUserView)> {\n  let pool = &mut context.pool();\n  let data = TestData::create(pool).await?;\n\n  // Enable some local site settings\n  let local_site_form = LocalSiteUpdateForm {\n    require_email_verification: Some(true),\n    application_question: Some(Some(\".\".to_string())),\n    registration_mode: Some(RegistrationMode::RequireApplication),\n    site_setup: Some(true),\n    ..Default::default()\n  };\n  LocalSite::update(pool, &local_site_form).await?;\n\n  let admin_person = Person::create(\n    pool,\n    &PersonInsertForm::test_form(data.instance.id, \"admin\"),\n  )\n  .await?;\n  LocalUser::create(\n    pool,\n    &LocalUserInsertForm::test_form_admin(admin_person.id),\n    vec![],\n  )\n  .await?;\n\n  let admin_local_user_view = LocalUserView::read_person(pool, admin_person.id).await?;\n\n  Ok((data, admin_local_user_view))\n}\n\nasync fn signup(\n  pool: &mut DbPool<'_>,\n  instance_id: InstanceId,\n  name: &str,\n  email: Option<&str>,\n) -> LemmyResult<(LocalUser, RegistrationApplication)> {\n  let person_insert_form = PersonInsertForm::test_form(instance_id, name);\n  let person = Person::create(pool, &person_insert_form).await?;\n\n  let local_user_insert_form = match email {\n    Some(email) => LocalUserInsertForm {\n      email: Some(email.to_string()),\n      email_verified: Some(false),\n      ..LocalUserInsertForm::test_form(person.id)\n    },\n    None => LocalUserInsertForm::test_form(person.id),\n  };\n\n  let local_user = LocalUser::create(pool, &local_user_insert_form, vec![]).await?;\n\n  let application_insert_form = RegistrationApplicationInsertForm {\n    local_user_id: local_user.id,\n    answer: \"x\".to_string(),\n  };\n  let application = RegistrationApplication::create(pool, &application_insert_form).await?;\n\n  Ok((local_user, application))\n}\n\nasync fn get_application_statuses(\n  context: &Data<LemmyContext>,\n  admin: LocalUserView,\n) -> LemmyResult<(\n  i64,\n  Vec<RegistrationApplicationView>,\n  Vec<RegistrationApplicationView>,\n)> {\n  let Json(unread_counts) = get_unread_counts(context.clone(), admin.clone()).await?;\n\n  let Json(unread_applications) = list_registration_applications(\n    Query::from_query(\"unread_only=true\")?,\n    context.clone(),\n    admin.clone(),\n  )\n  .await?;\n\n  let Json(all_applications) = list_registration_applications(\n    Query::from_query(\"unread_only=false\")?,\n    context.clone(),\n    admin,\n  )\n  .await?;\n\n  Ok((\n    unread_counts\n      .registration_application_count\n      .unwrap_or_default(),\n    unread_applications.items,\n    all_applications.items,\n  ))\n}\n\n#[serial]\n#[tokio::test]\n#[expect(clippy::indexing_slicing)]\nasync fn test_application_approval() -> LemmyResult<()> {\n  let context = LemmyContext::init_test_context().await;\n  let pool = &mut context.pool();\n\n  let (data, admin_local_user_view) = create_test_site(&context).await?;\n\n  // Non-unread counts unfortunately are duplicated due to different types (i64 vs usize)\n  let mut expected_total_applications = 0;\n  let mut expected_unread_applications = 0u8;\n\n  let (local_user_with_email, app_with_email) = signup(\n    pool,\n    data.instance.id,\n    \"user_w_email\",\n    Some(\"lemmy@localhost\"),\n  )\n  .await?;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // When email verification is required and the email is not verified the application should not\n  // be visible to admins\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  LocalUser::update(\n    pool,\n    local_user_with_email.id,\n    &LocalUserUpdateForm {\n      email_verified: Some(true),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  expected_total_applications += 1;\n  expected_unread_applications += 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // When email verification is required and the email is verified the application should be\n  // visible to admins\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert!(\n    !unread_applications[0]\n      .creator_local_user\n      .accepted_application\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  approve_registration_application(\n    Json(ApproveRegistrationApplication {\n      id: app_with_email.id,\n      approve: true,\n      deny_reason: None,\n    }),\n    context.clone(),\n    admin_local_user_view.clone(),\n  )\n  .await?;\n\n  expected_unread_applications -= 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // When the application is approved it should only be returned for unread queries\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n  assert!(all_applications[0].creator_local_user.accepted_application);\n\n  let (_local_user, app_with_email_2) = signup(\n    pool,\n    data.instance.id,\n    \"user_w_email_2\",\n    Some(\"lemmy2@localhost\"),\n  )\n  .await?;\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // Email not verified, so application still not visible\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  Box::pin(edit_site(\n    Json(EditSite {\n      require_email_verification: Some(false),\n      ..Default::default()\n    }),\n    context.clone(),\n    admin_local_user_view.clone(),\n  ))\n  .await?;\n\n  // TODO: There is probably a better way to ensure cache invalidation\n  tokio::time::sleep(CACHE_DURATION_API).await;\n\n  expected_total_applications += 1;\n  expected_unread_applications += 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // After disabling email verification the application should now be visible\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  approve_registration_application(\n    Json(ApproveRegistrationApplication {\n      id: app_with_email_2.id,\n      approve: false,\n      deny_reason: None,\n    }),\n    context.clone(),\n    admin_local_user_view.clone(),\n  )\n  .await?;\n\n  expected_unread_applications -= 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // Denied applications should not be marked as unread\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  signup(pool, data.instance.id, \"user_wo_email\", None).await?;\n\n  expected_total_applications += 1;\n  expected_unread_applications += 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // New user without email should immediately be visible\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  signup(pool, data.instance.id, \"user_w_email_3\", None).await?;\n\n  expected_total_applications += 1;\n  expected_unread_applications += 1;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // New user with email should immediately be visible\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  Box::pin(edit_site(\n    Json(EditSite {\n      registration_mode: Some(RegistrationMode::Open),\n      ..Default::default()\n    }),\n    context.clone(),\n    admin_local_user_view.clone(),\n  ))\n  .await?;\n\n  // TODO: There is probably a better way to ensure cache invalidation\n  tokio::time::sleep(CACHE_DURATION_API).await;\n\n  let (application_count, unread_applications, all_applications) =\n    get_application_statuses(&context, admin_local_user_view.clone()).await?;\n\n  // TODO: At this time applications do not get approved when switching to open registration, so the\n  //       numbers will not change. See https://github.com/LemmyNet/lemmy/issues/4969\n  // expected_application_count = 0;\n  // expected_unread_applications_len = 0;\n\n  // When applications are not required all previous applications should become approved but still\n  // visible\n  assert_eq!(application_count, i64::from(expected_unread_applications),);\n  assert_eq!(\n    unread_applications.len(),\n    usize::from(expected_unread_applications),\n  );\n  assert_eq!(all_applications.len(), expected_total_applications,);\n\n  LocalSite::delete(pool).await?;\n  // Instance deletion cascades cleanup of all created persons\n  data.delete(pool).await?;\n\n  Ok(())\n}\n"
  },
  {
    "path": "crates/api/api/src/sitemap.rs",
    "content": "use actix_web::{\n  HttpResponse,\n  http::header::{self, CacheDirective},\n  web::Data,\n};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_schema::source::post::Post;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse lemmy_utils::error::LemmyResult;\nuse sitemap_rs::{url::Url, url_set::UrlSet};\nuse tracing::info;\n\nfn generate_urlset(posts: Vec<(DbUrl, chrono::DateTime<chrono::Utc>)>) -> LemmyResult<UrlSet> {\n  let urls = posts\n    .into_iter()\n    .map_while(|(url, date_time)| {\n      Url::builder(url.to_string())\n        .last_modified(date_time.into())\n        .build()\n        .ok()\n    })\n    .collect();\n\n  Ok(UrlSet::new(urls)?)\n}\n\npub async fn get_sitemap(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  check_private_instance(&None, &local_site)?;\n\n  info!(\"Generating sitemap...\",);\n  let posts = Post::list_for_sitemap(&mut context.pool()).await?;\n  info!(\"Loaded latest {} posts\", posts.len());\n\n  let mut buf = Vec::<u8>::new();\n  generate_urlset(posts)?.write(&mut buf)?;\n\n  Ok(\n    HttpResponse::Ok()\n      .content_type(\"application/xml\")\n      .insert_header(header::CacheControl(vec![CacheDirective::MaxAge(3_600)])) // 1 h\n      .body(buf),\n  )\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n\n  use crate::sitemap::generate_urlset;\n  use chrono::{DateTime, NaiveDate, Utc};\n  use elementtree::Element;\n  use lemmy_diesel_utils::dburl::DbUrl;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use url::Url;\n\n  #[tokio::test]\n  async fn test_generate_urlset() -> LemmyResult<()> {\n    let posts: Vec<(DbUrl, DateTime<Utc>)> = vec![\n      (\n        Url::parse(\"https://example.com\")?.into(),\n        NaiveDate::from_ymd_opt(2022, 12, 1)\n          .unwrap_or_default()\n          .and_hms_opt(9, 10, 11)\n          .unwrap_or_default()\n          .and_utc(),\n      ),\n      (\n        Url::parse(\"https://lemmy.ml\")?.into(),\n        NaiveDate::from_ymd_opt(2023, 1, 1)\n          .unwrap_or_default()\n          .and_hms_opt(1, 2, 3)\n          .unwrap_or_default()\n          .and_utc(),\n      ),\n    ];\n\n    let mut buf = Vec::<u8>::new();\n    generate_urlset(posts)?.write(&mut buf)?;\n    let root = Element::from_reader(buf.as_slice())?;\n\n    assert_eq!(root.tag().name(), \"urlset\");\n    assert_eq!(root.child_count(), 2);\n\n    assert!(root.children().all(|url| url.tag().name() == \"url\"));\n    assert!(root.children().all(|url| url.child_count() == 2));\n    assert!(root.children().all(|url| {\n      url\n        .children()\n        .next()\n        .is_some_and(|element| element.tag().name() == \"loc\")\n    }));\n    assert!(root.children().all(|url| {\n      url\n        .children()\n        .nth(1)\n        .is_some_and(|element| element.tag().name() == \"lastmod\")\n    }));\n\n    assert_eq!(\n      root\n        .children()\n        .next()\n        .and_then(|n| n.children().find(|element| element.tag().name() == \"loc\"))\n        .map(Element::text)\n        .unwrap_or_default(),\n      \"https://example.com/\"\n    );\n    assert_eq!(\n      root\n        .children()\n        .next()\n        .and_then(|n| n\n          .children()\n          .find(|element| element.tag().name() == \"lastmod\"))\n        .map(Element::text)\n        .unwrap_or_default(),\n      \"2022-12-01T09:10:11+00:00\"\n    );\n    assert_eq!(\n      root\n        .children()\n        .nth(1)\n        .and_then(|n| n.children().find(|element| element.tag().name() == \"loc\"))\n        .map(Element::text)\n        .unwrap_or_default(),\n      \"https://lemmy.ml/\"\n    );\n    assert_eq!(\n      root\n        .children()\n        .nth(1)\n        .and_then(|n| n\n          .children()\n          .find(|element| element.tag().name() == \"lastmod\"))\n        .map(Element::text)\n        .unwrap_or_default(),\n      \"2023-01-01T01:02:03+00:00\"\n    );\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api_common/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api_common\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_api_common\"\npath = \"src/lib.rs\"\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = []\nts-rs = [\n  \"lemmy_utils/ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_schema_file/ts-rs\",\n  \"lemmy_db_views_comment/ts-rs\",\n  \"lemmy_db_views_community/ts-rs\",\n  \"lemmy_db_views_community_follower/ts-rs\",\n  \"lemmy_db_views_community_follower_approval/ts-rs\",\n  \"lemmy_db_views_community_moderator/ts-rs\",\n  \"lemmy_db_views_custom_emoji/ts-rs\",\n  \"lemmy_db_views_notification/ts-rs\",\n  \"lemmy_db_views_local_image/ts-rs\",\n  \"lemmy_db_views_local_user/ts-rs\",\n  \"lemmy_db_views_modlog/ts-rs\",\n  \"lemmy_db_views_person/ts-rs\",\n  \"lemmy_db_views_person_content_combined/ts-rs\",\n  \"lemmy_db_views_person_liked_combined/ts-rs\",\n  \"lemmy_db_views_person_saved_combined/ts-rs\",\n  \"lemmy_db_views_post/ts-rs\",\n  \"lemmy_db_views_private_message/ts-rs\",\n  \"lemmy_db_views_registration_applications/ts-rs\",\n  \"lemmy_db_views_report_combined/ts-rs\",\n  \"lemmy_db_views_search_combined/ts-rs\",\n  \"lemmy_db_views_site/ts-rs\",\n  \"lemmy_db_views_vote/ts-rs\",\n]\n\n[dependencies]\nlemmy_utils.workspace = true\nlemmy_db_schema.workspace = true\nlemmy_db_schema_file.workspace = true\nlemmy_db_views_comment.workspace = true\nlemmy_db_views_community.workspace = true\nlemmy_db_views_community_follower.workspace = true\nlemmy_db_views_community_follower_approval.workspace = true\nlemmy_db_views_community_moderator.workspace = true\nlemmy_db_views_custom_emoji.workspace = true\nlemmy_db_views_notification.workspace = true\nlemmy_db_views_local_image.workspace = true\nlemmy_db_views_local_user.workspace = true\nlemmy_db_views_modlog.workspace = true\nlemmy_db_views_person.workspace = true\nlemmy_db_views_person_content_combined.workspace = true\nlemmy_db_views_person_liked_combined.workspace = true\nlemmy_db_views_person_saved_combined.workspace = true\nlemmy_db_views_post_comment_combined.workspace = true\nlemmy_db_views_post.workspace = true\nlemmy_db_views_private_message.workspace = true\nlemmy_db_views_registration_applications.workspace = true\nlemmy_db_views_report_combined.workspace = true\nlemmy_db_views_search_combined.workspace = true\nlemmy_db_views_site.workspace = true\nlemmy_db_views_vote.workspace = true\nlemmy_diesel_utils.workspace = true\n"
  },
  {
    "path": "crates/api/api_common/README.md",
    "content": "# lemmy_api_common\n\nThis crate provides all the data types which are necessary to build a client for [Lemmy](https://join-lemmy.org/). You can use them with the HTTP client of your choice.\n\nHere is an example using [reqwest](https://crates.io/crates/reqwest):\n\n```rust\n    let params = GetPosts {\n        community_name: Some(\"asklemmy\".to_string()),\n        ..Default::default()\n    };\n    let client = Client::new();\n    let response = client\n        .get(\"https://lemmy.ml/api/v4/post/list\")\n        .query(&params)\n        .send()\n        .await?;\n    let json = response.json::<GetPostsResponse>().await.unwrap();\n    print!(\"{:?}\", &json);\n```\n\nAs you can see, each API endpoint needs a parameter type ( GetPosts), path (/post/list) and response type (GetPostsResponse). You can find the paths and handler methods from [this file](https://github.com/LemmyNet/lemmy/blob/main/src/api_routes_http.rs). The parameter type and response type are defined on each handler method.\n\nFor a real example of a Lemmy API client, look at [lemmyBB](https://github.com/LemmyNet/lemmyBB/tree/main/src/api).\n\nLemmy also provides a websocket API. You can find the full websocket code in [this file](https://github.com/LemmyNet/lemmy/blob/main/src/api_routes_websocket.rs).\n\n## Generate TypeScript bindings\n\nTypeScript bindings (API types) can be generated by running `cargo test --features full`.\nThe ts files be generated into a `bindings` folder.\n\nThis crate uses [`ts_rs`](https://docs.rs/ts-rs/6.2.1/ts_rs/#traits) macros `derive(TS)` and `ts(export)` to attribute types for binding generating.\n"
  },
  {
    "path": "crates/api/api_common/src/account.rs",
    "content": "pub use lemmy_db_views_person_content_combined::api::{ListPersonHidden, ListPersonRead};\npub use lemmy_db_views_person_liked_combined::ListPersonLiked;\npub use lemmy_db_views_person_saved_combined::ListPersonSaved;\npub use lemmy_db_views_post_comment_combined::PostCommentCombinedView;\npub use lemmy_db_views_site::api::{DeleteAccount, MyUserInfo, SaveUserSettings};\npub mod auth {\n  pub use lemmy_db_schema::source::login_token::LoginToken;\n  pub use lemmy_db_views_registration_applications::api::{CaptchaAnswer, Register};\n  pub use lemmy_db_views_site::api::{\n    CaptchaResponse,\n    ChangePassword,\n    EditTotp,\n    EditTotpResponse,\n    ExportDataResponse,\n    GenerateTotpSecretResponse,\n    GetCaptchaResponse,\n    ListLoginsResponse,\n    Login,\n    LoginResponse,\n    PasswordChangeAfterReset,\n    PasswordReset,\n    ResendVerificationEmail,\n    UserSettingsBackup,\n    VerifyEmail,\n  };\n}\n"
  },
  {
    "path": "crates/api/api_common/src/comment.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::CommentId,\n  source::comment::{Comment, CommentActions, CommentInsertForm},\n};\npub use lemmy_db_views_comment::{\n  CommentSlimView,\n  CommentView,\n  api::{CommentResponse, GetComment, GetComments},\n};\n\npub mod actions {\n  pub use lemmy_db_views_comment::api::{\n    CreateComment,\n    CreateCommentLike,\n    DeleteComment,\n    EditComment,\n    SaveComment,\n  };\n\n  pub mod moderation {\n    pub use lemmy_db_views_comment::api::{\n      DistinguishComment,\n      ListCommentLikes,\n      PurgeComment,\n      RemoveComment,\n    };\n  }\n}\n"
  },
  {
    "path": "crates/api/api_common/src/community.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::{CommunityId, CommunityTagId, MultiCommunityId},\n  source::{\n    community::{Community, CommunityActions},\n    community_tag::{CommunityTag, CommunityTagsView},\n    multi_community::{MultiCommunity, MultiCommunityFollow},\n  },\n};\npub use lemmy_db_schema_file::enums::CommunityVisibility;\npub use lemmy_db_views_community::{\n  CommunityView,\n  MultiCommunityView,\n  api::{\n    CommunityResponse,\n    CreateMultiCommunity,\n    CreateOrDeleteMultiCommunityEntry,\n    EditCommunityNotifications,\n    EditMultiCommunity,\n    FollowMultiCommunity,\n    GetCommunity,\n    GetCommunityResponse,\n    GetMultiCommunity,\n    GetMultiCommunityResponse,\n    GetRandomCommunity,\n    ListCommunities,\n    ListMultiCommunities,\n  },\n};\npub use lemmy_db_views_community_follower_approval::PendingFollowerView;\npub use lemmy_db_views_community_moderator::CommunityModeratorView;\n\npub mod actions {\n  pub use lemmy_db_views_community::api::{\n    BlockCommunity,\n    CreateCommunity,\n    FollowCommunity,\n    HideCommunity,\n  };\n\n  pub mod moderation {\n    pub use lemmy_db_schema_file::enums::CommunityFollowerState;\n    pub use lemmy_db_views_community::api::{\n      AddModToCommunity,\n      AddModToCommunityResponse,\n      ApproveCommunityPendingFollower,\n      BanFromCommunity,\n      CommunityIdQuery,\n      CreateCommunityTag,\n      DeleteCommunity,\n      DeleteCommunityTag,\n      EditCommunity,\n      EditCommunityTag,\n      PurgeCommunity,\n      RemoveCommunity,\n      TransferCommunity,\n    };\n    pub use lemmy_db_views_community_follower::CommunityFollowerView;\n    pub use lemmy_db_views_community_follower_approval::{\n      PendingFollowerView,\n      api::ListCommunityPendingFollows,\n    };\n  }\n}\n"
  },
  {
    "path": "crates/api/api_common/src/custom_emoji.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::CustomEmojiId,\n  source::{custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword},\n};\npub use lemmy_db_views_custom_emoji::{\n  CustomEmojiView,\n  api::{\n    CreateCustomEmoji,\n    CustomEmojiResponse,\n    DeleteCustomEmoji,\n    EditCustomEmoji,\n    ListCustomEmojis,\n    ListCustomEmojisResponse,\n  },\n};\n"
  },
  {
    "path": "crates/api/api_common/src/error.rs",
    "content": "pub use lemmy_utils::error::{LemmyErrorType, UntranslatedError};\n"
  },
  {
    "path": "crates/api/api_common/src/federation.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::ActivityId,\n  source::{\n    federation_allowlist::FederationAllowList,\n    federation_blocklist::FederationBlockList,\n    federation_queue_state::FederationQueueState,\n    instance::{Instance, InstanceActions},\n  },\n};\npub use lemmy_db_schema_file::{InstanceId, enums::FederationMode};\npub use lemmy_db_views_site::api::{\n  GetFederatedInstances,\n  GetFederatedInstancesKind,\n  ResolveObject,\n  UserBlockInstanceCommunitiesParams,\n  UserBlockInstancePersonsParams,\n};\n\npub mod administration {\n  pub use lemmy_db_views_site::api::{AdminAllowInstanceParams, AdminBlockInstanceParams};\n}\n"
  },
  {
    "path": "crates/api/api_common/src/language.rs",
    "content": "pub use lemmy_db_schema::{newtypes::LanguageId, source::language::Language};\n"
  },
  {
    "path": "crates/api/api_common/src/lib.rs",
    "content": "pub mod account;\npub mod comment;\npub mod community;\npub mod custom_emoji;\npub mod error;\npub mod federation;\npub mod language;\npub mod media;\npub mod modlog;\npub mod notification;\npub mod oauth;\npub mod person;\npub mod plugin;\npub mod post;\npub mod private_message;\npub mod report;\npub mod search;\npub mod site;\npub mod tagline;\n\npub use lemmy_db_schema_file::enums::VoteShow;\npub use lemmy_db_views_site::api::SuccessResponse;\npub use lemmy_db_views_vote::VoteView;\npub use lemmy_diesel_utils::{\n  dburl::DbUrl,\n  pagination::{PagedResponse, PaginationCursor},\n  sensitive::SensitiveString,\n};\n"
  },
  {
    "path": "crates/api/api_common/src/media.rs",
    "content": "pub use lemmy_db_schema::source::images::{ImageDetails, LocalImage, RemoteImage};\npub use lemmy_db_views_local_image::{\n  LocalImageView,\n  api::{DeleteImageParams, ImageGetParams, ImageProxyParams, ListMedia, UploadImageResponse},\n};\n"
  },
  {
    "path": "crates/api/api_common/src/modlog.rs",
    "content": "pub use lemmy_db_schema::{newtypes::ModlogId, source::modlog::Modlog};\npub use lemmy_db_views_modlog::api::GetModlog;\n"
  },
  {
    "path": "crates/api/api_common/src/notification.rs",
    "content": "pub use lemmy_db_schema::{\n  NotificationTypeFilter,\n  newtypes::NotificationId,\n  source::notification::Notification,\n};\npub use lemmy_db_views_notification::{\n  ListNotifications,\n  NotificationView,\n  api::MarkNotificationAsRead,\n};\n"
  },
  {
    "path": "crates/api/api_common/src/oauth.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::OAuthProviderId,\n  source::{\n    oauth_account::OAuthAccount,\n    oauth_provider::{AdminOAuthProvider, PublicOAuthProvider},\n  },\n};\npub use lemmy_db_views_site::api::{\n  AuthenticateWithOauth,\n  CreateOAuthProvider,\n  DeleteOAuthProvider,\n  EditOAuthProvider,\n};\n"
  },
  {
    "path": "crates/api/api_common/src/person.rs",
    "content": "pub use lemmy_db_schema::{\n  PersonContentType,\n  newtypes::LocalUserId,\n  source::{\n    local_user::LocalUser,\n    person::{Person, PersonActions},\n  },\n};\npub use lemmy_db_schema_file::PersonId;\npub use lemmy_db_views_local_user::LocalUserView;\npub use lemmy_db_views_person::{\n  PersonView,\n  api::{GetPersonDetails, GetPersonDetailsResponse, PersonResponse},\n};\n\npub mod actions {\n  pub use lemmy_db_schema::newtypes::PersonContentCombinedId;\n  pub use lemmy_db_views_person::api::{BlockPerson, NotePerson};\n  pub use lemmy_db_views_person_content_combined::ListPersonContent;\n\n  pub mod moderation {\n    pub use lemmy_db_schema::{\n      newtypes::RegistrationApplicationId,\n      source::registration_application::RegistrationApplication,\n    };\n    pub use lemmy_db_views_person::api::{BanPerson, PurgePerson};\n    pub use lemmy_db_views_registration_applications::{\n      RegistrationApplicationView,\n      api::{GetRegistrationApplication, RegistrationApplicationResponse},\n    };\n  }\n}\n"
  },
  {
    "path": "crates/api/api_common/src/plugin.rs",
    "content": "pub use lemmy_db_views_site::api::PluginMetadata;\n"
  },
  {
    "path": "crates/api/api_common/src/post.rs",
    "content": "pub use lemmy_db_schema::{\n  PostFeatureType,\n  newtypes::PostId,\n  source::post::{Post, PostActions, PostInsertForm, PostLikeForm},\n};\npub use lemmy_db_schema_file::enums::{PostListingMode, PostNotificationsMode};\npub use lemmy_db_views_post::{\n  PostView,\n  api::{\n    GetPosts,\n    GetSiteMetadata,\n    GetSiteMetadataResponse,\n    LinkMetadata,\n    OpenGraphData,\n    PostResponse,\n  },\n};\npub use lemmy_db_views_search_combined::api::{GetPost, GetPostResponse};\npub mod actions {\n  pub use lemmy_db_views_post::api::{\n    CreatePost,\n    CreatePostLike,\n    DeletePost,\n    EditPost,\n    EditPostNotifications,\n    HidePost,\n    MarkManyPostsAsRead,\n    MarkPostAsRead,\n    SavePost,\n  };\n\n  pub mod moderation {\n    pub use lemmy_db_views_post::api::{\n      FeaturePost,\n      ListPostLikes,\n      LockPost,\n      ModEditPost,\n      PurgePost,\n      RemovePost,\n    };\n  }\n}\n"
  },
  {
    "path": "crates/api/api_common/src/private_message.rs",
    "content": "pub use lemmy_db_schema::{newtypes::PrivateMessageId, source::private_message::PrivateMessage};\npub use lemmy_db_views_private_message::{PrivateMessageView, api::PrivateMessageResponse};\n\npub mod actions {\n  pub use lemmy_db_views_private_message::api::{\n    CreatePrivateMessage,\n    DeletePrivateMessage,\n    EditPrivateMessage,\n  };\n}\n"
  },
  {
    "path": "crates/api/api_common/src/report.rs",
    "content": "pub use lemmy_db_schema::{\n  ReportType,\n  newtypes::{CommentReportId, CommunityReportId, PostReportId, PrivateMessageReportId},\n  source::{\n    comment_report::CommentReport,\n    community_report::CommunityReport,\n    post_report::PostReport,\n    private_message_report::PrivateMessageReport,\n  },\n};\npub use lemmy_db_views_report_combined::{\n  CommentReportView,\n  CommunityReportView,\n  PostReportView,\n  PrivateMessageReportView,\n  ReportCombinedView,\n  api::{\n    CommentReportResponse,\n    CommunityReportResponse,\n    CreateCommentReport,\n    CreateCommunityReport,\n    CreatePostReport,\n    CreatePrivateMessageReport,\n    ListReports,\n    PostReportResponse,\n    PrivateMessageReportResponse,\n    ResolveCommentReport,\n    ResolveCommunityReport,\n    ResolvePostReport,\n    ResolvePrivateMessageReport,\n  },\n};\n"
  },
  {
    "path": "crates/api/api_common/src/search.rs",
    "content": "pub use lemmy_db_schema::{\n  CommunitySortType,\n  LikeType,\n  PersonContentType,\n  SearchSortType,\n  SearchType,\n  newtypes::SearchCombinedId,\n  source::combined::search::SearchCombined,\n};\npub use lemmy_db_schema_file::enums::{CommentSortType, ListingType, PostSortType};\npub use lemmy_db_views_search_combined::{Search, SearchCombinedView, SearchResponse};\n"
  },
  {
    "path": "crates/api/api_common/src/site.rs",
    "content": "pub use lemmy_db_schema::{\n  newtypes::{LocalSiteId, SiteId},\n  source::{\n    local_site::LocalSite,\n    local_site_rate_limit::LocalSiteRateLimit,\n    local_site_url_blocklist::LocalSiteUrlBlocklist,\n    site::Site,\n  },\n};\npub use lemmy_db_schema_file::enums::RegistrationMode;\npub use lemmy_db_views_site::{\n  SiteView,\n  api::{GetSiteResponse, PostOrCommentOrPrivateMessage, SiteResponse, UnreadCountsResponse},\n};\n\npub mod administration {\n  pub use lemmy_db_views_local_user::api::AdminListUsers;\n  pub use lemmy_db_views_person::api::{AddAdmin, AddAdminResponse};\n  pub use lemmy_db_views_registration_applications::api::{\n    ApproveRegistrationApplication,\n    ListRegistrationApplications,\n  };\n  pub use lemmy_db_views_site::api::{CreateSite, EditSite};\n}\n"
  },
  {
    "path": "crates/api/api_common/src/tagline.rs",
    "content": "pub use lemmy_db_schema::{newtypes::TaglineId, source::tagline::Tagline};\npub use lemmy_db_views_site::api::{ListTaglines, TaglineResponse};\n\npub mod administration {\n  pub use lemmy_db_views_site::api::{CreateTagline, DeleteTagline, EditTagline};\n}\n"
  },
  {
    "path": "crates/api/api_crud/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api_crud\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lints]\nworkspace = true\n\n[features]\nfull = []\n\n[dependencies]\nlemmy_db_views_comment = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_moderator = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_follower = { workspace = true, features = [\"full\"] }\nlemmy_db_views_post = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person = { workspace = true, features = [\"full\"] }\nlemmy_db_views_custom_emoji = { workspace = true, features = [\"full\"] }\nlemmy_db_views_private_message = { workspace = true, features = [\"full\"] }\nlemmy_db_views_registration_applications = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_db_views_search_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema_file = { workspace = true }\nlemmy_apub_objects = { workspace = true }\nlemmy_email = { workspace = true }\nactivitypub_federation = { workspace = true }\nbcrypt = { workspace = true }\nactix-web = { workspace = true }\nurl = { workspace = true }\ntracing = { workspace = true }\nfutures = { workspace = true }\nfutures-util = { workspace = true }\nanyhow.workspace = true\nchrono.workspace = true\naccept-language = \"3.1.0\"\nregex = { workspace = true }\nserde_json = { workspace = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\ndiesel-async = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\n\n[package.metadata.cargo-shear]\nignored = [\"futures\", \"futures-util\"]\n\n[dev-dependencies]\n\n[build-dependencies]\nserde = { workspace = true }\nserde_json = { workspace = true }\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/create.rs",
    "content": "use crate::community_use_pending;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  notify::NotifyData,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_comment_depth,\n    check_community_user_action,\n    check_post_deleted_or_removed,\n    get_url_blocklist,\n    is_mod_or_admin,\n    process_markdown,\n    slur_regex,\n    update_read_comments,\n  },\n};\nuse lemmy_db_schema::{\n  impls::actor_language::validate_post_language,\n  source::{\n    comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm},\n    notification::Notification,\n  },\n  traits::Likeable,\n};\nuse lemmy_db_views_comment::api::{CommentResponse, CreateComment};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::is_valid_body_field,\n};\n\npub async fn create_comment(\n  Json(data): Json<CreateComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let content = process_markdown(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  is_valid_body_field(&content, false)?;\n\n  // Check for a community ban\n  let post_id = data.post_id;\n  let my_person_id = local_user_view.person.id;\n\n  let local_instance_id = local_user_view.person.instance_id;\n\n  // Read the full post view in order to get the comments count.\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    true,\n  )\n  .await?;\n\n  let post = post_view.post;\n  let community_id = post_view.community.id;\n\n  check_community_user_action(&local_user_view, &post_view.community, &mut context.pool()).await?;\n  check_post_deleted_or_removed(&post)?;\n\n  // Fetch the parent, if it exists\n  let parent_opt = if let Some(parent_id) = data.parent_id {\n    Comment::read(&mut context.pool(), parent_id).await.ok()\n  } else {\n    None\n  };\n\n  // Check if post or parent is locked, no new comments\n  let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id)\n    .await\n    .is_ok();\n  // We only need to check the parent comment here as when we lock a\n  // comment we also lock all of its children.\n  let locked = post.locked || parent_opt.as_ref().is_some_and(|p| p.locked);\n  if locked && !is_mod_or_admin {\n    return Err(LemmyErrorType::Locked.into());\n  }\n\n  // If there's a parent_id, check to make sure that comment is in that post\n  // Strange issue where sometimes the post ID of the parent comment is incorrect\n  if let Some(parent) = parent_opt.as_ref() {\n    if parent.post_id != post_id {\n      return Err(LemmyErrorType::CouldntCreate.into());\n    }\n    check_comment_depth(parent)?;\n  }\n\n  let mut comment_form = CommentInsertForm {\n    language_id: data.language_id,\n    federation_pending: Some(community_use_pending(&post_view.community, &context).await),\n    ..CommentInsertForm::new(my_person_id, data.post_id, content.clone())\n  };\n  comment_form = plugin_hook_before(\"local_comment_before_create\", comment_form).await?;\n  validate_post_language(&mut context.pool(), comment_form.language_id, community_id).await?;\n\n  // Create the comment\n  let parent_path = parent_opt.clone().map(|t| t.path);\n  let inserted_comment =\n    Comment::create(&mut context.pool(), &comment_form, parent_path.as_ref()).await?;\n  plugin_hook_after(\"local_comment_after_create\", &inserted_comment);\n\n  NotifyData {\n    comment: Some(inserted_comment.clone()),\n    do_send_email: !local_site.disable_email_notifications,\n    ..NotifyData::new(\n      post.clone(),\n      local_user_view.person.clone(),\n      post_view.community,\n    )\n  }\n  .send(&context);\n\n  // You like your own comment by default\n  let like_form = CommentLikeForm::new(inserted_comment.id, my_person_id, Some(true));\n\n  CommentActions::like(&mut context.pool(), &like_form).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::CreateComment(inserted_comment.clone()),\n    &context,\n  )?;\n\n  // Update the read comments, so your own new comment doesn't appear as a +1 unread\n  update_read_comments(\n    my_person_id,\n    post_id,\n    post.comments + 1,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // If we're responding to a comment where we're the recipient,\n  // (ie we're the grandparent, or the recipient of the parent comment_reply),\n  // then mark the parent as read.\n  // Then we don't have to do it manually after we respond to a comment.\n  if let Some(parent) = parent_opt {\n    Notification::mark_read_by_comment_and_recipient(\n      &mut context.pool(),\n      parent.id,\n      my_person_id,\n      true,\n    )\n    .await\n    .ok();\n  }\n\n  Ok(Json(\n    build_comment_response(\n      &context,\n      inserted_comment.id,\n      Some(local_user_view),\n      local_instance_id,\n    )\n    .await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_user_action,\n};\nuse lemmy_db_schema::source::comment::{Comment, CommentUpdateForm};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, DeleteComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn delete_comment(\n  Json(data): Json<DeleteComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let orig_comment = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  // Dont delete it if its already been deleted.\n  if orig_comment.comment.deleted == data.deleted {\n    return Err(LemmyErrorType::CouldntUpdate.into());\n  }\n\n  check_community_user_action(\n    &local_user_view,\n    &orig_comment.community,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Verify that only the creator can delete\n  if local_user_view.person.id != orig_comment.creator.id {\n    return Err(LemmyErrorType::NoCommentEditAllowed.into());\n  }\n\n  // Do the delete\n  let deleted = data.deleted;\n  let updated_comment = Comment::update(\n    &mut context.pool(),\n    comment_id,\n    &CommentUpdateForm {\n      deleted: Some(deleted),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  let updated_comment_id = updated_comment.id;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::DeleteComment(\n      updated_comment,\n      local_user_view.person.clone(),\n      orig_comment.community,\n    ),\n    &context,\n  )?;\n\n  Ok(Json(\n    build_comment_response(\n      &context,\n      updated_comment_id,\n      Some(local_user_view),\n      local_instance_id,\n    )\n    .await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod read;\npub mod remove;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/read.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  utils::check_private_instance,\n};\nuse lemmy_db_views_comment::api::{CommentResponse, GetComment};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_comment(\n  Query(data): Query<GetComment>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<CommentResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n  let local_instance_id = site_view.site.instance_id;\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  Ok(Json(\n    build_comment_response(&context, data.id, local_user_view, local_instance_id).await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/remove.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::{\n  source::{\n    comment::{Comment, CommentUpdateForm},\n    comment_report::CommentReport,\n    local_user::LocalUser,\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, RemoveComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn remove_comment(\n  Json(data): Json<RemoveComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let orig_comment = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  check_community_mod_action(\n    &local_user_view,\n    &orig_comment.community,\n    false,\n    &mut context.pool(),\n  )\n  .await?;\n\n  LocalUser::is_higher_mod_or_admin_check(\n    &mut context.pool(),\n    orig_comment.community.id,\n    local_user_view.person.id,\n    vec![orig_comment.creator.id],\n  )\n  .await?;\n\n  // Don't allow removing or restoring comment which was deleted by user, as it would reveal\n  // the comment text in mod log.\n  if orig_comment.comment.deleted {\n    return Err(LemmyErrorType::CouldntUpdate.into());\n  }\n\n  let (updated_comment, forms) = if let Some(remove_children) = data.remove_children {\n    let updated_comments: Vec<Comment> = Comment::update_removed_for_comment_and_children(\n      &mut context.pool(),\n      &orig_comment.comment.path,\n      remove_children,\n    )\n    .await?;\n\n    let updated_comment = updated_comments\n      .iter()\n      .find(|c| c.id == comment_id)\n      .ok_or(LemmyErrorType::CouldntUpdate)?\n      .clone();\n\n    let forms: Vec<_> = updated_comments\n      .iter()\n      // Filter out deleted comments here so their content doesn't show up in the modlog.\n      .filter(|c| !c.deleted)\n      .map(|comment| {\n        ModlogInsertForm::mod_remove_comment(\n          local_user_view.person.id,\n          comment,\n          orig_comment.community.id,\n          remove_children,\n          &data.reason,\n          None,\n        )\n      })\n      .collect();\n\n    CommentReport::resolve_all_for_thread(\n      &mut context.pool(),\n      &orig_comment.comment.path,\n      local_user_view.person.id,\n    )\n    .await?;\n\n    (updated_comment, forms)\n  } else {\n    // Do the remove\n    let removed = data.removed;\n    let updated_comment = Comment::update(\n      &mut context.pool(),\n      comment_id,\n      &CommentUpdateForm {\n        removed: Some(removed),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    CommentReport::resolve_all_for_object(\n      &mut context.pool(),\n      comment_id,\n      local_user_view.person.id,\n    )\n    .await?;\n\n    // Mod tables\n    let form = ModlogInsertForm::mod_remove_comment(\n      local_user_view.person.id,\n      &orig_comment.comment,\n      orig_comment.community.id,\n      removed,\n      &data.reason,\n      None,\n    );\n\n    (updated_comment, vec![form])\n  };\n\n  let actions = Modlog::create(&mut context.pool(), &forms).await?;\n  notify_mod_action(actions, &context);\n\n  let updated_comment_id = updated_comment.id;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemoveComment {\n      comment: updated_comment,\n      moderator: local_user_view.person.clone(),\n      community: orig_comment.community,\n      reason: data.reason.clone(),\n      with_replies: data.remove_children.unwrap_or_default(),\n    },\n    &context,\n  )?;\n\n  Ok(Json(\n    build_comment_response(\n      &context,\n      updated_comment_id,\n      Some(local_user_view),\n      local_instance_id,\n    )\n    .await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/comment/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  build_response::build_comment_response,\n  context::LemmyContext,\n  notify::NotifyData,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_user_action, get_url_blocklist, process_markdown_opt, slur_regex},\n};\nuse lemmy_db_schema::{\n  impls::actor_language::validate_post_language,\n  source::comment::{Comment, CommentUpdateForm},\n};\nuse lemmy_db_views_comment::{\n  CommentView,\n  api::{CommentResponse, EditComment},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::is_valid_body_field,\n};\n\npub async fn edit_comment(\n  Json(data): Json<EditComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let comment_id = data.comment_id;\n  let local_instance_id = local_user_view.person.instance_id;\n  let orig_comment = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n  )\n  .await?;\n\n  check_community_user_action(\n    &local_user_view,\n    &orig_comment.community,\n    &mut context.pool(),\n  )\n  .await?;\n\n  // Verify that only the creator can edit\n  if local_user_view.person.id != orig_comment.creator.id {\n    return Err(LemmyErrorType::NoCommentEditAllowed.into());\n  }\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let content = process_markdown_opt(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  if let Some(content) = &content {\n    is_valid_body_field(content, false)?;\n  }\n\n  let comment_id = data.comment_id;\n  let mut form = CommentUpdateForm {\n    content,\n    language_id: data.language_id,\n    updated_at: Some(Some(Utc::now())),\n    ..Default::default()\n  };\n  form = plugin_hook_before(\"local_comment_before_update\", form).await?;\n  validate_post_language(\n    &mut context.pool(),\n    form.language_id,\n    orig_comment.community.id,\n  )\n  .await?;\n\n  let updated_comment = Comment::update(&mut context.pool(), comment_id, &form).await?;\n\n  plugin_hook_after(\"local_comment_after_update\", &updated_comment);\n\n  // Do the mentions / recipients\n  NotifyData {\n    comment: Some(updated_comment.clone()),\n    ..NotifyData::new(\n      orig_comment.post,\n      local_user_view.person.clone(),\n      orig_comment.community,\n    )\n  }\n  .send(&context);\n\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdateComment(updated_comment.clone()),\n    &context,\n  )?;\n\n  Ok(Json(\n    build_comment_response(\n      &context,\n      updated_comment.id,\n      Some(local_user_view),\n      local_instance_id,\n    )\n    .await?,\n  ))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/community/create.rs",
    "content": "use activitypub_federation::{config::Data, http_signatures::generate_actor_keypair};\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_community_response,\n  context::LemmyContext,\n  utils::{\n    check_local_user_valid,\n    check_nsfw_allowed,\n    generate_featured_url,\n    generate_followers_url,\n    generate_inbox_url,\n    generate_moderators_url,\n    get_url_blocklist,\n    is_admin,\n    process_markdown_opt,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    actor_language::{CommunityLanguage, LocalUserLanguage, SiteLanguage},\n    community::{\n      Community,\n      CommunityActions,\n      CommunityFollowerForm,\n      CommunityInsertForm,\n      CommunityModeratorForm,\n    },\n  },\n  traits::{ApubActor, Followable},\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_community::api::{CommunityResponse, CreateCommunity};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::{\n    slurs::check_slurs,\n    validation::{\n      is_valid_actor_name,\n      is_valid_body_field,\n      is_valid_display_name,\n      summary_length_check,\n    },\n  },\n};\n\npub async fn create_community(\n  Json(data): Json<CreateCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let SiteView {\n    site, local_site, ..\n  } = SiteView::read_local(&mut context.pool()).await?;\n\n  if local_site.community_creation_admin_only && is_admin(&local_user_view).is_err() {\n    return Err(LemmyErrorType::OnlyAdminsCanCreateCommunities.into());\n  }\n\n  check_nsfw_allowed(data.nsfw, Some(&local_site))?;\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  check_slurs(&data.name, &slur_regex)?;\n  check_slurs(&data.title, &slur_regex)?;\n\n  let sidebar = process_markdown_opt(\n    &data.sidebar,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  let title = data.title.trim().to_string();\n  is_valid_display_name(&title)?;\n\n  // Ensure that the sidebar has fewer than the max num characters...\n  if let Some(sidebar) = &sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  let summary = data.summary.clone();\n  if let Some(summary) = &summary {\n    summary_length_check(summary)?;\n    check_slurs(summary, &slur_regex)?;\n  }\n\n  is_valid_actor_name(&data.name)?;\n\n  // Double check for duplicate community actor_ids\n  let community_ap_id = Community::generate_local_actor_url(&data.name, context.settings())?;\n  let community_dupe = Community::read_from_apub_id(&mut context.pool(), &community_ap_id).await?;\n  if community_dupe.is_some() {\n    return Err(LemmyErrorType::AlreadyExists.into());\n  }\n\n  let keypair = generate_actor_keypair()?;\n  let community_form = CommunityInsertForm {\n    sidebar,\n    summary,\n    nsfw: data.nsfw,\n    ap_id: Some(community_ap_id.clone()),\n    private_key: Some(keypair.private_key),\n    followers_url: Some(generate_followers_url(&community_ap_id)?),\n    inbox_url: Some(generate_inbox_url()?),\n    moderators_url: Some(generate_moderators_url(&community_ap_id)?),\n    featured_url: Some(generate_featured_url(&community_ap_id)?),\n    posting_restricted_to_mods: data.posting_restricted_to_mods,\n    visibility: data.visibility,\n    ..CommunityInsertForm::new(\n      site.instance_id,\n      data.name.clone(),\n      title,\n      keypair.public_key,\n    )\n  };\n\n  let inserted_community = Community::create(&mut context.pool(), &community_form).await?;\n  let community_id = inserted_community.id;\n\n  // The community creator becomes a moderator\n  let community_moderator_form =\n    CommunityModeratorForm::new(community_id, local_user_view.person.id);\n\n  CommunityActions::join(&mut context.pool(), &community_moderator_form).await?;\n\n  // Follow your own community\n  let community_follower_form = CommunityFollowerForm::new(\n    community_id,\n    local_user_view.person.id,\n    CommunityFollowerState::Accepted,\n  );\n\n  CommunityActions::follow(&mut context.pool(), &community_follower_form).await?;\n\n  // Update the discussion_languages if that's provided\n  let site_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?;\n  let languages = if let Some(languages) = data.discussion_languages.clone() {\n    // check that community languages are a subset of site languages\n    // https://stackoverflow.com/a/64227550\n    let is_subset = languages.iter().all(|item| site_languages.contains(item));\n    if !is_subset {\n      return Err(LemmyErrorType::LanguageNotAllowed.into());\n    }\n    languages\n  } else {\n    // Copy languages from creator\n    LocalUserLanguage::read(&mut context.pool(), local_user_view.local_user.id)\n      .await?\n      .into_iter()\n      .filter(|l| site_languages.contains(l))\n      .collect()\n  };\n  CommunityLanguage::update(&mut context.pool(), languages, community_id).await?;\n\n  build_community_response(&context, local_user_view, community_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/community/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_community_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_mod_action, check_local_user_valid, is_top_mod},\n};\nuse lemmy_db_schema::source::community::{Community, CommunityUpdateForm};\nuse lemmy_db_views_community::api::{CommunityResponse, DeleteCommunity};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn delete_community(\n  Json(data): Json<DeleteCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  // Fetch the community mods\n  let community_mods =\n    CommunityModeratorView::for_community(&mut context.pool(), data.community_id).await?;\n\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n  check_community_mod_action(&local_user_view, &community, true, &mut context.pool()).await?;\n\n  // Make sure deleter is the top mod\n  is_top_mod(&local_user_view, &community_mods)?;\n\n  // Do the delete\n  let community_id = data.community_id;\n  let deleted = data.deleted;\n  let community = Community::update(\n    &mut context.pool(),\n    community_id,\n    &CommunityUpdateForm {\n      deleted: Some(deleted),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::DeleteCommunity(local_user_view.person.clone(), community, data.deleted),\n    &context,\n  )?;\n\n  build_community_response(&context, local_user_view, community_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/community/list.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_private_instance};\nuse lemmy_db_views_community::{CommunityView, api::ListCommunities, impls::CommunityQuery};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_communities(\n  Query(data): Query<ListCommunities>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<CommunityView>>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?;\n\n  check_private_instance(&local_user_view, &local_site.local_site)?;\n\n  let local_user = local_user_view.map(|l| l.local_user);\n\n  // Show nsfw content if param is true, or if content_warning exists\n  let show_nsfw = data\n    .show_nsfw\n    .unwrap_or(local_site.site.content_warning.is_some());\n\n  let res = CommunityQuery {\n    listing_type: data.type_,\n    show_nsfw: Some(show_nsfw),\n    sort: data.sort,\n    time_range_seconds: data.time_range_seconds,\n    local_user: local_user.as_ref(),\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    ..Default::default()\n  }\n  .list(&local_site.site, &mut context.pool())\n  .await?;\n\n  // Return the jwt\n  Ok(Json(res))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/community/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod list;\npub mod remove;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/community/remove.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_community_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_mod_action, is_admin},\n};\nuse lemmy_db_schema::{\n  source::{\n    community::{Community, CommunityUpdateForm},\n    community_report::CommunityReport,\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_community::api::{CommunityResponse, RemoveCommunity};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn remove_community(\n  Json(data): Json<RemoveCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n  check_community_mod_action(&local_user_view, &community, true, &mut context.pool()).await?;\n\n  // Verify its an admin (only an admin can remove a community)\n  is_admin(&local_user_view)?;\n\n  // Do the remove\n  let community_id = data.community_id;\n  let removed = data.removed;\n  let community = Community::update(\n    &mut context.pool(),\n    community_id,\n    &CommunityUpdateForm {\n      removed: Some(removed),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  CommunityReport::resolve_all_for_object(\n    &mut context.pool(),\n    community_id,\n    local_user_view.person.id,\n  )\n  .await?;\n\n  // Mod\n  let community_owner =\n    CommunityModeratorView::top_mod_for_community(&mut context.pool(), data.community_id).await?;\n  let form = ModlogInsertForm::admin_remove_community(\n    &local_user_view.person,\n    data.community_id,\n    community_owner,\n    removed,\n    &data.reason,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action.clone(), context.app_data());\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemoveCommunity {\n      moderator: local_user_view.person.clone(),\n      community,\n      reason: data.reason.clone(),\n      removed: data.removed,\n    },\n    &context,\n  )?;\n\n  build_community_response(&context, local_user_view, community_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/community/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  build_response::build_community_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_community_mod_action,\n    check_local_user_valid,\n    check_nsfw_allowed,\n    get_url_blocklist,\n    process_markdown_opt,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::source::{\n  actor_language::{CommunityLanguage, SiteLanguage},\n  community::{Community, CommunityUpdateForm},\n  modlog::{Modlog, ModlogInsertForm},\n};\nuse lemmy_db_views_community::api::{CommunityResponse, EditCommunity};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::{\n    slurs::{check_slurs, check_slurs_opt},\n    validation::{is_valid_body_field, is_valid_display_name},\n  },\n};\n\npub async fn edit_community(\n  Json(data): Json<EditCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  check_slurs_opt(&data.title, &slur_regex)?;\n  check_slurs_opt(&data.summary, &slur_regex)?;\n  check_nsfw_allowed(data.nsfw, Some(&local_site))?;\n\n  let title = data.title.as_ref().map(|x| x.trim().to_string());\n\n  if let Some(title) = &title {\n    check_slurs(title, &slur_regex)?;\n    is_valid_display_name(title)?;\n  }\n\n  let sidebar = diesel_string_update(\n    process_markdown_opt(\n      &data.sidebar,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?\n    .as_deref(),\n  );\n  if let Some(Some(sidebar)) = &sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  let summary = diesel_string_update(data.summary.as_deref());\n\n  let old_community = Community::read(&mut context.pool(), data.community_id).await?;\n\n  // Verify its a mod (only mods can edit it)\n  check_community_mod_action(&local_user_view, &old_community, false, &mut context.pool()).await?;\n\n  let community_id = data.community_id;\n  if let Some(languages) = data.discussion_languages.clone() {\n    let site_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?;\n    // check that community languages are a subset of site languages\n    // https://stackoverflow.com/a/64227550\n    let is_subset = languages.iter().all(|item| site_languages.contains(item));\n    if !is_subset {\n      return Err(LemmyErrorType::LanguageNotAllowed.into());\n    }\n    CommunityLanguage::update(&mut context.pool(), languages, community_id).await?;\n  }\n\n  let community_form = CommunityUpdateForm {\n    title,\n    sidebar,\n    summary,\n    nsfw: data.nsfw,\n    posting_restricted_to_mods: data.posting_restricted_to_mods,\n    visibility: data.visibility,\n    updated_at: Some(Some(Utc::now())),\n    ..Default::default()\n  };\n\n  let community_id = data.community_id;\n  let community = Community::update(&mut context.pool(), community_id, &community_form).await?;\n\n  let visibility_changed = old_community.visibility != community.visibility;\n  if visibility_changed {\n    let form = ModlogInsertForm::mod_change_community_visibility(\n      local_user_view.person.id,\n      data.community_id,\n    );\n    Modlog::create(&mut context.pool(), &[form]).await?;\n  }\n\n  // If community visibility was changed to local-only, mark it as deleted on other instances. Also\n  // restore it if visibility is changed to public again.\n  if visibility_changed && !old_community.visibility.can_federate()\n    || !community.visibility.can_federate()\n  {\n    let mark_deleted = !community.visibility.can_federate();\n    let activity = SendActivityData::DeleteCommunity(\n      local_user_view.person.clone(),\n      community.clone(),\n      mark_deleted,\n    );\n    ActivityChannel::submit_activity(activity, &context)?;\n  }\n\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),\n    &context,\n  )?;\n\n  build_community_response(&context, local_user_view, community_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/custom_emoji/create.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::{\n  custom_emoji::{CustomEmoji, CustomEmojiInsertForm},\n  custom_emoji_keyword::CustomEmojiKeyword,\n};\nuse lemmy_db_views_custom_emoji::{\n  CustomEmojiView,\n  api::{CreateCustomEmoji, CustomEmojiResponse},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn create_custom_emoji(\n  Json(data): Json<CreateCustomEmoji>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CustomEmojiResponse>> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let emoji_form = CustomEmojiInsertForm {\n    shortcode: data.shortcode.to_lowercase().trim().to_string(),\n    image_url: data.image_url.clone(),\n    alt_text: data.alt_text.clone(),\n    category: data.category.clone(),\n  };\n  let emoji = CustomEmoji::create(&mut context.pool(), &emoji_form).await?;\n\n  CustomEmojiKeyword::create_from_keywords(&mut context.pool(), emoji.id, &data.keywords).await?;\n\n  let view = CustomEmojiView::get(&mut context.pool(), emoji.id).await?;\n  Ok(Json(CustomEmojiResponse { custom_emoji: view }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/custom_emoji/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::custom_emoji::CustomEmoji;\nuse lemmy_db_views_custom_emoji::api::DeleteCustomEmoji;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn delete_custom_emoji(\n  Json(data): Json<DeleteCustomEmoji>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  CustomEmoji::delete(&mut context.pool(), data.id).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/custom_emoji/list.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_custom_emoji::{\n  CustomEmojiView,\n  api::{ListCustomEmojis, ListCustomEmojisResponse},\n};\nuse lemmy_utils::error::LemmyError;\n\npub async fn list_custom_emojis(\n  Query(data): Query<ListCustomEmojis>,\n  context: Data<LemmyContext>,\n) -> Result<Json<ListCustomEmojisResponse>, LemmyError> {\n  let custom_emojis = CustomEmojiView::list(&mut context.pool(), &data.category).await?;\n\n  Ok(Json(ListCustomEmojisResponse { custom_emojis }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/custom_emoji/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod list;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/custom_emoji/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::{\n  custom_emoji::{CustomEmoji, CustomEmojiUpdateForm},\n  custom_emoji_keyword::CustomEmojiKeyword,\n};\nuse lemmy_db_views_custom_emoji::{\n  CustomEmojiView,\n  api::{CustomEmojiResponse, EditCustomEmoji},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn edit_custom_emoji(\n  Json(data): Json<EditCustomEmoji>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CustomEmojiResponse>> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let emoji_form = CustomEmojiUpdateForm {\n    image_url: data.image_url.clone(),\n    shortcode: data\n      .shortcode\n      .clone()\n      .map(|s| s.to_lowercase().trim().to_string()),\n    alt_text: data.alt_text.clone(),\n    category: data.category.clone(),\n  };\n  let emoji = CustomEmoji::update(&mut context.pool(), data.id, &emoji_form).await?;\n\n  // Delete the existing keywords, and recreate\n  if let Some(keywords) = &data.keywords {\n    CustomEmojiKeyword::delete(&mut context.pool(), data.id).await?;\n    CustomEmojiKeyword::create_from_keywords(&mut context.pool(), emoji.id, keywords).await?;\n  }\n\n  let view = CustomEmojiView::get(&mut context.pool(), emoji.id).await?;\n  Ok(Json(CustomEmojiResponse { custom_emoji: view }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/lib.rs",
    "content": "use lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::community::{Community, CommunityActions};\n\npub mod comment;\npub mod community;\npub mod custom_emoji;\npub mod multi_community;\npub mod oauth_provider;\npub mod post;\npub mod private_message;\npub mod site;\npub mod tagline;\npub mod user;\n\n/// Only mark new posts/comments to remote community as pending if it has any local followers.\n/// Otherwise it could never get updated to be marked as published.\nasync fn community_use_pending(community: &Community, context: &LemmyContext) -> bool {\n  if community.local {\n    return false;\n  }\n  CommunityActions::check_accept_activity_in_community(&mut context.pool(), community)\n    .await\n    .is_ok()\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/create.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::multi_community::{MultiCommunity, MultiCommunityFollowForm, MultiCommunityInsertForm},\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_community::{\n  MultiCommunityView,\n  api::{CreateMultiCommunity, MultiCommunityResponse},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::LemmyResult,\n  utils::{\n    slurs::check_slurs,\n    validation::{\n      is_valid_actor_name,\n      is_valid_body_field,\n      is_valid_display_name,\n      summary_length_check,\n    },\n  },\n};\nuse url::Url;\n\npub async fn create_multi_community(\n  Json(data): Json<CreateMultiCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<MultiCommunityResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  let SiteView {\n    site, local_site, ..\n  } = SiteView::read_local(&mut context.pool()).await?;\n\n  let my_person_id = local_user_view.person.id;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n\n  is_valid_display_name(&data.name)?;\n  check_slurs(&data.name, &slur_regex)?;\n\n  let ap_id = MultiCommunity::generate_local_actor_url(&data.name, context.settings())?;\n  let following_url = Url::parse(&format!(\"{}/following\", ap_id))?;\n\n  let title = data.title.as_ref().map(|x| x.trim().to_string());\n  if let Some(title) = &title {\n    check_slurs(title, &slur_regex)?;\n    is_valid_display_name(title)?;\n  }\n\n  // Ensure that the sidebar has fewer than the max num characters...\n  let sidebar = process_markdown_opt(\n    &data.sidebar,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  if let Some(sidebar) = &sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  let summary = data.summary.clone();\n  if let Some(summary) = &summary {\n    summary_length_check(summary)?;\n    check_slurs(summary, &slur_regex)?;\n  }\n\n  is_valid_actor_name(&data.name)?;\n\n  let form = MultiCommunityInsertForm {\n    title,\n    summary,\n    sidebar,\n    ap_id: Some(ap_id),\n    private_key: site.private_key,\n    inbox_url: Some(site.inbox_url),\n    following_url: Some(following_url.into()),\n    ..MultiCommunityInsertForm::new(\n      my_person_id,\n      local_user_view.person.instance_id,\n      data.name.clone(),\n      site.public_key,\n    )\n  };\n\n  let multi = MultiCommunity::create(&mut context.pool(), &form).await?;\n\n  // You follow your own community\n  let follow_form = MultiCommunityFollowForm {\n    multi_community_id: multi.id,\n    person_id: my_person_id,\n    follow_state: CommunityFollowerState::Accepted,\n  };\n  MultiCommunity::follow(&mut context.pool(), &follow_form).await?;\n\n  let multi_community_view =\n    MultiCommunityView::read(&mut context.pool(), multi.id, Some(my_person_id)).await?;\n\n  Ok(Json(MultiCommunityResponse {\n    multi_community_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/create_entry.rs",
    "content": "use super::{check_multi_community_creator, send_federation_update};\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_community_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_community_deleted_removed, check_local_user_valid},\n};\nuse lemmy_db_schema::{\n  source::{\n    community::{Community, CommunityActions, CommunityFollowerForm},\n    multi_community::{MultiCommunity, MultiCommunityEntry, MultiCommunityEntryForm},\n  },\n  traits::Followable,\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_community::api::{CommunityResponse, CreateOrDeleteMultiCommunityEntry};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn create_multi_community_entry(\n  Json(data): Json<CreateOrDeleteMultiCommunityEntry>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponse>> {\n  let community_id = data.community_id;\n\n  check_local_user_valid(&local_user_view)?;\n\n  let multi = MultiCommunity::read(&mut context.pool(), data.id).await?;\n  check_multi_community_creator(&multi, &local_user_view)?;\n\n  let community = Community::read(&mut context.pool(), community_id).await?;\n  check_community_deleted_removed(&community)?;\n\n  MultiCommunityEntry::check_entry_limit(&mut context.pool(), data.id).await?;\n\n  let form = MultiCommunityEntryForm {\n    multi_community_id: data.id,\n    community_id,\n  };\n  let inserted_entry = MultiCommunityEntry::create(&mut context.pool(), &form).await?;\n\n  if !community.local {\n    let multicomm_follower = SiteView::read_system_account(&mut context.pool()).await?;\n    let actions = CommunityActions::read(&mut context.pool(), community.id, multicomm_follower.id)\n      .await\n      .unwrap_or_default();\n\n    // follow the community if not already followed\n    if actions.followed_at.is_none() {\n      let form = CommunityFollowerForm::new(\n        community.id,\n        multicomm_follower.id,\n        CommunityFollowerState::Pending,\n      );\n      CommunityActions::follow(&mut context.pool(), &form).await?;\n      ActivityChannel::submit_activity(\n        SendActivityData::FollowCommunity(community, local_user_view.person.clone(), true),\n        &context,\n      )?;\n    }\n  }\n\n  send_federation_update(multi, local_user_view.person.clone(), &context)?;\n\n  build_community_response(&context, local_user_view, inserted_entry.community_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/delete_entry.rs",
    "content": "use super::{check_multi_community_creator, send_federation_update};\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_local_user_valid,\n};\nuse lemmy_db_schema::{\n  source::{\n    community::{Community, CommunityActions},\n    multi_community::{MultiCommunity, MultiCommunityEntry, MultiCommunityEntryForm},\n  },\n  traits::Followable,\n};\nuse lemmy_db_views_community::api::CreateOrDeleteMultiCommunityEntry;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{SiteView, api::SuccessResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn delete_multi_community_entry(\n  Json(data): Json<CreateOrDeleteMultiCommunityEntry>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  check_local_user_valid(&local_user_view)?;\n\n  let multi = MultiCommunity::read(&mut context.pool(), data.id).await?;\n  check_multi_community_creator(&multi, &local_user_view)?;\n\n  let community = Community::read(&mut context.pool(), data.community_id).await?;\n\n  let form = MultiCommunityEntryForm {\n    multi_community_id: data.id,\n    community_id: data.community_id,\n  };\n  MultiCommunityEntry::delete(&mut context.pool(), &form).await?;\n\n  if !community.local {\n    let used_in_multiple =\n      MultiCommunityEntry::community_used_in_multiple(&mut context.pool(), &form).await?;\n    // unfollow the community only if its not used in another multi-community\n    if !used_in_multiple {\n      let multicomm_follower = SiteView::read_system_account(&mut context.pool()).await?;\n      CommunityActions::unfollow(&mut context.pool(), multicomm_follower.id, community.id).await?;\n      ActivityChannel::submit_activity(\n        SendActivityData::FollowCommunity(community, local_user_view.person.clone(), false),\n        &context,\n      )?;\n    }\n  }\n\n  send_federation_update(multi, local_user_view.person, &context)?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/list.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::{Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_community::{\n  MultiCommunityView,\n  api::ListMultiCommunities,\n  impls::MultiCommunityQuery,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn list_multi_communities(\n  Query(data): Query<ListMultiCommunities>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<PagedResponse<MultiCommunityView>>> {\n  let my_person_id = local_user_view.map(|l| l.person.id);\n\n  let res = MultiCommunityQuery {\n    listing_type: data.type_,\n    sort: data.sort,\n    creator_id: data.creator_id,\n    my_person_id,\n    time_range_seconds: data.time_range_seconds,\n    page_cursor: data.page_cursor,\n    limit: data.limit,\n    ..Default::default()\n  }\n  .list(&mut context.pool())\n  .await?;\n\n  Ok(Json(res))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/mod.rs",
    "content": "use activitypub_federation::config::Data;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n};\nuse lemmy_db_schema::source::{multi_community::MultiCommunity, person::Person};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub mod create;\npub mod create_entry;\npub mod delete_entry;\npub mod list;\npub mod update;\n\n/// Check that current user is creator of multi-comm and can modify it.\nfn check_multi_community_creator(\n  multi: &MultiCommunity,\n  local_user_view: &LocalUserView,\n) -> LemmyResult<()> {\n  if multi.local && local_user_view.local_user.admin {\n    Ok(())\n  } else if multi.creator_id != local_user_view.person.id {\n    Err(LemmyErrorType::MultiCommunityUpdateWrongUser.into())\n  } else {\n    Ok(())\n  }\n}\n\nfn send_federation_update(\n  multi: MultiCommunity,\n  person: Person,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdateMultiCommunity(multi, person),\n    context,\n  )\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/multi_community/update.rs",
    "content": "use super::{check_multi_community_creator, send_federation_update};\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex},\n};\nuse lemmy_db_schema::source::multi_community::{MultiCommunity, MultiCommunityUpdateForm};\nuse lemmy_db_views_community::{\n  MultiCommunityView,\n  api::{EditMultiCommunity, MultiCommunityResponse},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update};\nuse lemmy_utils::{\n  error::LemmyResult,\n  utils::{\n    slurs::check_slurs,\n    validation::{is_valid_body_field, is_valid_display_name, summary_length_check},\n  },\n};\n\npub async fn edit_multi_community(\n  Json(data): Json<EditMultiCommunity>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<MultiCommunityResponse>> {\n  let multi_community_id = data.id;\n  let my_person_id = local_user_view.person.id;\n  check_local_user_valid(&local_user_view)?;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let orig_multi = MultiCommunity::read(&mut context.pool(), data.id).await?;\n  check_multi_community_creator(&orig_multi, &local_user_view)?;\n\n  let title = data.title.as_ref().map(|x| x.trim().to_string());\n  if let Some(title) = &title {\n    check_slurs(title, &slur_regex)?;\n    is_valid_display_name(title)?;\n  }\n  let title = diesel_string_update(title.as_deref());\n\n  let sidebar = diesel_string_update(\n    process_markdown_opt(\n      &data.sidebar,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?\n    .as_deref(),\n  );\n  if let Some(Some(sidebar)) = &sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  let summary = data.summary.clone();\n  if let Some(summary) = &summary {\n    summary_length_check(summary)?;\n    check_slurs(summary, &slur_regex)?;\n  }\n  let summary = diesel_string_update(summary.as_deref());\n\n  let form = MultiCommunityUpdateForm {\n    title,\n    sidebar,\n    summary,\n    deleted: data.deleted,\n    updated_at: Some(Utc::now()),\n  };\n  let multi = MultiCommunity::update(&mut context.pool(), multi_community_id, &form).await?;\n\n  send_federation_update(multi, local_user_view.person, &context)?;\n\n  let multi_community_view =\n    MultiCommunityView::read(&mut context.pool(), multi_community_id, Some(my_person_id)).await?;\n\n  Ok(Json(MultiCommunityResponse {\n    multi_community_view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/oauth_provider/create.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::oauth_provider::{AdminOAuthProvider, OAuthProviderInsertForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::CreateOAuthProvider;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyError;\nuse url::Url;\n\npub async fn create_oauth_provider(\n  Json(data): Json<CreateOAuthProvider>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<AdminOAuthProvider>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let cloned_data = data.clone();\n  let oauth_provider_form = OAuthProviderInsertForm {\n    display_name: cloned_data.display_name,\n    issuer: Url::parse(&cloned_data.issuer)?.into(),\n    authorization_endpoint: Url::parse(&cloned_data.authorization_endpoint)?.into(),\n    token_endpoint: Url::parse(&cloned_data.token_endpoint)?.into(),\n    userinfo_endpoint: Url::parse(&cloned_data.userinfo_endpoint)?.into(),\n    id_claim: cloned_data.id_claim,\n    client_id: data.client_id.clone(),\n    client_secret: data.client_secret.clone(),\n    scopes: data.scopes.clone(),\n    auto_verify_email: data.auto_verify_email,\n    account_linking_enabled: data.account_linking_enabled,\n    use_pkce: data.use_pkce,\n    enabled: data.enabled,\n  };\n  let oauth_provider =\n    AdminOAuthProvider::create(&mut context.pool(), &oauth_provider_form).await?;\n  Ok(Json(oauth_provider))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/oauth_provider/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::oauth_provider::AdminOAuthProvider;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{DeleteOAuthProvider, SuccessResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyError;\n\npub async fn delete_oauth_provider(\n  Json(data): Json<DeleteOAuthProvider>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<SuccessResponse>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  AdminOAuthProvider::delete(&mut context.pool(), data.id).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/oauth_provider/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/oauth_provider/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::oauth_provider::{AdminOAuthProvider, OAuthProviderUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::EditOAuthProvider;\nuse lemmy_diesel_utils::{\n  traits::Crud,\n  utils::{diesel_required_string_update, diesel_required_url_update},\n};\nuse lemmy_utils::error::LemmyError;\n\npub async fn edit_oauth_provider(\n  Json(data): Json<EditOAuthProvider>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<AdminOAuthProvider>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let cloned_data = data.clone();\n  let oauth_provider_form = OAuthProviderUpdateForm {\n    display_name: diesel_required_string_update(cloned_data.display_name.as_deref()),\n    authorization_endpoint: diesel_required_url_update(\n      cloned_data.authorization_endpoint.as_deref(),\n    )?,\n    token_endpoint: diesel_required_url_update(cloned_data.token_endpoint.as_deref())?,\n    userinfo_endpoint: diesel_required_url_update(cloned_data.userinfo_endpoint.as_deref())?,\n    id_claim: diesel_required_string_update(data.id_claim.as_deref()),\n    client_secret: diesel_required_string_update(data.client_secret.as_deref()),\n    scopes: diesel_required_string_update(data.scopes.as_deref()),\n    auto_verify_email: data.auto_verify_email,\n    account_linking_enabled: data.account_linking_enabled,\n    enabled: data.enabled,\n    use_pkce: data.use_pkce,\n    updated_at: Some(Some(Utc::now())),\n  };\n\n  let update_result =\n    AdminOAuthProvider::update(&mut context.pool(), data.id, &oauth_provider_form).await?;\n  let oauth_provider = AdminOAuthProvider::read(&mut context.pool(), update_result.id).await?;\n  Ok(Json(oauth_provider))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/create.rs",
    "content": "use super::convert_published_time;\nuse crate::community_use_pending;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  notify::NotifyData,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  request::generate_post_link_metadata,\n  send_activity::SendActivityData,\n  utils::{\n    check_community_user_action,\n    check_nsfw_allowed,\n    get_url_blocklist,\n    honeypot_check,\n    process_markdown_opt,\n    send_webmention,\n    slur_regex,\n    update_post_tags,\n  },\n};\nuse lemmy_db_schema::{\n  impls::actor_language::validate_post_language,\n  source::post::{Post, PostActions, PostInsertForm, PostLikeForm},\n  traits::Likeable,\n};\nuse lemmy_db_views_community::CommunityView;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::{CreatePost, PostResponse};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{traits::Crud, utils::diesel_url_create};\nuse lemmy_utils::{\n  error::LemmyResult,\n  utils::{\n    slurs::check_slurs,\n    validation::{\n      is_url_blocked,\n      is_valid_alt_text_field,\n      is_valid_body_field,\n      is_valid_post_title,\n      is_valid_url,\n    },\n  },\n};\n\npub async fn create_post(\n  Json(data): Json<CreatePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  honeypot_check(&data.honeypot)?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let slur_regex = slur_regex(&context).await?;\n  check_slurs(&data.name, &slur_regex)?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n\n  let body = process_markdown_opt(\n    &data.body,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  let url = diesel_url_create(data.url.as_deref())?;\n  let custom_thumbnail = diesel_url_create(data.custom_thumbnail.as_deref())?;\n  check_nsfw_allowed(data.nsfw, Some(&local_site))?;\n\n  is_valid_post_title(&data.name)?;\n\n  if let Some(url) = &url {\n    is_url_blocked(url, &url_blocklist)?;\n    is_valid_url(url)?;\n  }\n\n  if let Some(custom_thumbnail) = &custom_thumbnail {\n    is_valid_url(custom_thumbnail)?;\n  }\n\n  if let Some(alt_text) = &data.alt_text {\n    is_valid_alt_text_field(alt_text)?;\n  }\n\n  if let Some(body) = &body {\n    is_valid_body_field(body, true)?;\n  }\n\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    data.community_id,\n    Some(&local_user_view.local_user),\n    false,\n  )\n  .await?;\n  let community = &community_view.community;\n  check_community_user_action(&local_user_view, community, &mut context.pool()).await?;\n\n  // Ensure that all posts in NSFW communities are marked as NSFW\n  let nsfw = if community.nsfw {\n    Some(true)\n  } else {\n    data.nsfw\n  };\n\n  if community.posting_restricted_to_mods {\n    let community_id = data.community_id;\n    CommunityModeratorView::check_is_community_moderator(\n      &mut context.pool(),\n      community_id,\n      local_user_view.local_user.person_id,\n    )\n    .await?;\n  }\n\n  let scheduled_publish_time_at =\n    convert_published_time(data.scheduled_publish_time_at, &local_user_view, &context).await?;\n  let mut post_form = PostInsertForm {\n    url,\n    body,\n    alt_text: data.alt_text.clone(),\n    nsfw,\n    language_id: data.language_id,\n    federation_pending: Some(community_use_pending(community, &context).await),\n    scheduled_publish_time_at,\n    ..PostInsertForm::new(\n      data.name.trim().to_string(),\n      local_user_view.person.id,\n      data.community_id,\n    )\n  };\n\n  post_form = plugin_hook_before(\"local_post_before_create\", post_form).await?;\n  validate_post_language(\n    &mut context.pool(),\n    post_form.language_id,\n    data.community_id,\n  )\n  .await?;\n\n  let inserted_post = Post::create(&mut context.pool(), &post_form).await?;\n\n  plugin_hook_after(\"local_post_after_create\", &inserted_post);\n\n  if let Some(tags) = &data.tags {\n    update_post_tags(&inserted_post, tags, &context).await?;\n  }\n\n  let community_id = community.id;\n  let federate_post = if scheduled_publish_time_at.is_none() {\n    send_webmention(inserted_post.clone(), community);\n    |post| Some(SendActivityData::CreatePost(post))\n  } else {\n    |_| None\n  };\n  generate_post_link_metadata(\n    inserted_post.clone(),\n    custom_thumbnail.map(Into::into),\n    federate_post,\n    context.clone(),\n  )\n  .await?;\n\n  // They like their own post by default\n  let person_id = local_user_view.person.id;\n  let post_id = inserted_post.id;\n  let like_form = PostLikeForm::new(post_id, person_id, Some(true));\n\n  PostActions::like(&mut context.pool(), &like_form).await?;\n\n  NotifyData {\n    do_send_email: !local_site.disable_email_notifications,\n    ..NotifyData::new(\n      inserted_post.clone(),\n      local_user_view.person.clone(),\n      community.clone(),\n    )\n  }\n  .send(&context);\n\n  PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?;\n\n  build_post_response(&context, community_id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_user_action,\n};\nuse lemmy_db_schema::source::{\n  community::Community,\n  post::{Post, PostUpdateForm},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::{DeletePost, PostResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn delete_post(\n  Json(data): Json<DeletePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let post_id = data.post_id;\n  let orig_post = Post::read(&mut context.pool(), post_id).await?;\n\n  // Dont delete it if its already been deleted.\n  if orig_post.deleted == data.deleted {\n    return Err(LemmyErrorType::CouldntUpdate.into());\n  }\n\n  let community = Community::read(&mut context.pool(), orig_post.community_id).await?;\n  check_community_user_action(&local_user_view, &community, &mut context.pool()).await?;\n\n  // Verify that only the creator can delete\n  if !Post::is_post_creator(local_user_view.person.id, orig_post.creator_id) {\n    return Err(LemmyErrorType::NoPostEditAllowed.into());\n  }\n\n  // Update the post\n  let post = Post::update(\n    &mut context.pool(),\n    post_id,\n    &PostUpdateForm {\n      deleted: Some(data.deleted),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::DeletePost(post, local_user_view.person.clone(), community),\n    &context,\n  )?;\n\n  build_post_response(&context, orig_post.community_id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/mod.rs",
    "content": "use chrono::{DateTime, TimeZone, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::post::Post;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub mod create;\npub mod delete;\npub mod read;\npub mod remove;\npub mod update;\n\nasync fn convert_published_time(\n  scheduled_publish_time: Option<i64>,\n  local_user_view: &LocalUserView,\n  context: &LemmyContext,\n) -> LemmyResult<Option<DateTime<Utc>>> {\n  const MAX_SCHEDULED_POSTS: i64 = 10;\n  if let Some(scheduled_publish_time) = scheduled_publish_time {\n    let converted = Utc\n      .timestamp_opt(scheduled_publish_time, 0)\n      .single()\n      .ok_or(LemmyErrorType::InvalidUnixTime)?;\n    if converted < Utc::now() {\n      return Err(LemmyErrorType::PostScheduleTimeMustBeInFuture.into());\n    }\n    if !local_user_view.local_user.admin {\n      let count =\n        Post::user_scheduled_post_count(local_user_view.person.id, &mut context.pool()).await?;\n      if count >= MAX_SCHEDULED_POSTS {\n        return Err(LemmyErrorType::TooManyScheduledPosts.into());\n      }\n    }\n    Ok(Some(converted))\n  } else {\n    Ok(None)\n  }\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/read.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_private_instance, is_mod_or_admin_opt, update_read_comments},\n};\nuse lemmy_db_schema::{\n  SearchType,\n  source::{\n    comment::Comment,\n    post::{Post, PostActions},\n  },\n};\nuse lemmy_db_views_community::CommunityView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_search_combined::{\n  api::{GetPost, GetPostResponse},\n  impls::SearchCombinedQuery,\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn get_post(\n  Query(data): Query<GetPost>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetPostResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n  let local_instance_id = site_view.site.instance_id;\n\n  check_private_instance(&local_user_view, &local_site)?;\n\n  let person_id = local_user_view.as_ref().map(|u| u.person.id);\n  let local_user = local_user_view.as_ref().map(|l| l.local_user.clone());\n\n  // I'd prefer fetching the post_view by a comment join, but it adds a lot of boilerplate\n  let post_id = if let Some(id) = data.id {\n    id\n  } else if let Some(comment_id) = data.comment_id {\n    Comment::read(&mut context.pool(), comment_id)\n      .await?\n      .post_id\n  } else {\n    return Err(LemmyErrorType::NotFound.into());\n  };\n\n  // Check to see if the person is a mod or admin, to show deleted / removed\n  let community_id = Post::read(&mut context.pool(), post_id).await?.community_id;\n\n  let is_mod_or_admin = is_mod_or_admin_opt(\n    &mut context.pool(),\n    local_user_view.as_ref(),\n    Some(community_id),\n  )\n  .await\n  .is_ok();\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    local_user.as_ref(),\n    local_instance_id,\n    is_mod_or_admin,\n  )\n  .await?;\n\n  let post_id = post_view.post.id;\n  if let Some(person_id) = person_id {\n    PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?;\n\n    update_read_comments(\n      person_id,\n      post_id,\n      post_view.post.comments,\n      &mut context.pool(),\n    )\n    .await?;\n  }\n\n  // Necessary for the sidebar subscribed\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    local_user.as_ref(),\n    is_mod_or_admin,\n  )\n  .await?;\n\n  // Fetch the cross_posts\n  let cross_posts = if let Some(url) = &post_view.post.url {\n    SearchCombinedQuery {\n      search_term: Some(url.inner().as_str().into()),\n      post_url_only: Some(true),\n      type_: Some(SearchType::Posts),\n      ..Default::default()\n    }\n    .list(&mut context.pool(), &local_user_view, &site_view.site)\n    .await?\n    .iter()\n    // Filter map to collect posts\n    .filter_map(|f| f.to_post_view())\n    // Don't return this post as one of the cross_posts\n    .filter(|x| x.post.id != post_id)\n    .cloned()\n    .collect::<Vec<PostView>>()\n  } else {\n    Vec::new()\n  };\n\n  // Return the jwt\n  Ok(Json(GetPostResponse {\n    post_view,\n    community_view,\n    cross_posts,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/remove.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  notify::notify_mod_action,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_community_mod_action,\n};\nuse lemmy_db_schema::{\n  source::{\n    comment::Comment,\n    comment_report::CommentReport,\n    community::Community,\n    local_user::LocalUser,\n    modlog::{Modlog, ModlogInsertForm},\n    post::{Post, PostUpdateForm},\n    post_report::PostReport,\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::api::{PostResponse, RemovePost};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn remove_post(\n  Json(data): Json<RemovePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let post_id = data.post_id;\n  let remove_post = data.remove_children.unwrap_or(data.removed);\n\n  // We cannot use PostView to avoid a database read here, as it doesn't return removed items\n  // by default. So we would have to pass in `is_mod_or_admin`, but that is impossible without\n  // knowing which community the post belongs to.\n  let orig_post = Post::read(&mut context.pool(), post_id).await?;\n  let community = Community::read(&mut context.pool(), orig_post.community_id).await?;\n\n  check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;\n\n  LocalUser::is_higher_mod_or_admin_check(\n    &mut context.pool(),\n    orig_post.community_id,\n    local_user_view.person.id,\n    vec![orig_post.creator_id],\n  )\n  .await?;\n\n  // Update the post\n  let post = Post::update(\n    &mut context.pool(),\n    post_id,\n    &PostUpdateForm {\n      removed: Some(remove_post),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  PostReport::resolve_all_for_object(&mut context.pool(), post_id, local_user_view.person.id)\n    .await?;\n\n  // Mod tables\n  let form = ModlogInsertForm::mod_remove_post(\n    local_user_view.person.id,\n    &post,\n    remove_post,\n    &data.reason,\n    None,\n  );\n  let action = Modlog::create(&mut context.pool(), &[form]).await?;\n  notify_mod_action(action, context.app_data());\n\n  if let Some(remove_children) = data.remove_children {\n    let updated_comments: Vec<Comment> =\n      Comment::update_removed_for_post(&mut context.pool(), post_id, remove_children).await?;\n\n    let forms: Vec<_> = updated_comments\n      .iter()\n      // Filter out deleted comments here so their content doesn't show up in the modlog.\n      .filter(|c| !c.deleted)\n      .map(|comment| {\n        ModlogInsertForm::mod_remove_comment(\n          local_user_view.person.id,\n          comment,\n          community.id,\n          remove_children,\n          &data.reason,\n          None,\n        )\n      })\n      .collect();\n\n    let actions = Modlog::create(&mut context.pool(), &forms).await?;\n    notify_mod_action(actions, &context);\n\n    CommentReport::resolve_all_for_post(&mut context.pool(), post.id, local_user_view.person.id)\n      .await?;\n  }\n\n  ActivityChannel::submit_activity(\n    SendActivityData::RemovePost {\n      post,\n      moderator: local_user_view.person.clone(),\n      reason: data.reason.clone(),\n      removed: remove_post,\n      with_replies: data.remove_children.unwrap_or_default(),\n    },\n    &context,\n  )?;\n\n  build_post_response(&context, community.id, local_user_view, post_id).await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/post/update.rs",
    "content": "use super::convert_published_time;\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  build_response::build_post_response,\n  context::LemmyContext,\n  notify::NotifyData,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  request::generate_post_link_metadata,\n  send_activity::SendActivityData,\n  utils::{\n    check_community_user_action,\n    check_nsfw_allowed,\n    get_url_blocklist,\n    process_markdown_opt,\n    send_webmention,\n    slur_regex,\n    update_post_tags,\n  },\n};\nuse lemmy_db_schema::{\n  impls::actor_language::validate_post_language,\n  source::{\n    community::Community,\n    post::{Post, PostUpdateForm},\n  },\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{\n  PostView,\n  api::{EditPost, PostResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{\n  traits::Crud,\n  utils::{diesel_string_update, diesel_url_update},\n};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::{\n    slurs::check_slurs,\n    validation::{\n      is_url_blocked,\n      is_valid_alt_text_field,\n      is_valid_body_field,\n      is_valid_post_title,\n      is_valid_url,\n    },\n  },\n};\nuse std::ops::Deref;\n\npub async fn edit_post(\n  Json(data): Json<EditPost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let local_instance_id = local_user_view.person.instance_id;\n  let url = diesel_url_update(data.url.as_deref())?;\n\n  let custom_thumbnail = diesel_url_update(data.custom_thumbnail.as_deref())?;\n\n  let url_blocklist = get_url_blocklist(&context).await?;\n\n  let slur_regex = slur_regex(&context).await?;\n\n  let body = diesel_string_update(\n    process_markdown_opt(\n      &data.body,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?\n    .as_deref(),\n  );\n\n  check_nsfw_allowed(data.nsfw, Some(&local_site))?;\n\n  let alt_text = diesel_string_update(data.alt_text.as_deref());\n\n  if let Some(name) = &data.name {\n    is_valid_post_title(name)?;\n    check_slurs(name, &slur_regex)?;\n  }\n\n  if let Some(Some(body)) = &body {\n    is_valid_body_field(body, true)?;\n  }\n\n  if let Some(Some(alt_text)) = &alt_text {\n    is_valid_alt_text_field(alt_text)?;\n  }\n\n  if let Some(Some(url)) = &url {\n    is_url_blocked(url, &url_blocklist)?;\n    is_valid_url(url)?;\n  }\n\n  if let Some(Some(custom_thumbnail)) = &custom_thumbnail {\n    is_valid_url(custom_thumbnail)?;\n  }\n\n  let post_id = data.post_id;\n  let orig_post = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user_view.local_user),\n    local_instance_id,\n    false,\n  )\n  .await?;\n\n  let nsfw = if orig_post.community.nsfw {\n    Some(true)\n  } else {\n    data.nsfw\n  };\n\n  check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?;\n\n  // Verify that only the creator can edit\n  if !Post::is_post_creator(local_user_view.person.id, orig_post.post.creator_id) {\n    return Err(LemmyErrorType::NoPostEditAllowed.into());\n  }\n\n  // handle changes to scheduled_publish_time\n  let scheduled_publish_time_at = match (\n    orig_post.post.scheduled_publish_time_at,\n    data.scheduled_publish_time_at,\n  ) {\n    // schedule time can be changed if post is still scheduled (and not published yet)\n    (Some(_), Some(_)) => Some(\n      convert_published_time(data.scheduled_publish_time_at, &local_user_view, &context).await?,\n    ),\n    // post was scheduled, gets changed to publish immediately\n    (Some(_), None) => Some(None),\n    // unchanged\n    (_, _) => None,\n  };\n\n  let mut post_form = PostUpdateForm {\n    name: data.name.clone(),\n    url,\n    body,\n    alt_text,\n    nsfw,\n    language_id: data.language_id,\n    updated_at: Some(Some(Utc::now())),\n    scheduled_publish_time_at,\n    ..Default::default()\n  };\n  post_form = plugin_hook_before(\"local_post_before_update\", post_form).await?;\n  validate_post_language(\n    &mut context.pool(),\n    post_form.language_id,\n    orig_post.post.community_id,\n  )\n  .await?;\n\n  let post_id = data.post_id;\n  let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?;\n  plugin_hook_after(\"local_post_after_update\", &post_form);\n\n  if let Some(tags) = &data.tags {\n    update_post_tags(&orig_post.post, tags, &context).await?;\n  }\n\n  NotifyData::new(\n    updated_post.clone(),\n    local_user_view.person.clone(),\n    orig_post.community.clone(),\n  )\n  .send(&context);\n\n  // send out federation/webmention if necessary\n  match (\n    orig_post.post.scheduled_publish_time_at,\n    data.scheduled_publish_time_at,\n  ) {\n    // schedule was removed, send create activity and webmention\n    (Some(_), None) => {\n      let community = Community::read(&mut context.pool(), orig_post.community.id).await?;\n      send_webmention(updated_post.clone(), &community);\n      generate_post_link_metadata(\n        updated_post.clone(),\n        custom_thumbnail.flatten().map(Into::into),\n        |post| Some(SendActivityData::CreatePost(post)),\n        context.clone(),\n      )\n      .await?;\n    }\n    // post was already public, send update\n    (None, _) => {\n      generate_post_link_metadata(\n        updated_post.clone(),\n        custom_thumbnail.flatten().map(Into::into),\n        |post| Some(SendActivityData::UpdatePost(post)),\n        context.clone(),\n      )\n      .await?\n    }\n    // schedule was changed, do nothing\n    (Some(_), Some(_)) => {}\n  };\n\n  build_post_response(\n    context.deref(),\n    orig_post.community.id,\n    local_user_view,\n    post_id,\n  )\n  .await\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/private_message/create.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_private_message,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{\n    check_local_user_valid,\n    check_private_messages_enabled,\n    get_url_blocklist,\n    process_markdown,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    person::PersonActions,\n    private_message::{PrivateMessage, PrivateMessageInsertForm},\n  },\n  traits::Blockable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_private_message::{\n  PrivateMessageView,\n  api::{CreatePrivateMessage, PrivateMessageResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{error::LemmyResult, utils::validation::is_valid_body_field};\n\npub async fn create_private_message(\n  Json(data): Json<CreatePrivateMessage>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PrivateMessageResponse>> {\n  check_local_user_valid(&local_user_view)?;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let content = process_markdown(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  is_valid_body_field(&content, false)?;\n\n  PersonActions::read_block(\n    &mut context.pool(),\n    data.recipient_id,\n    local_user_view.person.id,\n  )\n  .await?;\n\n  check_private_messages_enabled(&local_user_view)?;\n\n  // Don't allow local sends to people who have private messages disabled\n  let recipient_local_user_opt = LocalUserView::read_person(&mut context.pool(), data.recipient_id)\n    .await\n    .ok();\n  if let Some(recipient_local_user) = recipient_local_user_opt {\n    check_private_messages_enabled(&recipient_local_user)?;\n  }\n\n  let mut form = PrivateMessageInsertForm::new(\n    local_user_view.person.id,\n    data.recipient_id,\n    content.clone(),\n  );\n\n  form = plugin_hook_before(\"local_private_message_before_create\", form).await?;\n  let inserted_private_message = PrivateMessage::create(&mut context.pool(), &form).await?;\n  plugin_hook_after(\n    \"local_private_message_after_create\",\n    &inserted_private_message,\n  );\n\n  let view = PrivateMessageView::read(\n    &mut context.pool(),\n    inserted_private_message.id,\n    Some(&local_user_view.person),\n  )\n  .await?;\n\n  notify_private_message(&view, true, &context);\n\n  ActivityChannel::submit_activity(\n    SendActivityData::CreatePrivateMessage(view.clone()),\n    &context,\n  )?;\n\n  Ok(Json(PrivateMessageResponse {\n    private_message_view: view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/private_message/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::check_local_user_valid,\n};\nuse lemmy_db_schema::source::private_message::{PrivateMessage, PrivateMessageUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_private_message::{\n  PrivateMessageView,\n  api::{DeletePrivateMessage, PrivateMessageResponse},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn delete_private_message(\n  Json(data): Json<DeletePrivateMessage>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PrivateMessageResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  // Checking permissions\n  let private_message_id = data.private_message_id;\n  let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?;\n\n  let deleted = data.deleted;\n  let form = if local_user_view.person.id == orig_private_message.recipient_id {\n    PrivateMessageUpdateForm {\n      deleted_by_recipient: Some(deleted),\n      ..Default::default()\n    }\n  } else if local_user_view.person.id == orig_private_message.creator_id {\n    PrivateMessageUpdateForm {\n      deleted: Some(deleted),\n      ..Default::default()\n    }\n  } else {\n    return Err(LemmyErrorType::EditPrivateMessageNotAllowed.into());\n  };\n\n  // Doing the update\n  let private_message =\n    PrivateMessage::update(&mut context.pool(), private_message_id, &form).await?;\n\n  let view = PrivateMessageView::read(\n    &mut context.pool(),\n    private_message_id,\n    Some(&local_user_view.person),\n  )\n  .await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::DeletePrivateMessage(local_user_view.person, private_message, data.deleted),\n    &context,\n  )?;\n\n  Ok(Json(PrivateMessageResponse {\n    private_message_view: view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/private_message/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/private_message/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_private_message,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::{check_local_user_valid, get_url_blocklist, process_markdown, slur_regex},\n};\nuse lemmy_db_schema::source::private_message::{PrivateMessage, PrivateMessageUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_private_message::{\n  PrivateMessageView,\n  api::{EditPrivateMessage, PrivateMessageResponse},\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::is_valid_body_field,\n};\n\npub async fn edit_private_message(\n  Json(data): Json<EditPrivateMessage>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PrivateMessageResponse>> {\n  check_local_user_valid(&local_user_view)?;\n  // Checking permissions\n  let private_message_id = data.private_message_id;\n  let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?;\n  if local_user_view.person.id != orig_private_message.creator_id {\n    return Err(LemmyErrorType::EditPrivateMessageNotAllowed.into());\n  }\n\n  // Doing the update\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let content = process_markdown(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n  is_valid_body_field(&content, false)?;\n\n  let private_message_id = data.private_message_id;\n  let mut form = PrivateMessageUpdateForm {\n    content: Some(content),\n    updated_at: Some(Some(Utc::now())),\n    ..Default::default()\n  };\n  form = plugin_hook_before(\"local_private_message_before_update\", form).await?;\n  let private_message =\n    PrivateMessage::update(&mut context.pool(), private_message_id, &form).await?;\n  plugin_hook_after(\"local_private_message_after_update\", &private_message);\n\n  let view = PrivateMessageView::read(\n    &mut context.pool(),\n    private_message_id,\n    Some(&local_user_view.person),\n  )\n  .await?;\n\n  notify_private_message(&view, false, &context);\n\n  ActivityChannel::submit_activity(\n    SendActivityData::UpdatePrivateMessage(view.clone()),\n    &context,\n  )?;\n\n  Ok(Json(PrivateMessageResponse {\n    private_message_view: view,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/site/create.rs",
    "content": "use super::not_zero;\nuse crate::site::{application_question_check, site_default_post_listing_type_check};\nuse activitypub_federation::{config::Data, http_signatures::generate_actor_keypair};\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{\n    generate_inbox_url,\n    get_url_blocklist,\n    is_admin,\n    local_site_rate_limit_to_rate_limit_config,\n    process_markdown_opt,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  newtypes::MultiCommunityId,\n  source::{\n    local_site::{LocalSite, LocalSiteUpdateForm},\n    local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitUpdateForm},\n    site::{Site, SiteUpdateForm},\n  },\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{CreateSite, SiteResponse},\n};\nuse lemmy_diesel_utils::{\n  dburl::DbUrl,\n  traits::Crud,\n  utils::{diesel_opt_number_update, diesel_string_update},\n};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::{\n    slurs::check_slurs,\n    validation::{\n      build_and_check_regex,\n      is_valid_body_field,\n      site_name_length_check,\n      summary_length_check,\n    },\n  },\n};\nuse url::Url;\n\npub async fn create_site(\n  Json(data): Json<CreateSite>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SiteResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  // Make sure user is an admin; other types of users should not create site data...\n  is_admin(&local_user_view)?;\n\n  validate_create_payload(&local_site, &data)?;\n\n  let ap_id: DbUrl = Url::parse(&context.settings().get_protocol_and_hostname())?.into();\n  let inbox_url = Some(generate_inbox_url()?);\n  let keypair = generate_actor_keypair()?;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let sidebar = process_markdown_opt(\n    &data.sidebar,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n\n  let suggested_multi_community_id =\n    diesel_opt_number_update(data.suggested_multi_community_id.map(|id| id.0))\n      .map(|id| id.map(MultiCommunityId));\n\n  let site_form = SiteUpdateForm {\n    name: Some(data.name.clone()),\n    sidebar: diesel_string_update(sidebar.as_deref()),\n    summary: diesel_string_update(data.summary.as_deref()),\n    ap_id: Some(ap_id),\n    last_refreshed_at: Some(Utc::now()),\n    inbox_url,\n    private_key: Some(Some(keypair.private_key)),\n    public_key: Some(keypair.public_key),\n    content_warning: diesel_string_update(data.content_warning.as_deref()),\n    ..Default::default()\n  };\n\n  let site_id = local_site.site_id;\n\n  Site::update(&mut context.pool(), site_id, &site_form).await?;\n\n  let local_site_form = LocalSiteUpdateForm {\n    // Set the site setup to true\n    site_setup: Some(true),\n    registration_mode: data.registration_mode,\n    community_creation_admin_only: data.community_creation_admin_only,\n    require_email_verification: data.require_email_verification,\n    application_question: diesel_string_update(data.application_question.as_deref()),\n    private_instance: data.private_instance,\n    default_theme: data.default_theme.clone(),\n    default_post_listing_type: data.default_post_listing_type,\n    default_post_sort_type: data.default_post_sort_type,\n    default_post_time_range_seconds: diesel_opt_number_update(data.default_post_time_range_seconds),\n    default_comment_sort_type: data.default_comment_sort_type,\n    reports_email_admins: data.reports_email_admins,\n    legal_information: diesel_string_update(data.legal_information.as_deref()),\n    application_email_admins: data.application_email_admins,\n    updated_at: Some(Some(Utc::now())),\n    slur_filter_regex: diesel_string_update(data.slur_filter_regex.as_deref()),\n    federation_enabled: data.federation_enabled,\n    default_post_listing_mode: data.default_post_listing_mode,\n    post_upvotes: data.post_upvotes,\n    post_downvotes: data.post_downvotes,\n    comment_upvotes: data.comment_upvotes,\n    comment_downvotes: data.comment_downvotes,\n    disallow_nsfw_content: data.disallow_nsfw_content,\n    disable_email_notifications: data.disable_email_notifications,\n    suggested_multi_community_id,\n    federation_signed_fetch: data.federation_signed_fetch,\n    oauth_registration: data.oauth_registration,\n    default_items_per_page: data.default_items_per_page,\n    image_mode: data.image_mode,\n    image_proxy_bypass_domains: diesel_string_update(data.image_proxy_bypass_domains.as_deref()),\n    image_upload_timeout_seconds: data.image_upload_timeout_seconds,\n    image_max_thumbnail_size: data.image_max_thumbnail_size,\n    image_max_avatar_size: data.image_max_avatar_size,\n    image_max_banner_size: data.image_max_banner_size,\n    image_max_upload_size: data.image_max_upload_size,\n    image_allow_video_uploads: data.image_allow_video_uploads,\n    image_upload_disabled: data.image_upload_disabled,\n  };\n\n  LocalSite::update(&mut context.pool(), &local_site_form).await?;\n\n  let local_site_rate_limit_form = LocalSiteRateLimitUpdateForm {\n    message_max_requests: data.rate_limit_message_max_requests,\n    message_interval_seconds: not_zero(data.rate_limit_message_interval_seconds),\n    post_max_requests: data.rate_limit_post_max_requests,\n    post_interval_seconds: not_zero(data.rate_limit_post_interval_seconds),\n    register_max_requests: data.rate_limit_register_max_requests,\n    register_interval_seconds: not_zero(data.rate_limit_register_interval_seconds),\n    image_max_requests: data.rate_limit_image_max_requests,\n    image_interval_seconds: not_zero(data.rate_limit_image_interval_seconds),\n    comment_max_requests: data.rate_limit_comment_max_requests,\n    comment_interval_seconds: not_zero(data.rate_limit_comment_interval_seconds),\n    search_max_requests: data.rate_limit_search_max_requests,\n    search_interval_seconds: not_zero(data.rate_limit_search_interval_seconds),\n    import_user_settings_max_requests: data.rate_limit_import_user_settings_max_requests,\n    import_user_settings_interval_seconds: not_zero(\n      data.rate_limit_import_user_settings_interval_seconds,\n    ),\n    updated_at: Some(Some(Utc::now())),\n  };\n\n  LocalSiteRateLimit::update(&mut context.pool(), &local_site_rate_limit_form).await?;\n\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n  let rate_limit_config =\n    local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit);\n  context.rate_limit_cell().set_config(rate_limit_config);\n\n  Ok(Json(SiteResponse { site_view }))\n}\n\nfn validate_create_payload(local_site: &LocalSite, create_site: &CreateSite) -> LemmyResult<()> {\n  // Make sure the site hasn't already been set up...\n  if local_site.site_setup {\n    return Err(LemmyErrorType::AlreadyExists.into());\n  };\n\n  // Check that the slur regex compiles, and returns the regex if valid...\n  // Prioritize using new slur regex from the request; if not provided, use the existing regex.\n  let slur_regex = build_and_check_regex(\n    create_site\n      .slur_filter_regex\n      .as_deref()\n      .or(local_site.slur_filter_regex.as_deref()),\n  )?;\n\n  site_name_length_check(&create_site.name)?;\n  check_slurs(&create_site.name, &slur_regex)?;\n\n  if let Some(desc) = &create_site.summary {\n    summary_length_check(desc)?;\n    check_slurs(desc, &slur_regex)?;\n  }\n\n  site_default_post_listing_type_check(&create_site.default_post_listing_type)?;\n\n  // Ensure that the sidebar has fewer than the max num characters...\n  if let Some(sidebar) = &create_site.sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  application_question_check(\n    &local_site.application_question,\n    &create_site.application_question,\n    create_site\n      .registration_mode\n      .unwrap_or(local_site.registration_mode),\n  )\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::site::create::validate_create_payload;\n  use lemmy_db_schema::source::local_site::LocalSite;\n  use lemmy_db_schema_file::enums::{ListingType, PostSortType, RegistrationMode};\n  use lemmy_db_views_site::api::CreateSite;\n  use lemmy_utils::error::LemmyErrorType;\n\n  #[test]\n  fn test_validate_invalid_create_payload() {\n    let invalid_payloads = [\n      (\n        \"CreateSite attempted on set up LocalSite\",\n        &LemmyErrorType::AlreadyExists,\n        &LocalSite {\n          site_setup: true,\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite name matches LocalSite slur filter\",\n        &LemmyErrorType::Slurs,\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"foo site_name\"),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite name matches new slur filter\",\n        &LemmyErrorType::Slurs,\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"zeta site_name\"),\n          slur_filter_regex: Some(String::from(\"(zeta|alpha)\")),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite listing type is Subscribed, which is invalid\",\n        &LemmyErrorType::InvalidDefaultPostListingType,\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          default_post_listing_type: Some(ListingType::Subscribed),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite requires application, but neither it nor LocalSite has an application question\",\n        &LemmyErrorType::ApplicationQuestionRequired,\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          registration_mode: Some(RegistrationMode::RequireApplication),\n          ..Default::default()\n        },\n      ),\n    ];\n\n    invalid_payloads.iter().enumerate().for_each(\n      |(\n         idx,\n         &(reason,  expected_err, local_site, create_site),\n       )| {\n        match validate_create_payload(\n          local_site,\n          create_site,\n        ) {\n          Ok(_) => {\n            panic!(\n              \"Got Ok, but validation should have failed with error: {} for reason: {}. invalid_payloads.nth({})\",\n              expected_err, reason, idx\n            )\n          }\n          Err(error) => {\n            assert!(\n              error.error_type.eq(&expected_err.clone()),\n              \"Got Err {:?}, but should have failed with message: {} for reason: {}. invalid_payloads.nth({})\",\n              error.error_type,\n              expected_err,\n              reason,\n              idx\n            )\n          }\n        }\n      },\n    );\n  }\n\n  #[test]\n  fn test_validate_valid_create_payload() {\n    let valid_payloads = [\n      (\n        \"No changes between LocalSite and CreateSite\",\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite allows clearing and changing values\",\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          sidebar: Some(String::new()),\n          summary: Some(String::new()),\n          application_question: Some(String::new()),\n          private_instance: Some(false),\n          default_post_listing_type: Some(ListingType::All),\n          default_post_sort_type: Some(PostSortType::Active),\n          slur_filter_regex: Some(String::new()),\n          federation_enabled: Some(true),\n          registration_mode: Some(RegistrationMode::Open),\n          ..Default::default()\n        },\n      ),\n      (\n        \"CreateSite clears existing slur filter regex\",\n        &LocalSite {\n          site_setup: false,\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"foo site_name\"),\n          slur_filter_regex: Some(String::new()),\n          ..Default::default()\n        },\n      ),\n      (\n        \"LocalSite has application question and CreateSite now requires applications,\",\n        &LocalSite {\n          site_setup: false,\n          application_question: Some(String::from(\"question\")),\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &CreateSite {\n          name: String::from(\"site_name\"),\n          registration_mode: Some(RegistrationMode::RequireApplication),\n          ..Default::default()\n        },\n      ),\n    ];\n\n    valid_payloads\n      .iter()\n      .enumerate()\n      .for_each(|(idx, &(reason, local_site, edit_site))| {\n        assert!(\n          validate_create_payload(local_site, edit_site).is_ok(),\n          \"Got Err, but should have got Ok for reason: {}. valid_payloads.nth({})\",\n          reason,\n          idx\n        );\n      })\n  }\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/site/mod.rs",
    "content": "use lemmy_db_schema_file::enums::{ListingType, RegistrationMode};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub mod create;\npub mod read;\npub mod update;\n\n/// Checks whether the default post listing type is valid for a site.\npub fn site_default_post_listing_type_check(\n  default_post_listing_type: &Option<ListingType>,\n) -> LemmyResult<()> {\n  if let Some(listing_type) = default_post_listing_type {\n    // Dont allow Subscribed or ModeratorView as default listing type\n    if [ListingType::Subscribed, ListingType::ModeratorView].contains(listing_type) {\n      Err(LemmyErrorType::InvalidDefaultPostListingType.into())\n    } else {\n      Ok(())\n    }\n  } else {\n    Ok(())\n  }\n}\n\n/// Checks whether the application question and registration mode align.\npub fn application_question_check(\n  current_application_question: &Option<String>,\n  new_application_question: &Option<String>,\n  registration_mode: RegistrationMode,\n) -> LemmyResult<()> {\n  let has_no_question: bool =\n    current_application_question.is_none() && new_application_question.is_none();\n  let is_nullifying_question: bool = new_application_question == &Some(String::new());\n\n  if registration_mode == RegistrationMode::RequireApplication\n    && (has_no_question || is_nullifying_question)\n  {\n    Err(LemmyErrorType::ApplicationQuestionRequired.into())\n  } else {\n    Ok(())\n  }\n}\n\nfn not_zero(val: Option<i32>) -> Option<i32> {\n  match val {\n    Some(0) => None,\n    v => v,\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::site::{application_question_check, not_zero, site_default_post_listing_type_check};\n  use lemmy_db_schema_file::enums::{ListingType, RegistrationMode};\n\n  #[test]\n  fn test_site_default_post_listing_type_check() {\n    assert!(site_default_post_listing_type_check(&None::<ListingType>).is_ok());\n    assert!(site_default_post_listing_type_check(&Some(ListingType::All)).is_ok());\n    assert!(site_default_post_listing_type_check(&Some(ListingType::Local)).is_ok());\n    assert!(site_default_post_listing_type_check(&Some(ListingType::Subscribed)).is_err());\n  }\n\n  #[test]\n  fn test_application_question_check() {\n    assert!(\n      application_question_check(\n        &Some(String::from(\"q\")),\n        &Some(String::new()),\n        RegistrationMode::RequireApplication\n      )\n      .is_err(),\n      \"Expected application to be invalid because an application is required, current question: {:?}, new question: {:?}\",\n      \"q\",\n      String::new(),\n    );\n    assert!(\n      application_question_check(&None, &None, RegistrationMode::RequireApplication).is_err(),\n      \"Expected application to be invalid because an application is required, current question: {:?}, new question: {:?}\",\n      None::<String>,\n      None::<String>\n    );\n\n    assert!(\n      application_question_check(&None, &None, RegistrationMode::Open).is_ok(),\n      \"Expected application to be valid because no application required, current question: {:?}, new question: {:?}, mode: {:?}\",\n      None::<String>,\n      None::<String>,\n      RegistrationMode::Open\n    );\n    assert!(\n      application_question_check(\n        &None,\n        &Some(String::from(\"q\")),\n        RegistrationMode::RequireApplication\n      )\n      .is_ok(),\n      \"Expected application to be valid because new application provided, current question: {:?}, new question: {:?}, mode: {:?}\",\n      None::<String>,\n      Some(String::from(\"q\")),\n      RegistrationMode::RequireApplication\n    );\n    assert!(\n      application_question_check(\n        &Some(String::from(\"q\")),\n        &None,\n        RegistrationMode::RequireApplication\n      )\n      .is_ok(),\n      \"Expected application to be valid because application existed, current question: {:?}, new question: {:?}, mode: {:?}\",\n      Some(String::from(\"q\")),\n      None::<String>,\n      RegistrationMode::RequireApplication\n    );\n  }\n\n  #[test]\n  fn test_not_zero() {\n    assert_eq!(None, not_zero(None));\n    assert_eq!(None, not_zero(Some(0)));\n    assert_eq!(Some(5), not_zero(Some(5)));\n  }\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/site/read.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::{is_captcha_plugin_loaded, plugin_metadata},\n};\nuse lemmy_db_schema::source::{\n  actor_language::SiteLanguage,\n  language::Language,\n  local_site_url_blocklist::LocalSiteUrlBlocklist,\n  oauth_provider::AdminOAuthProvider,\n  registration_application::RegistrationApplication,\n  tagline::Tagline,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::PersonView;\nuse lemmy_db_views_site::{SiteView, api::GetSiteResponse};\nuse lemmy_utils::{CacheLock, VERSION, build_cache, error::LemmyResult};\nuse std::sync::LazyLock;\n\npub async fn get_site(\n  local_user_view: Option<LocalUserView>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<GetSiteResponse>> {\n  // This data is independent from the user account so we can cache it across requests\n  static CACHE: CacheLock<GetSiteResponse> = LazyLock::new(build_cache);\n  let mut site_response = Box::pin(CACHE.try_get_with((), read_site(&context)))\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to construct site response: {e}\"))?;\n\n  // filter oauth_providers for public access\n  if !local_user_view\n    .map(|l| l.local_user.admin)\n    .unwrap_or_default()\n  {\n    site_response.admin_oauth_providers = vec![];\n  }\n\n  Ok(Json(site_response))\n}\n\nasync fn read_site(context: &LemmyContext) -> LemmyResult<GetSiteResponse> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let admins = PersonView::list_admins(None, site_view.instance.id, &mut context.pool()).await?;\n  let all_languages = Language::read_all(&mut context.pool()).await?;\n  let discussion_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?;\n  let blocked_urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?;\n  let tagline = Tagline::get_random(&mut context.pool()).await.ok();\n  let admin_oauth_providers = AdminOAuthProvider::get_all(&mut context.pool()).await?;\n  let oauth_providers =\n    AdminOAuthProvider::convert_providers_to_public(admin_oauth_providers.clone());\n  let last_application_duration_seconds =\n    RegistrationApplication::last_updated(&mut context.pool())\n      .await\n      .ok()\n      .and_then(|u| u.updated_published_duration());\n\n  Ok(GetSiteResponse {\n    site_view,\n    admins,\n    version: VERSION.to_string(),\n    all_languages,\n    discussion_languages,\n    blocked_urls,\n    tagline,\n    oauth_providers,\n    admin_oauth_providers,\n    active_plugins: plugin_metadata(),\n    last_application_duration_seconds,\n    captcha_enabled: is_captcha_plugin_loaded(),\n  })\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/site/update.rs",
    "content": "use super::not_zero;\nuse crate::site::{application_question_check, site_default_post_listing_type_check};\nuse activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{\n    get_url_blocklist,\n    is_admin,\n    local_site_rate_limit_to_rate_limit_config,\n    process_markdown_opt,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  newtypes::MultiCommunityId,\n  source::{\n    actor_language::SiteLanguage,\n    local_site::{LocalSite, LocalSiteUpdateForm},\n    local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitUpdateForm},\n    local_site_url_blocklist::LocalSiteUrlBlocklist,\n    local_user::LocalUser,\n    site::{Site, SiteUpdateForm},\n  },\n};\nuse lemmy_db_schema_file::enums::RegistrationMode;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{EditSite, SiteResponse},\n};\nuse lemmy_diesel_utils::{\n  traits::Crud,\n  utils::{diesel_opt_number_update, diesel_string_update},\n};\nuse lemmy_utils::{\n  error::LemmyResult,\n  utils::{\n    slurs::check_slurs_opt,\n    validation::{\n      build_and_check_regex,\n      check_urls_are_valid,\n      is_valid_body_field,\n      site_name_length_check,\n      summary_length_check,\n    },\n  },\n};\n\npub async fn edit_site(\n  Json(data): Json<EditSite>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SiteResponse>> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_site = site_view.local_site;\n  let site = site_view.site;\n\n  // Make sure user is an admin; other types of users should not update site data...\n  is_admin(&local_user_view)?;\n\n  validate_update_payload(&local_site, &data)?;\n\n  if let Some(discussion_languages) = data.discussion_languages.clone() {\n    SiteLanguage::update(&mut context.pool(), discussion_languages.clone(), &site).await?;\n  }\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let sidebar = diesel_string_update(\n    process_markdown_opt(\n      &data.sidebar,\n      &slur_regex,\n      &url_blocklist,\n      &local_site,\n      &context,\n    )\n    .await?\n    .as_deref(),\n  );\n  let default_post_time_range_seconds =\n    diesel_opt_number_update(data.default_post_time_range_seconds);\n  let default_items_per_page = data.default_items_per_page;\n\n  let suggested_multi_community_id =\n    diesel_opt_number_update(data.suggested_multi_community_id.map(|id| id.0))\n      .map(|id| id.map(MultiCommunityId));\n\n  let site_form = SiteUpdateForm {\n    name: data.name.clone(),\n    sidebar,\n    summary: diesel_string_update(data.summary.as_deref()),\n    content_warning: diesel_string_update(data.content_warning.as_deref()),\n    updated_at: Some(Some(Utc::now())),\n    ..Default::default()\n  };\n\n  Site::update(&mut context.pool(), site.id, &site_form)\n    .await\n    // Ignore errors for all these, so as to not throw errors if no update occurs\n    // Diesel will throw an error for empty update forms\n    .ok();\n\n  let local_site_form = LocalSiteUpdateForm {\n    site_setup: None,\n    federation_signed_fetch: data.federation_signed_fetch,\n    registration_mode: data.registration_mode,\n    community_creation_admin_only: data.community_creation_admin_only,\n    require_email_verification: data.require_email_verification,\n    application_question: diesel_string_update(data.application_question.as_deref()),\n    private_instance: data.private_instance,\n    default_theme: data.default_theme.clone(),\n    default_post_listing_type: data.default_post_listing_type,\n    default_post_sort_type: data.default_post_sort_type,\n    default_post_time_range_seconds,\n    default_items_per_page,\n    default_comment_sort_type: data.default_comment_sort_type,\n    legal_information: diesel_string_update(data.legal_information.as_deref()),\n    application_email_admins: data.application_email_admins,\n    updated_at: Some(Some(Utc::now())),\n    slur_filter_regex: diesel_string_update(data.slur_filter_regex.as_deref()),\n    federation_enabled: data.federation_enabled,\n    reports_email_admins: data.reports_email_admins,\n    default_post_listing_mode: data.default_post_listing_mode,\n    oauth_registration: data.oauth_registration,\n    post_upvotes: data.post_upvotes,\n    post_downvotes: data.post_downvotes,\n    comment_upvotes: data.comment_upvotes,\n    comment_downvotes: data.comment_downvotes,\n    disallow_nsfw_content: data.disallow_nsfw_content,\n    disable_email_notifications: data.disable_email_notifications,\n    suggested_multi_community_id,\n    image_mode: data.image_mode,\n    image_proxy_bypass_domains: diesel_string_update(data.image_proxy_bypass_domains.as_deref()),\n    image_upload_timeout_seconds: data.image_upload_timeout_seconds,\n    image_max_thumbnail_size: data.image_max_thumbnail_size,\n    image_max_avatar_size: data.image_max_avatar_size,\n    image_max_banner_size: data.image_max_banner_size,\n    image_max_upload_size: data.image_max_upload_size,\n    image_allow_video_uploads: data.image_allow_video_uploads,\n    image_upload_disabled: data.image_upload_disabled,\n  };\n\n  let update_local_site = LocalSite::update(&mut context.pool(), &local_site_form)\n    .await\n    .ok();\n\n  let local_site_rate_limit_form = LocalSiteRateLimitUpdateForm {\n    message_max_requests: data.rate_limit_message_max_requests,\n    message_interval_seconds: not_zero(data.rate_limit_message_interval_seconds),\n    post_max_requests: data.rate_limit_post_max_requests,\n    post_interval_seconds: not_zero(data.rate_limit_post_interval_seconds),\n    register_max_requests: data.rate_limit_register_max_requests,\n    register_interval_seconds: not_zero(data.rate_limit_register_interval_seconds),\n    image_max_requests: data.rate_limit_image_max_requests,\n    image_interval_seconds: not_zero(data.rate_limit_image_interval_seconds),\n    comment_max_requests: data.rate_limit_comment_max_requests,\n    comment_interval_seconds: not_zero(data.rate_limit_comment_interval_seconds),\n    search_max_requests: data.rate_limit_search_max_requests,\n    search_interval_seconds: not_zero(data.rate_limit_search_interval_seconds),\n    import_user_settings_max_requests: data.rate_limit_import_user_settings_max_requests,\n    import_user_settings_interval_seconds: not_zero(\n      data.rate_limit_import_user_settings_interval_seconds,\n    ),\n    updated_at: Some(Some(Utc::now())),\n  };\n\n  LocalSiteRateLimit::update(&mut context.pool(), &local_site_rate_limit_form)\n    .await\n    .ok();\n\n  if let Some(url_blocklist) = data.blocked_urls.clone() {\n    // If this validation changes it must be synced with\n    // lemmy_utils::utils::markdown::create_url_blocklist_test_regex_set.\n    let parsed_urls = check_urls_are_valid(&url_blocklist)?;\n    LocalSiteUrlBlocklist::replace(&mut context.pool(), parsed_urls).await?;\n  }\n\n  // TODO can't think of a better way to do this.\n  // If the server suddenly requires email verification, or required applications, no old users\n  // will be able to log in. It really only wants this to be a requirement for NEW signups.\n  // So if it was set from false, to true, you need to update all current users columns to be\n  // verified.\n\n  let old_require_application =\n    local_site.registration_mode == RegistrationMode::RequireApplication;\n  let new_require_application = update_local_site\n    .as_ref()\n    .map(|ols| ols.registration_mode == RegistrationMode::RequireApplication)\n    .unwrap_or(false);\n  if !old_require_application && new_require_application {\n    LocalUser::set_all_users_registration_applications_accepted(&mut context.pool()).await?;\n  }\n\n  let new_require_email_verification = update_local_site\n    .as_ref()\n    .map(|ols| ols.require_email_verification)\n    .unwrap_or(false);\n  if !local_site.require_email_verification && new_require_email_verification {\n    LocalUser::set_all_users_email_verified(&mut context.pool()).await?;\n  }\n\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n  let rate_limit_config =\n    local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit);\n  context.rate_limit_cell().set_config(rate_limit_config);\n\n  Ok(Json(SiteResponse { site_view }))\n}\n\nfn validate_update_payload(local_site: &LocalSite, edit_site: &EditSite) -> LemmyResult<()> {\n  // Check that the slur regex compiles, and return the regex if valid...\n  // Prioritize using new slur regex from the request; if not provided, use the existing regex.\n  let slur_regex = build_and_check_regex(\n    edit_site\n      .slur_filter_regex\n      .as_deref()\n      .or(local_site.slur_filter_regex.as_deref()),\n  )?;\n\n  if let Some(name) = &edit_site.name {\n    // The name doesn't need to be updated, but if provided it cannot be blanked out...\n    site_name_length_check(name)?;\n    check_slurs_opt(&edit_site.name, &slur_regex)?;\n  }\n\n  if let Some(summary) = &edit_site.summary {\n    summary_length_check(summary)?;\n    check_slurs_opt(&edit_site.summary, &slur_regex)?;\n  }\n\n  site_default_post_listing_type_check(&edit_site.default_post_listing_type)?;\n\n  // Ensure that the sidebar has fewer than the max num characters...\n  if let Some(sidebar) = &edit_site.sidebar {\n    is_valid_body_field(sidebar, false)?;\n  }\n\n  application_question_check(\n    &local_site.application_question,\n    &edit_site.application_question,\n    edit_site\n      .registration_mode\n      .unwrap_or(local_site.registration_mode),\n  )\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::site::update::validate_update_payload;\n  use lemmy_db_schema::source::local_site::LocalSite;\n  use lemmy_db_schema_file::enums::{ListingType, PostSortType, RegistrationMode};\n  use lemmy_db_views_site::api::EditSite;\n  use lemmy_utils::error::LemmyErrorType;\n\n  #[test]\n  fn test_validate_invalid_update_payload() {\n    let invalid_payloads = [\n      (\n        \"EditSite name matches LocalSite slur filter\",\n        &LemmyErrorType::Slurs,\n        &LocalSite {\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"foo site_name\")),\n          ..Default::default()\n        },\n      ),\n      (\n        \"EditSite name matches new slur filter\",\n        &LemmyErrorType::Slurs,\n        &LocalSite {\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"zeta site_name\")),\n          slur_filter_regex: Some(String::from(\"(zeta|alpha)\")),\n          ..Default::default()\n        },\n      ),\n      (\n        \"EditSite listing type is Subscribed, which is invalid\",\n        &LemmyErrorType::InvalidDefaultPostListingType,\n        &LocalSite {\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"site_name\")),\n          default_post_listing_type: Some(ListingType::Subscribed),\n          ..Default::default()\n        },\n      ),\n      (\n        \"EditSite requires application, but neither it nor LocalSite has an application question\",\n        &LemmyErrorType::ApplicationQuestionRequired,\n        &LocalSite {\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"site_name\")),\n          registration_mode: Some(RegistrationMode::RequireApplication),\n          ..Default::default()\n        },\n      ),\n    ];\n\n    invalid_payloads.iter().enumerate().for_each(\n      |(\n         idx,\n         &(reason,  expected_err, local_site, edit_site),\n       )| {\n        match validate_update_payload(local_site, edit_site) {\n          Ok(_) => {\n            panic!(\n              \"Got Ok, but validation should have failed with error: {} for reason: {}. invalid_payloads.nth({})\",\n              expected_err, reason, idx\n            )\n          }\n          Err(error) => {\n            assert!(\n              error.error_type.eq(&expected_err.clone()),\n              \"Got Err {:?}, but should have failed with message: {} for reason: {}. invalid_payloads.nth({})\",\n              error.error_type,\n              expected_err,\n              reason,\n              idx\n            )\n          }\n        }\n      },\n    );\n  }\n\n  #[test]\n  fn test_validate_valid_update_payload() {\n    let valid_payloads = [\n      (\n        \"No changes between LocalSite and EditSite\",\n        &LocalSite {\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite::default(),\n      ),\n      (\n        \"EditSite allows clearing and changing values\",\n        &LocalSite {\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"site_name\")),\n          sidebar: Some(String::new()),\n          summary: Some(String::new()),\n          application_question: Some(String::new()),\n          private_instance: Some(false),\n          default_post_listing_type: Some(ListingType::All),\n          default_post_sort_type: Some(PostSortType::Active),\n          slur_filter_regex: Some(String::new()),\n          registration_mode: Some(RegistrationMode::Open),\n          federation_enabled: Some(true),\n          ..Default::default()\n        },\n      ),\n      (\n        \"EditSite name passes slur filter regex\",\n        &LocalSite {\n          private_instance: true,\n          slur_filter_regex: Some(String::from(\"(foo|bar)\")),\n          registration_mode: RegistrationMode::Open,\n          federation_enabled: false,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"foo site_name\")),\n          slur_filter_regex: Some(String::new()),\n          ..Default::default()\n        },\n      ),\n      (\n        \"LocalSite has application question and EditSite now requires applications,\",\n        &LocalSite {\n          application_question: Some(String::from(\"question\")),\n          private_instance: true,\n          federation_enabled: false,\n          registration_mode: RegistrationMode::Open,\n          ..Default::default()\n        },\n        &EditSite {\n          name: Some(String::from(\"site_name\")),\n          registration_mode: Some(RegistrationMode::RequireApplication),\n          ..Default::default()\n        },\n      ),\n    ];\n\n    valid_payloads\n      .iter()\n      .enumerate()\n      .for_each(|(idx, &(reason, local_site, edit_site))| {\n        assert!(\n          validate_update_payload(local_site, edit_site).is_ok(),\n          \"Got Err, but should have got Ok for reason: {}. valid_payloads.nth({})\",\n          reason,\n          idx\n        );\n      })\n  }\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/tagline/create.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{get_url_blocklist, is_admin, process_markdown, slur_regex},\n};\nuse lemmy_db_schema::source::tagline::{Tagline, TaglineInsertForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{CreateTagline, TaglineResponse},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyError;\n\npub async fn create_tagline(\n  Json(data): Json<CreateTagline>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<TaglineResponse>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let content = process_markdown(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n\n  let tagline_form = TaglineInsertForm { content };\n\n  let tagline = Tagline::create(&mut context.pool(), &tagline_form).await?;\n\n  Ok(Json(TaglineResponse { tagline }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/tagline/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_db_schema::source::tagline::Tagline;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{DeleteTagline, SuccessResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyError;\n\npub async fn delete_tagline(\n  Json(data): Json<DeleteTagline>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<SuccessResponse>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  Tagline::delete(&mut context.pool(), data.id).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/tagline/list.rs",
    "content": "use actix_web::web::{Data, Json, Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::tagline::Tagline;\nuse lemmy_db_views_site::api::ListTaglines;\nuse lemmy_diesel_utils::pagination::PagedResponse;\nuse lemmy_utils::error::LemmyError;\n\npub async fn list_taglines(\n  Query(data): Query<ListTaglines>,\n  context: Data<LemmyContext>,\n) -> Result<Json<PagedResponse<Tagline>>, LemmyError> {\n  let taglines = Tagline::list(&mut context.pool(), data.page_cursor, data.limit).await?;\n\n  Ok(Json(taglines))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/tagline/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod list;\npub mod update;\n"
  },
  {
    "path": "crates/api/api_crud/src/tagline/update.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{get_url_blocklist, is_admin, process_markdown, slur_regex},\n};\nuse lemmy_db_schema::source::tagline::{Tagline, TaglineUpdateForm};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{EditTagline, TaglineResponse},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyError;\n\npub async fn edit_tagline(\n  Json(data): Json<EditTagline>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> Result<Json<TaglineResponse>, LemmyError> {\n  // Make sure user is an admin\n  is_admin(&local_user_view)?;\n\n  let slur_regex = slur_regex(&context).await?;\n  let url_blocklist = get_url_blocklist(&context).await?;\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  let content = process_markdown(\n    &data.content,\n    &slur_regex,\n    &url_blocklist,\n    &local_site,\n    &context,\n  )\n  .await?;\n\n  let tagline_form = TaglineUpdateForm {\n    content,\n    updated_at: Some(Some(Utc::now())),\n  };\n\n  let tagline = Tagline::update(&mut context.pool(), data.id, &tagline_form).await?;\n\n  Ok(Json(TaglineResponse { tagline }))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/user/create.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  http_signatures::generate_actor_keypair,\n};\nuse actix_web::{HttpRequest, rt::time::sleep, web::Json};\nuse diesel_async::{AsyncPgConnection, scoped_futures::ScopedFutureExt};\nuse lemmy_api_utils::{\n  claims::Claims,\n  context::LemmyContext,\n  plugins::{is_captcha_plugin_loaded, plugin_validate_captcha},\n  utils::{\n    check_email_verified,\n    check_local_user_valid,\n    check_registration_application,\n    generate_featured_url,\n    generate_followers_url,\n    generate_inbox_url,\n    generate_moderators_url,\n    honeypot_check,\n    password_length_check,\n    slur_regex,\n  },\n};\nuse lemmy_apub_objects::objects::community::ApubCommunity;\nuse lemmy_db_schema::{\n  newtypes::OAuthProviderId,\n  source::{\n    actor_language::SiteLanguage,\n    community::{Community, CommunityActions, CommunityInsertForm, CommunityModeratorForm},\n    language::Language,\n    local_site::LocalSite,\n    local_user::{LocalUser, LocalUserInsertForm},\n    oauth_account::{OAuthAccount, OAuthAccountInsertForm},\n    oauth_provider::AdminOAuthProvider,\n    person::{Person, PersonInsertForm},\n    post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},\n  },\n  traits::{ApubActor, Likeable},\n};\nuse lemmy_db_schema_file::enums::RegistrationMode;\nuse lemmy_db_views_community::CommunityView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::PersonView;\nuse lemmy_db_views_registration_applications::api::Register;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{AuthenticateWithOauth, LoginResponse},\n};\nuse lemmy_diesel_utils::{connection::get_conn, pagination::PagedResponse, traits::Crud};\nuse lemmy_email::{\n  account::send_verification_email_if_required,\n  admin::send_new_applicant_email_to_admins,\n  user_language,\n};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult},\n  spawn_try_task,\n  utils::{\n    slurs::{check_slurs, check_slurs_opt},\n    validation::is_valid_actor_name,\n  },\n};\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse std::{collections::HashSet, sync::LazyLock, time::Duration};\nuse tracing::info;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n/// Response from OAuth token endpoint\nstruct TokenResponse {\n  pub access_token: String,\n  pub token_type: String,\n  pub expires_in: Option<i64>,\n  pub refresh_token: Option<String>,\n  pub scope: Option<String>,\n}\n\npub async fn register(\n  Json(data): Json<Register>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<LoginResponse>> {\n  let pool = &mut context.pool();\n  let site_view = SiteView::read_local(pool).await?;\n  let local_site = site_view.local_site.clone();\n  let require_registration_application =\n    local_site.registration_mode == RegistrationMode::RequireApplication;\n\n  if local_site.registration_mode == RegistrationMode::Closed {\n    return Err(LemmyErrorType::RegistrationClosed.into());\n  }\n\n  password_length_check(&data.password)?;\n  honeypot_check(&data.honeypot)?;\n\n  if local_site.require_email_verification && data.email.is_none() {\n    return Err(LemmyErrorType::EmailRequired.into());\n  }\n\n  // make sure the registration answer is provided when the registration application is required\n  if local_site.site_setup {\n    validate_registration_answer(require_registration_application, &data.answer)?;\n  }\n\n  // Make sure passwords match\n  if data.password != data.password_verify {\n    return Err(LemmyErrorType::PasswordsDoNotMatch.into());\n  }\n\n  if local_site.site_setup && is_captcha_plugin_loaded() {\n    let answer = data.captcha_answer.clone().unwrap_or_default();\n    let uuid = data.captcha_uuid.clone().unwrap_or_default();\n    plugin_validate_captcha(answer, uuid).await?;\n  }\n\n  let slur_regex = slur_regex(&context).await?;\n  check_slurs(&data.username, &slur_regex)?;\n  check_slurs_opt(&data.answer, &slur_regex)?;\n\n  Person::check_username_taken(pool, &data.username).await?;\n\n  if let Some(email) = &data.email {\n    LocalUser::check_is_email_taken(pool, email).await?;\n  }\n\n  // Automatically set their application as accepted, if they created this with open registration.\n  // Also fixes a bug which allows users to log in when registrations are changed to closed.\n  let accepted_application = Some(!require_registration_application);\n\n  // Show nsfw content if param is true, or if content_warning exists\n  let show_nsfw = data\n    .show_nsfw\n    .unwrap_or(site_view.site.content_warning.is_some());\n\n  let language_tags = get_language_tags(&req);\n\n  // Wrap the insert person, insert local user, and create registration,\n  // in a transaction, so that if any fail, the rows aren't created.\n  let conn = &mut get_conn(pool).await?;\n  let tx_data = data.clone();\n  let tx_context = context.clone();\n  let user = conn\n    .run_transaction(|conn| {\n      async move {\n        // We have to create both a person, and local_user\n        let person = create_person(tx_data.username.clone(), &site_view, &tx_context, conn).await?;\n\n        // Create the local user\n        let local_user_form = LocalUserInsertForm {\n          email: tx_data.email.as_deref().map(str::to_lowercase),\n          show_nsfw: Some(show_nsfw),\n          accepted_application,\n          ..LocalUserInsertForm::new(person.id, Some(tx_data.password.to_string()))\n        };\n\n        let local_user = create_local_user(\n          conn,\n          language_tags,\n          local_user_form,\n          &site_view.local_site,\n          &tx_context,\n        )\n        .await?;\n\n        if site_view.local_site.site_setup\n          && require_registration_application\n          && let Some(answer) = tx_data.answer.clone()\n        {\n          // Create the registration application\n          let form = RegistrationApplicationInsertForm {\n            local_user_id: local_user.id,\n            answer,\n          };\n\n          RegistrationApplication::create(&mut conn.into(), &form).await?;\n        }\n\n        Ok(LocalUserView {\n          person,\n          local_user,\n          banned: false,\n          ban_expires_at: None,\n        })\n      }\n      .scope_boxed()\n    })\n    .await?;\n\n  // Email the admins, only if email verification is not required\n  if local_site.application_email_admins && !local_site.require_email_verification {\n    send_new_applicant_email_to_admins(&data.username, pool, context.settings()).await?;\n  }\n\n  let mut login_response = LoginResponse {\n    jwt: None,\n    registration_created: false,\n    verify_email_sent: false,\n  };\n\n  // Log the user in directly if the site is not setup, or email verification and application aren't\n  // required\n  if !local_site.site_setup\n    || (!require_registration_application && !local_site.require_email_verification)\n  {\n    let jwt = Claims::generate(user.local_user.id, data.stay_logged_in, req, &context).await?;\n    login_response.jwt = Some(jwt);\n  } else {\n    login_response.verify_email_sent = send_verification_email_if_required(\n      &local_site,\n      &user,\n      &mut context.pool(),\n      context.settings(),\n    )\n    .await?;\n\n    if require_registration_application {\n      login_response.registration_created = true;\n    }\n  }\n\n  Ok(Json(login_response))\n}\n\npub async fn authenticate_with_oauth(\n  Json(data): Json<AuthenticateWithOauth>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<LoginResponse>> {\n  let pool = &mut context.pool();\n  let site_view = SiteView::read_local(pool).await?;\n  let local_site = site_view.local_site.clone();\n\n  // Show nsfw content if param is true, or if content_warning exists\n  let show_nsfw = data\n    .show_nsfw\n    .unwrap_or(site_view.site.content_warning.is_some());\n\n  let language_tags = get_language_tags(&req);\n\n  // validate inputs\n  if data.oauth_provider_id == OAuthProviderId(0) || data.code.is_empty() || data.code.len() > 300 {\n    return Err(LemmyErrorType::OauthAuthorizationInvalid.into());\n  }\n\n  // validate the redirect_uri\n  let redirect_uri = &data.redirect_uri;\n  if redirect_uri.host_str().unwrap_or(\"\").is_empty()\n    || !redirect_uri.path().eq(&String::from(\"/oauth/callback\"))\n    || !redirect_uri.query().unwrap_or(\"\").is_empty()\n  {\n    return Err(LemmyErrorType::OauthAuthorizationInvalid.into());\n  }\n\n  // validate the PKCE challenge\n  if let Some(code_verifier) = &data.pkce_code_verifier {\n    check_code_verifier(code_verifier)?;\n  }\n\n  // Fetch the OAUTH provider and make sure it's enabled\n  let oauth_provider_id = data.oauth_provider_id;\n  let oauth_provider = AdminOAuthProvider::read(pool, oauth_provider_id)\n    .await\n    .ok()\n    .ok_or(LemmyErrorType::OauthAuthorizationInvalid)?;\n\n  if !oauth_provider.enabled {\n    return Err(LemmyErrorType::OauthAuthorizationInvalid.into());\n  }\n\n  let token_response = oauth_request_access_token(\n    &context,\n    &oauth_provider,\n    &data.code,\n    data.pkce_code_verifier.as_deref(),\n    redirect_uri.as_str(),\n  )\n  .await?;\n\n  let user_info = oidc_get_user_info(\n    &context,\n    &oauth_provider,\n    token_response.access_token.as_str(),\n  )\n  .await?;\n\n  let oauth_user_id = read_user_info(&user_info, oauth_provider.id_claim.as_str())?;\n\n  let require_registration_application =\n    local_site.registration_mode == RegistrationMode::RequireApplication;\n\n  let mut login_response = LoginResponse {\n    jwt: None,\n    registration_created: false,\n    verify_email_sent: false,\n  };\n\n  // Lookup user by oauth_user_id\n  let mut local_user_view =\n    LocalUserView::find_by_oauth_id(pool, oauth_provider.id, &oauth_user_id).await;\n\n  let local_user = if let Ok(user_view) = local_user_view {\n    // user found by oauth_user_id => Login user\n    let local_user = user_view.clone().local_user;\n\n    login_response.registration_created = local_site.site_setup\n      && require_registration_application\n      && !local_user.accepted_application\n      && !local_user.admin\n      && data.answer.is_some();\n\n    check_local_user_valid(&user_view)?;\n    check_email_verified(&user_view, &site_view)?;\n    check_registration_application(&user_view, &site_view.local_site, pool).await?;\n    local_user\n  } else {\n    // user has never previously registered using oauth\n\n    // prevent registration if registration is closed\n    if local_site.registration_mode == RegistrationMode::Closed {\n      return Err(LemmyErrorType::RegistrationClosed.into());\n    }\n\n    // prevent registration if registration is closed for OAUTH providers\n    if !local_site.oauth_registration {\n      return Err(LemmyErrorType::OauthRegistrationClosed.into());\n    }\n\n    // Extract the OAUTH email claim from the returned user_info\n    let email = read_user_info(&user_info, \"email\")?;\n\n    // Lookup user by OAUTH email and link accounts\n    local_user_view = LocalUserView::find_by_email(pool, &email).await;\n\n    if let Ok(user_view) = local_user_view {\n      // user found by email => link and login if linking is allowed\n\n      // we only allow linking by email when email_verification is required otherwise emails cannot\n      // be trusted\n      if oauth_provider.account_linking_enabled && site_view.local_site.require_email_verification {\n        // WARNING:\n        // If an admin switches the require_email_verification config from false to true,\n        // users who signed up before the switch could have accounts with unverified emails falsely\n        // marked as verified.\n\n        check_local_user_valid(&user_view)?;\n        check_email_verified(&user_view, &site_view)?;\n        check_registration_application(&user_view, &site_view.local_site, pool).await?;\n\n        // Link with OAUTH => Login user\n        let oauth_account_form =\n          OAuthAccountInsertForm::new(user_view.local_user.id, oauth_provider.id, oauth_user_id);\n\n        OAuthAccount::create(pool, &oauth_account_form).await?;\n\n        user_view.local_user.clone()\n      } else {\n        return Err(LemmyErrorType::EmailAlreadyTaken.into());\n      }\n    } else {\n      // No user was found by email => Register as new user\n\n      // make sure the registration answer is provided when the registration application is required\n      validate_registration_answer(require_registration_application, &data.answer)?;\n\n      let slur_regex = slur_regex(&context).await?;\n\n      // Wrap the insert person, insert local user, and create registration,\n      // in a transaction, so that if any fail, the rows aren't created.\n      let conn = &mut get_conn(pool).await?;\n      let tx_data = data.clone();\n      let tx_context = context.clone();\n      let user = conn\n        .run_transaction(|conn| {\n          async move {\n            // make sure the username is provided\n            let username = tx_data\n              .username\n              .as_ref()\n              .ok_or(LemmyErrorType::RegistrationUsernameRequired)?;\n\n            check_slurs(username, &slur_regex)?;\n            check_slurs_opt(&tx_data.answer, &slur_regex)?;\n\n            Person::check_username_taken(&mut conn.into(), username).await?;\n\n            // We have to create a person, a local_user, and an oauth_account\n            let person = create_person(username.clone(), &site_view, &tx_context, conn).await?;\n\n            // Create the local user\n            let local_user_form = LocalUserInsertForm {\n              email: Some(str::to_lowercase(&email)),\n              show_nsfw: Some(show_nsfw),\n              accepted_application: Some(!require_registration_application),\n              email_verified: Some(oauth_provider.auto_verify_email),\n              ..LocalUserInsertForm::new(person.id, None)\n            };\n\n            let local_user = create_local_user(\n              conn,\n              language_tags,\n              local_user_form,\n              &site_view.local_site,\n              &tx_context,\n            )\n            .await?;\n\n            // Create the oauth account\n            let oauth_account_form =\n              OAuthAccountInsertForm::new(local_user.id, oauth_provider.id, oauth_user_id);\n\n            OAuthAccount::create(&mut conn.into(), &oauth_account_form).await?;\n\n            // prevent sign in until application is accepted\n            if login_response.registration_created {\n              // Create the registration application\n              RegistrationApplication::create(\n                &mut conn.into(),\n                &RegistrationApplicationInsertForm {\n                  local_user_id: local_user.id,\n                  // We already check earlier that this Some, however using `ok_or` is cleaner\n                  // than unwrap or expect (which also requires clippy allow).\n                  answer: data\n                    .answer\n                    .ok_or(LemmyErrorType::RegistrationApplicationAnswerRequired)?,\n                },\n              )\n              .await?;\n            }\n            Ok(LocalUserView {\n              person,\n              local_user,\n              banned: false,\n              ban_expires_at: None,\n            })\n          }\n          .scope_boxed()\n        })\n        .await?;\n\n      // Check email is verified when required\n      login_response.verify_email_sent = send_verification_email_if_required(\n        &local_site,\n        &user,\n        &mut context.pool(),\n        context.settings(),\n      )\n      .await?;\n      user.local_user\n    }\n  };\n\n  if !login_response.registration_created && !login_response.verify_email_sent {\n    let jwt = Claims::generate(local_user.id, data.stay_logged_in, req, &context).await?;\n    login_response.jwt = Some(jwt);\n  }\n\n  Ok(Json(login_response))\n}\n\nasync fn create_person(\n  username: String,\n  site_view: &SiteView,\n  context: &LemmyContext,\n  conn: &mut AsyncPgConnection,\n) -> Result<Person, LemmyError> {\n  let actor_keypair = generate_actor_keypair()?;\n  is_valid_actor_name(&username)?;\n  let ap_id = Person::generate_local_actor_url(&username, context.settings())?;\n\n  // Register the new person\n  let person_form = PersonInsertForm {\n    ap_id: Some(ap_id.clone()),\n    inbox_url: Some(generate_inbox_url()?),\n    private_key: Some(actor_keypair.private_key),\n    ..PersonInsertForm::new(\n      username.clone(),\n      actor_keypair.public_key,\n      site_view.site.instance_id,\n    )\n  };\n\n  // insert the person\n  let inserted_person = Person::create(&mut conn.into(), &person_form).await?;\n\n  Ok(inserted_person)\n}\n\nfn get_language_tags(req: &HttpRequest) -> Vec<String> {\n  req\n    .headers()\n    .get(\"Accept-Language\")\n    .map(|hdr| accept_language::parse(hdr.to_str().unwrap_or_default()))\n    .iter()\n    .flatten()\n    // Remove the optional region code\n    .map(|lang_str| lang_str.split('-').next().unwrap_or_default().to_string())\n    .collect::<Vec<String>>()\n}\n\nasync fn create_local_user(\n  conn: &mut AsyncPgConnection,\n  language_tags: Vec<String>,\n  mut local_user_form: LocalUserInsertForm,\n  local_site: &LocalSite,\n  context: &Data<LemmyContext>,\n) -> Result<LocalUser, LemmyError> {\n  let conn_ = &mut conn.into();\n  let all_languages = Language::read_all(conn_).await?;\n  // use hashset to avoid duplicates\n  let mut language_ids = HashSet::new();\n\n  // Enable site languages. Ignored if all languages are enabled.\n  let discussion_languages = SiteLanguage::read(conn_, local_site.site_id).await?;\n\n  // Enable languages from `Accept-Language` header only if no site languages are set. Otherwise it\n  // is possible that browser languages are only set to e.g. French, and the user won't see any\n  // English posts.\n  if !discussion_languages.is_empty() {\n    for l in &language_tags {\n      if let Some(found) = all_languages.iter().find(|all| &all.code == l) {\n        language_ids.insert(found.id);\n      }\n    }\n  }\n  language_ids.extend(discussion_languages);\n\n  let language_ids = language_ids.into_iter().collect();\n\n  local_user_form.default_listing_type = Some(local_site.default_post_listing_type);\n  local_user_form.post_listing_mode = Some(local_site.default_post_listing_mode);\n  // If its the initial site setup, they are an admin\n  local_user_form.admin = Some(!local_site.site_setup);\n  local_user_form.interface_language = language_tags.first().cloned();\n  let inserted_local_user = LocalUser::create(conn_, &local_user_form, language_ids).await?;\n\n  // If we are setting up a new site, fetch initial communities and create welcome post.\n  if !local_site.site_setup {\n    local_user_form.admin = Some(true);\n    create_welcome_post(inserted_local_user.clone(), context);\n    fetch_community_list(context.clone());\n  }\n\n  Ok(inserted_local_user)\n}\n\nfn validate_registration_answer(\n  require_registration_application: bool,\n  answer: &Option<String>,\n) -> LemmyResult<()> {\n  if require_registration_application && answer.is_none() {\n    return Err(LemmyErrorType::RegistrationApplicationAnswerRequired.into());\n  }\n\n  Ok(())\n}\n\nasync fn oauth_request_access_token(\n  context: &Data<LemmyContext>,\n  oauth_provider: &AdminOAuthProvider,\n  code: &str,\n  pkce_code_verifier: Option<&str>,\n  redirect_uri: &str,\n) -> LemmyResult<TokenResponse> {\n  let mut form = vec![\n    (\"client_id\", &*oauth_provider.client_id),\n    (\"client_secret\", &*oauth_provider.client_secret),\n    (\"code\", code),\n    (\"grant_type\", \"authorization_code\"),\n    (\"redirect_uri\", redirect_uri),\n  ];\n\n  if let Some(code_verifier) = pkce_code_verifier {\n    form.push((\"code_verifier\", code_verifier));\n  }\n\n  // Request an Access Token from the OAUTH provider\n  let response = context\n    .client()\n    .post(oauth_provider.token_endpoint.as_str())\n    .header(\"Accept\", \"application/json\")\n    .form(&form[..])\n    .send()\n    .await\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?\n    .error_for_status()\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?;\n\n  // Extract the access token\n  let token_response = response\n    .json::<TokenResponse>()\n    .await\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?;\n\n  Ok(token_response)\n}\n\nasync fn oidc_get_user_info(\n  context: &Data<LemmyContext>,\n  oauth_provider: &AdminOAuthProvider,\n  access_token: &str,\n) -> LemmyResult<serde_json::Value> {\n  // Request the user info from the OAUTH provider\n  let response = context\n    .client()\n    .get(oauth_provider.userinfo_endpoint.as_str())\n    .header(\"Accept\", \"application/json\")\n    .bearer_auth(access_token)\n    .send()\n    .await\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?\n    .error_for_status()\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?;\n\n  // Extract the OAUTH user_id claim from the returned user_info\n  let user_info = response\n    .json::<serde_json::Value>()\n    .await\n    .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?;\n\n  Ok(user_info)\n}\n\nfn read_user_info(user_info: &serde_json::Value, key: &str) -> LemmyResult<String> {\n  if let Some(value) = user_info.get(key) {\n    let result = serde_json::from_value::<String>(value.clone())\n      .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?;\n    return Ok(result);\n  }\n  Err(LemmyErrorType::OauthLoginFailed.into())\n}\n\n#[expect(clippy::expect_used)]\nfn check_code_verifier(code_verifier: &str) -> LemmyResult<()> {\n  static VALID_CODE_VERIFIER_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"^[a-zA-Z0-9\\-._~]{43,128}$\").expect(\"compile regex\"));\n\n  let check = VALID_CODE_VERIFIER_REGEX.is_match(code_verifier);\n\n  if check {\n    Ok(())\n  } else {\n    Err(LemmyErrorType::InvalidCodeVerifier.into())\n  }\n}\n\nfn fetch_community_list(context: Data<LemmyContext>) {\n  // Only do this in release mode.\n  if cfg!(debug_assertions) {\n    //return;\n  }\n\n  spawn_try_task(async move {\n    let instances = context\n      .settings()\n      .setup\n      .clone()\n      .unwrap_or_default()\n      .bootstrap_instances;\n    let mut communities: Vec<ObjectId<ApubCommunity>> = vec![];\n    for i in instances {\n      info!(\"Trying to fetch community list from {i}\");\n      let res = context\n        .client()\n        .get(format!(\n          \"https://{i}/api/v4/community/list?type_=all&sort=active_monthly&limit=50\"\n        ))\n        .send()\n        .await;\n      if let Ok(res) = res\n        && let Ok(json) = res.json::<PagedResponse<CommunityView>>().await\n      {\n        communities = json\n          .items\n          .into_iter()\n          // exclude nsfw\n          .filter(|c| !c.community.nsfw)\n          .map(|c| c.community.ap_id.into())\n          .collect();\n        info!(\"Successfully fetched community list from {i}\");\n        break;\n      }\n      info!(\"Failed to fetch community list from {i}\");\n    }\n    // also prefetch these two communities as they are linked in the welcome post\n    communities.insert(0, \"https://lemmy.ml/c/announcements\".parse()?);\n    communities.insert(0, \"https://lemmy.ml/c/lemmy\".parse()?);\n\n    // Fetch communities themselves\n    let tasks = communities.iter().map(|c| async {\n      let context = context.reset_request_count();\n      c.dereference(&context).await.ok();\n    });\n\n    // This could be made faster by running tasks in parallel with try_join_all or\n    // FuturesUnordered. However that causes massive slowdown as each community fetch\n    // starts additional background tasks to fetch moderators, recent posts etc. So we\n    // need to run it one by one and sleep in between.\n    for t in tasks {\n      t.await;\n      sleep(Duration::from_secs(1)).await;\n    }\n\n    Ok(())\n  })\n}\n\nfn create_welcome_post(local_user: LocalUser, context: &LemmyContext) {\n  let context = context.clone();\n\n  spawn_try_task(async move {\n    let pool = &mut context.pool();\n    let site = SiteView::read_local(pool).await?;\n    let admins = PersonView::list_admins(None, site.instance.id, &mut context.pool()).await?;\n    let initial_user = admins.first();\n\n    let person = SiteView::read_system_account(&mut context.pool()).await?;\n\n    // Create main community\n    let community_name = \"main\".to_string();\n    let community_ap_id = Community::generate_local_actor_url(&community_name, context.settings())?;\n    let keypair = generate_actor_keypair()?;\n    let community_form = CommunityInsertForm {\n      ap_id: Some(community_ap_id.clone()),\n      private_key: Some(keypair.private_key),\n      followers_url: Some(generate_followers_url(&community_ap_id)?),\n      inbox_url: Some(generate_inbox_url()?),\n      moderators_url: Some(generate_moderators_url(&community_ap_id)?),\n      featured_url: Some(generate_featured_url(&community_ap_id)?),\n      ..CommunityInsertForm::new(\n        site.site.instance_id,\n        community_name,\n        \"Main\".to_string(),\n        keypair.public_key,\n      )\n    };\n    let community = Community::create(pool, &community_form).await?;\n\n    // Add initial admin user as community mod (not necessary but looks cleaner)\n    if let Some(initial_user) = initial_user {\n      let mod_form = CommunityModeratorForm::new(community.id, initial_user.person.id);\n      CommunityActions::join(pool, &mod_form).await?;\n    }\n\n    // Create post in this community with getting started info\n    let lang = user_language(&local_user);\n    let title = lang.welcome_post_title().to_string();\n    let body = lang.welcome_post_body().to_string();\n    let post_form = PostInsertForm {\n      body: Some(body),\n      featured_local: Some(true),\n      ..PostInsertForm::new(title, person.id, community.id)\n    };\n    let post = Post::create(pool, &post_form).await?;\n\n    // Own upvote for post\n    let like_form = PostLikeForm::new(post.id, person.id, Some(true));\n    PostActions::like(&mut context.pool(), &like_form).await?;\n\n    Ok(())\n  })\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/user/delete.rs",
    "content": "use activitypub_federation::config::Data;\nuse actix_web::web::Json;\nuse bcrypt::verify;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::purge_user_account,\n};\nuse lemmy_db_schema::source::{\n  community::CommunityActions,\n  login_token::LoginToken,\n  oauth_account::OAuthAccount,\n  person::Person,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::{DeleteAccount, SuccessResponse};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\n\npub async fn delete_account(\n  Json(data): Json<DeleteAccount>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let local_instance_id = local_user_view.person.instance_id;\n\n  // Verify the password\n  let valid: bool = local_user_view\n    .local_user\n    .password_encrypted\n    .as_ref()\n    .and_then(|password_encrypted| verify(&data.password, password_encrypted).ok())\n    .unwrap_or(false);\n  if !valid {\n    return Err(LemmyErrorType::IncorrectLogin.into());\n  }\n\n  if data.delete_content {\n    purge_user_account(local_user_view.person.id, local_instance_id, &context).await?;\n  } else {\n    // These are already run in purge_user_account,\n    // but should be done anyway even if delete_content is false\n    OAuthAccount::delete_user_accounts(&mut context.pool(), local_user_view.local_user.id).await?;\n    CommunityActions::leave_mod_team_for_all_communities(\n      &mut context.pool(),\n      local_user_view.person.id,\n    )\n    .await?;\n    Person::delete_account(\n      &mut context.pool(),\n      local_user_view.person.id,\n      local_instance_id,\n    )\n    .await?;\n  }\n\n  LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;\n\n  ActivityChannel::submit_activity(\n    SendActivityData::DeleteUser(local_user_view.person, data.delete_content),\n    &context,\n  )?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/api/api_crud/src/user/mod.rs",
    "content": "pub mod create;\npub mod delete;\npub mod my_user;\n"
  },
  {
    "path": "crates/api/api_crud/src/user/my_user.rs",
    "content": "use actix_web::web::{Data, Json};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_local_user_deleted};\nuse lemmy_db_schema::{\n  MultiCommunityListingType,\n  MultiCommunitySortType,\n  source::{\n    actor_language::LocalUserLanguage,\n    community::CommunityActions,\n    instance::InstanceActions,\n    keyword_block::LocalUserKeywordBlock,\n    person::PersonActions,\n  },\n  traits::Blockable,\n};\nuse lemmy_db_views_community::impls::MultiCommunityQuery;\nuse lemmy_db_views_community_follower::CommunityFollowerView;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::api::MyUserInfo;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn get_my_user(\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<MyUserInfo>> {\n  check_local_user_deleted(&local_user_view)?;\n\n  // Build the local user with parallel queries and add it to site response\n  let person_id = local_user_view.person.id;\n  let local_user_id = local_user_view.local_user.id;\n  let pool = &mut context.pool();\n\n  let (\n    follows,\n    community_blocks,\n    instance_communities_blocks,\n    instance_persons_blocks,\n    person_blocks,\n    moderates,\n    multi_community_follows,\n    keyword_blocks,\n    discussion_languages,\n  ) = lemmy_diesel_utils::try_join_with_pool!(pool => (\n    |pool| CommunityFollowerView::for_person(pool, person_id),\n    |pool| CommunityActions::read_blocks_for_person(pool, person_id),\n    |pool| InstanceActions::read_communities_block_for_person(pool, person_id),\n    |pool| InstanceActions::read_persons_block_for_person(pool, person_id),\n    |pool| PersonActions::read_blocks_for_person(pool, person_id),\n    |pool| CommunityModeratorView::for_person(pool, person_id, Some(&local_user_view.local_user)),\n    |pool| MultiCommunityQuery {\n      my_person_id: Some(person_id),\n      listing_type: Some(MultiCommunityListingType::Subscribed),\n      sort: Some(MultiCommunitySortType::NameAsc),\n      no_limit: Some(true),\n      ..Default::default()\n    }\n    .list(pool),\n    |pool| LocalUserKeywordBlock::read(pool, local_user_id),\n    |pool| LocalUserLanguage::read(pool, local_user_id)\n  ))?;\n\n  Ok(Json(MyUserInfo {\n    local_user_view: local_user_view.clone(),\n    follows,\n    moderates,\n    multi_community_follows: multi_community_follows.items,\n    community_blocks,\n    instance_communities_blocks,\n    instance_persons_blocks,\n    person_blocks,\n    keyword_blocks,\n    discussion_languages,\n  }))\n}\n"
  },
  {
    "path": "crates/api/api_utils/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api_utils\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\n\n[lib]\nname = \"lemmy_api_utils\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_views_community/full\",\n  \"lemmy_db_views_community_follower_approval/full\",\n  \"lemmy_db_views_community_moderator/full\",\n  \"lemmy_db_views_local_image/full\",\n  \"lemmy_db_views_local_user/full\",\n  \"lemmy_db_views_site/full\",\n  \"lemmy_db_views_private_message/full\",\n  \"lemmy_db_views_comment/full\",\n  \"lemmy_db_views_post/full\",\n  \"lemmy_db_views_notification/full\",\n  \"lemmy_db_views_registration_applications/full\",\n]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_community = { workspace = true }\nlemmy_db_views_community_follower_approval = { workspace = true }\nlemmy_db_views_community_moderator = { workspace = true }\nlemmy_db_views_local_image = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_views_site = { workspace = true }\nlemmy_db_views_private_message = { workspace = true }\nlemmy_db_views_comment = { workspace = true }\nlemmy_db_views_post = { workspace = true }\nlemmy_db_views_notification = { workspace = true }\nlemmy_db_views_registration_applications = { workspace = true }\nlemmy_email = { workspace = true }\nanyhow = { workspace = true }\nserde = { workspace = true }\ntokio = { workspace = true }\ntracing = { workspace = true }\nlemmy_utils = { workspace = true }\nextism = { workspace = true }\nextism-convert = { workspace = true }\nextism-manifest = \"1.13.0\"\nreqwest-middleware = { workspace = true }\nactivitypub_federation = { workspace = true }\nmime = { version = \"0.3.17\" }\nmime_guess = \"2.0.5\"\ninfer = \"0.19.0\"\nchrono = { workspace = true }\nencoding_rs = \"0.8.35\"\nfutures = { workspace = true }\nreqwest = { workspace = true }\nactix-web = { workspace = true }\nactix-web-httpauth = { version = \"0.8.2\" }\nenum-map = { workspace = true }\nurl = { workspace = true }\nmoka = { workspace = true }\nwebmention = { version = \"0.6.0\" }\nurlencoding = { workspace = true }\nwebpage = { version = \"2.0\", default-features = false, features = [\"serde\"] }\nregex = { workspace = true }\njsonwebtoken = { version = \"10.3.0\", features = [\"rust_crypto\"] }\neither.workspace = true\nderive-new.workspace = true\nlemmy_diesel_utils = { workspace = true }\nrustls = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\npretty_assertions = { workspace = true }\nlemmy_db_views_notification = { workspace = true, features = [\"full\"] }\ndiesel_ltree = { workspace = true }\n"
  },
  {
    "path": "crates/api/api_utils/src/build_response.rs",
    "content": "use crate::{context::LemmyContext, utils::is_mod_or_admin};\nuse actix_web::web::Json;\nuse lemmy_db_schema::{\n  newtypes::{CommentId, CommunityId, PostId},\n  source::actor_language::CommunityLanguage,\n};\nuse lemmy_db_schema_file::InstanceId;\nuse lemmy_db_views_comment::{CommentView, api::CommentResponse};\nuse lemmy_db_views_community::{CommunityView, api::CommunityResponse};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_post::{PostView, api::PostResponse};\nuse lemmy_utils::error::LemmyResult;\n\npub async fn build_comment_response(\n  context: &LemmyContext,\n  comment_id: CommentId,\n  local_user_view: Option<LocalUserView>,\n  local_instance_id: InstanceId,\n) -> LemmyResult<CommentResponse> {\n  let local_user = local_user_view.map(|l| l.local_user);\n  let comment_view = CommentView::read(\n    &mut context.pool(),\n    comment_id,\n    local_user.as_ref(),\n    local_instance_id,\n  )\n  .await?;\n  Ok(CommentResponse { comment_view })\n}\n\npub async fn build_community_response(\n  context: &LemmyContext,\n  local_user_view: LocalUserView,\n  community_id: CommunityId,\n) -> LemmyResult<Json<CommunityResponse>> {\n  let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id)\n    .await\n    .is_ok();\n  let local_user = local_user_view.local_user;\n  let community_view = CommunityView::read(\n    &mut context.pool(),\n    community_id,\n    Some(&local_user),\n    is_mod_or_admin,\n  )\n  .await?;\n  let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;\n\n  Ok(Json(CommunityResponse {\n    community_view,\n    discussion_languages,\n  }))\n}\n\npub async fn build_post_response(\n  context: &LemmyContext,\n  community_id: CommunityId,\n  local_user_view: LocalUserView,\n  post_id: PostId,\n) -> LemmyResult<Json<PostResponse>> {\n  let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id)\n    .await\n    .is_ok();\n  let local_user = local_user_view.local_user;\n  let post_view = PostView::read(\n    &mut context.pool(),\n    post_id,\n    Some(&local_user),\n    local_user_view.person.instance_id,\n    is_mod_or_admin,\n  )\n  .await?;\n  Ok(Json(PostResponse { post_view }))\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/claims.rs",
    "content": "use crate::context::LemmyContext;\nuse actix_web::{HttpRequest, http::header::USER_AGENT};\nuse chrono::{DateTime, Duration, Utc};\nuse jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};\nuse lemmy_db_schema::{\n  newtypes::LocalUserId,\n  source::login_token::{LoginToken, LoginTokenCreateForm},\n};\nuse lemmy_diesel_utils::sensitive::SensitiveString;\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\npub struct Claims {\n  /// local_user_id, standard claim by RFC 7519.\n  pub sub: String,\n  /// Server domain\n  pub iss: String,\n  /// Time when this token was issued as UNIX-timestamp in seconds\n  pub iat: i64,\n  /// Expiration timestamp\n  pub exp: i64,\n}\n\nimpl Claims {\n  pub async fn validate(jwt: &str, context: &LemmyContext) -> LemmyResult<LocalUserId> {\n    let validation = Validation::default();\n    let jwt_secret = &context.secret().jwt_secret;\n    let key = DecodingKey::from_secret(jwt_secret.as_ref());\n    let claims =\n      decode::<Claims>(jwt, &key, &validation).with_lemmy_type(LemmyErrorType::NotLoggedIn)?;\n    let user_id = LocalUserId(claims.claims.sub.parse()?);\n    LoginToken::validate(&mut context.pool(), user_id, jwt).await?;\n    Ok(user_id)\n  }\n\n  pub async fn generate(\n    user_id: LocalUserId,\n    stay_logged_in: Option<bool>,\n    req: HttpRequest,\n    context: &LemmyContext,\n  ) -> LemmyResult<SensitiveString> {\n    let hostname = context.settings().hostname.clone();\n    let now = Utc::now();\n    let exp = if stay_logged_in.unwrap_or_default() {\n      // Login doesnt expire\n      DateTime::<Utc>::MAX_UTC\n    } else {\n      // Login expires after one week\n      now + Duration::weeks(1)\n    };\n    let my_claims = Claims {\n      sub: user_id.0.to_string(),\n      iss: hostname,\n      iat: now.timestamp(),\n      exp: exp.timestamp(),\n    };\n\n    let secret = &context.secret().jwt_secret;\n    let key = EncodingKey::from_secret(secret.as_ref());\n    let token: SensitiveString = encode(&Header::default(), &my_claims, &key)?.into();\n    let ip = req\n      .connection_info()\n      .realip_remote_addr()\n      .map(ToString::to_string);\n    let user_agent = req\n      .headers()\n      .get(USER_AGENT)\n      .and_then(|ua| ua.to_str().ok())\n      .map(ToString::to_string);\n    let form = LoginTokenCreateForm {\n      token: token.clone(),\n      user_id,\n      ip,\n      user_agent,\n    };\n    LoginToken::create(&mut context.pool(), form).await?;\n    Ok(token)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::{claims::Claims, context::LemmyContext};\n  use actix_web::test::TestRequest;\n  use lemmy_db_schema::source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm},\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_should_not_validate_user_token_after_password_change() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"Gerry9812\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let local_user_form = LocalUserInsertForm::test_form(inserted_person.id);\n\n    let inserted_local_user = LocalUser::create(pool, &local_user_form, vec![]).await?;\n\n    let req = TestRequest::default().to_http_request();\n    let jwt = Claims::generate(inserted_local_user.id, None, req, &context).await?;\n\n    let valid = Claims::validate(&jwt, &context).await;\n    assert!(valid.is_ok());\n\n    let num_deleted = Person::delete(pool, inserted_person.id).await?;\n    assert_eq!(1, num_deleted);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/context.rs",
    "content": "use crate::request::client_builder;\nuse activitypub_federation::config::{Data, FederationConfig};\nuse lemmy_db_schema::source::secret::Secret;\nuse lemmy_diesel_utils::connection::{ActualDbPool, DbPool, build_db_pool_for_tests};\nuse lemmy_utils::{\n  rate_limit::RateLimit,\n  settings::{SETTINGS, structs::Settings},\n};\nuse reqwest_middleware::{ClientBuilder, ClientWithMiddleware};\nuse std::sync::Arc;\n\n#[derive(Clone)]\npub struct LemmyContext {\n  pool: ActualDbPool,\n  client: Arc<ClientWithMiddleware>,\n  /// Pictrs requests must bypass proxy. Unfortunately no_proxy can only be set on ClientBuilder\n  /// and not on RequestBuilder, so we need a separate client here.\n  pictrs_client: Arc<ClientWithMiddleware>,\n  secret: Arc<Secret>,\n  rate_limit_cell: RateLimit,\n}\n\nimpl LemmyContext {\n  pub fn create(\n    pool: ActualDbPool,\n    client: ClientWithMiddleware,\n    pictrs_client: ClientWithMiddleware,\n    secret: Secret,\n    rate_limit_cell: RateLimit,\n  ) -> LemmyContext {\n    LemmyContext {\n      pool,\n      client: Arc::new(client),\n      pictrs_client: Arc::new(pictrs_client),\n      secret: Arc::new(secret),\n      rate_limit_cell,\n    }\n  }\n  pub fn pool(&self) -> DbPool<'_> {\n    DbPool::Pool(&self.pool)\n  }\n  pub fn inner_pool(&self) -> &ActualDbPool {\n    &self.pool\n  }\n  pub fn client(&self) -> &ClientWithMiddleware {\n    &self.client\n  }\n  pub fn pictrs_client(&self) -> &ClientWithMiddleware {\n    &self.pictrs_client\n  }\n  pub fn settings(&self) -> &'static Settings {\n    &SETTINGS\n  }\n  pub fn secret(&self) -> &Secret {\n    &self.secret\n  }\n  pub fn rate_limit_cell(&self) -> &RateLimit {\n    &self.rate_limit_cell\n  }\n\n  /// Initialize a context for use in tests which blocks federation network calls.\n  ///\n  /// Do not use this in production code.\n  #[expect(clippy::expect_used)]\n  pub async fn init_test_federation_config() -> FederationConfig<LemmyContext> {\n    // call this to run migrations\n    let pool = build_db_pool_for_tests();\n\n    let client = client_builder(&SETTINGS).build().expect(\"build client\");\n\n    let client = ClientBuilder::new(client).build();\n    let secret = Secret {\n      id: 0,\n      jwt_secret: String::new().into(),\n    };\n\n    let rate_limit_cell = RateLimit::with_debug_config();\n\n    let context = LemmyContext::create(\n      pool,\n      client.clone(),\n      client,\n      secret,\n      rate_limit_cell.clone(),\n    );\n\n    FederationConfig::builder()\n      .domain(context.settings().hostname.clone())\n      .app_data(context)\n      .debug(true)\n      // Dont allow any network fetches\n      .http_fetch_limit(0)\n      .build()\n      .await\n      .expect(\"build federation config\")\n  }\n  pub async fn init_test_context() -> Data<LemmyContext> {\n    let config = Self::init_test_federation_config().await;\n    config.to_request_data()\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/lib.rs",
    "content": "pub mod build_response;\npub mod claims;\npub mod context;\npub mod notify;\npub mod plugins;\npub mod request;\npub mod send_activity;\npub mod utils;\n"
  },
  {
    "path": "crates/api/api_utils/src/notify.rs",
    "content": "use crate::{context::LemmyContext, plugins::plugin_hook_notification};\nuse lemmy_db_schema::{\n  source::{\n    comment::Comment,\n    community::{Community, CommunityActions},\n    instance::InstanceActions,\n    modlog::Modlog,\n    notification::{Notification, NotificationInsertForm},\n    person::{Person, PersonActions},\n    post::{Post, PostActions},\n  },\n  traits::{ApubActor, Blockable},\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::{CommunityNotificationsMode, NotificationType, PostNotificationsMode},\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{dburl::DbUrl, traits::Crud};\nuse lemmy_email::notifications::{NotificationEmailData, send_notification_email};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  spawn_try_task,\n  utils::mention::scrape_text_for_mentions,\n};\nuse std::{\n  collections::HashSet,\n  hash::{Hash, Hasher},\n};\nuse url::Url;\n\n#[derive(derive_new::new, Debug, Clone)]\npub struct NotifyData {\n  pub post: Post,\n  pub creator: Person,\n  pub community: Community,\n  #[new(value = \"None\")]\n  pub comment: Option<Comment>,\n  #[new(value = \"false\")]\n  pub do_send_email: bool,\n  #[new(value = \"None\")]\n  pub apub_mentions: Option<Vec<Person>>,\n}\n\nstruct CollectedNotifyData<'a> {\n  recipient_id: PersonId,\n  local_url: DbUrl,\n  data: NotificationEmailData<'a>,\n  kind: NotificationType,\n}\n\n/// For PartialEq and Hash, we only need to compare recipient id and object url.\nimpl<'a> PartialEq for CollectedNotifyData<'a> {\n  fn eq(&self, other: &CollectedNotifyData<'_>) -> bool {\n    self.recipient_id == other.recipient_id && self.local_url == other.local_url\n  }\n}\n\nimpl<'a> Hash for CollectedNotifyData<'a> {\n  fn hash<H: Hasher>(&self, state: &mut H) {\n    self.recipient_id.hash(state);\n    self.local_url.hash(state);\n  }\n}\n\nimpl<'a> Eq for CollectedNotifyData<'a> {}\n\nimpl NotifyData {\n  /// Scans the post/comment content for mentions, then sends notifications via db and email\n  /// to mentioned users and parent creator. Spawns a task for background processing.\n  pub fn send(self, context: &LemmyContext) {\n    let context = context.clone();\n    spawn_try_task(self.send_internal(context))\n  }\n\n  /// Logic for send(), in separate function so it can run serially in tests.\n  pub async fn send_internal(self, context: LemmyContext) -> LemmyResult<()> {\n    // Use set so that notifications are unique per user and object.\n    let collected: HashSet<_> = [\n      self.notify_parent_creator(&context).await?,\n      self.notify_mentions(&context).await?,\n      self.notify_subscribers(&context).await?,\n    ]\n    .into_iter()\n    .flatten()\n    .collect();\n\n    let mut forms = vec![];\n    for c in collected {\n      // Dont get notified about own actions\n      if self.creator.id == c.recipient_id {\n        continue;\n      }\n\n      if self\n        .check_notifications_allowed(c.recipient_id, &context)\n        .await\n        .is_err()\n      {\n        continue;\n      };\n\n      forms.push(if let Some(comment) = &self.comment {\n        NotificationInsertForm::new_comment(comment, c.recipient_id, c.kind)\n      } else {\n        NotificationInsertForm::new_post(&self.post, c.recipient_id, c.kind)\n      });\n\n      let Ok(user_view) = LocalUserView::read_person(&mut context.pool(), c.recipient_id).await\n      else {\n        // is a remote user, ignore\n        continue;\n      };\n\n      if self.do_send_email {\n        send_notification_email(user_view, c.local_url, c.data, context.settings());\n      }\n    }\n    if !forms.is_empty() {\n      let notifications = Notification::create(&mut context.pool(), &forms).await?;\n      plugin_hook_notification(notifications, &context).await?;\n    }\n\n    Ok(())\n  }\n\n  async fn check_notifications_allowed(\n    &self,\n    potential_blocker_id: PersonId,\n    context: &LemmyContext,\n  ) -> LemmyResult<()> {\n    let pool = &mut context.pool();\n    // TODO: this needs too many queries for each user\n    PersonActions::read_block(pool, potential_blocker_id, self.post.creator_id).await?;\n    InstanceActions::read_communities_block(pool, potential_blocker_id, self.community.instance_id)\n      .await?;\n    InstanceActions::read_persons_block(pool, potential_blocker_id, self.creator.instance_id)\n      .await?;\n    CommunityActions::read_block(pool, potential_blocker_id, self.post.community_id).await?;\n    let post_notifications = PostActions::read(pool, self.post.id, potential_blocker_id)\n      .await\n      .ok()\n      .and_then(|a| a.notifications)\n      .unwrap_or_default();\n    let community_notifications =\n      CommunityActions::read(pool, self.community.id, potential_blocker_id)\n        .await\n        .ok()\n        .and_then(|a| a.notifications)\n        .unwrap_or_default();\n    if post_notifications == PostNotificationsMode::Mute\n      || community_notifications == CommunityNotificationsMode::Mute\n    {\n      // The specific error type is irrelevant\n      return Err(LemmyErrorType::NotFound.into());\n    }\n\n    Ok(())\n  }\n\n  fn content(&self) -> String {\n    if let Some(comment) = self.comment.as_ref() {\n      comment.content.clone()\n    } else {\n      self.post.body.clone().unwrap_or_default()\n    }\n  }\n\n  fn link(&self, context: &LemmyContext) -> LemmyResult<Url> {\n    if let Some(comment) = self.comment.as_ref() {\n      Ok(comment.local_url(context.settings())?)\n    } else {\n      Ok(self.post.local_url(context.settings())?)\n    }\n  }\n\n  async fn notify_parent_creator<'a>(\n    &'a self,\n    context: &LemmyContext,\n  ) -> LemmyResult<Vec<CollectedNotifyData<'a>>> {\n    let Some(comment) = self.comment.as_ref() else {\n      return Ok(vec![]);\n    };\n\n    // Get the parent data\n    let (parent_creator_id, parent_comment) =\n      if let Some(parent_comment_id) = comment.parent_comment_id() {\n        let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?;\n        (parent_comment.creator_id, Some(parent_comment))\n      } else {\n        (self.post.creator_id, None)\n      };\n\n    Ok(vec![CollectedNotifyData {\n      recipient_id: parent_creator_id,\n      local_url: comment.local_url(context.settings())?.into(),\n      data: NotificationEmailData::Reply {\n        comment,\n        person: &self.creator,\n        parent_comment,\n        post: &self.post,\n      },\n      kind: NotificationType::Reply,\n    }])\n  }\n\n  async fn notify_mentions<'a>(\n    &'a self,\n    context: &LemmyContext,\n  ) -> LemmyResult<Vec<CollectedNotifyData<'a>>> {\n    let mentions = if let Some(apub_mentions) = self.apub_mentions.clone() {\n      apub_mentions\n    } else {\n      let scraped = scrape_text_for_mentions(&self.content())\n        .into_iter()\n        .filter(|m| m.is_local(&context.settings().hostname) && m.name.ne(&self.creator.name));\n      let mut persons = vec![];\n      for m in scraped {\n        let Ok(Some(p)) = Person::read_from_name(&mut context.pool(), &m.name, None, false).await\n        else {\n          // Ignore error if user is remote\n          continue;\n        };\n        persons.push(p);\n      }\n      persons\n    };\n\n    let mut res = vec![];\n    for mention in mentions {\n      res.push(CollectedNotifyData {\n        recipient_id: mention.id,\n        local_url: self.link(context)?.into(),\n        data: NotificationEmailData::Mention {\n          content: self.content().clone(),\n          person: &self.creator,\n        },\n        kind: NotificationType::Mention,\n      })\n    }\n    Ok(res)\n  }\n\n  async fn notify_subscribers<'a>(\n    &'a self,\n    context: &LemmyContext,\n  ) -> LemmyResult<Vec<CollectedNotifyData<'a>>> {\n    let is_post = self.comment.is_none();\n    let subscribers = vec![\n      PostActions::list_subscribers(self.post.id, &mut context.pool()).await?,\n      CommunityActions::list_subscribers(self.post.community_id, is_post, &mut context.pool())\n        .await?,\n    ]\n    .into_iter()\n    .flatten()\n    .collect::<Vec<_>>();\n\n    let mut res = vec![];\n    for recipient_id in subscribers {\n      let d = if let Some(comment) = &self.comment {\n        NotificationEmailData::PostSubscribed {\n          post: &self.post,\n          comment,\n        }\n      } else {\n        NotificationEmailData::CommunitySubscribed {\n          community: &self.community,\n          post: &self.post,\n        }\n      };\n      res.push(CollectedNotifyData {\n        recipient_id,\n        local_url: self.link(context)?.into(),\n        data: d,\n        kind: NotificationType::Subscribed,\n      });\n    }\n\n    Ok(res)\n  }\n}\n\npub fn notify_private_message(view: &PrivateMessageView, is_create: bool, context: &LemmyContext) {\n  let view = view.clone();\n  let context = context.clone();\n  spawn_try_task(async move { notify_private_message_internal(&view, is_create, &context).await })\n}\nasync fn notify_private_message_internal(\n  view: &PrivateMessageView,\n  is_create: bool,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let Ok(local_recipient) =\n    LocalUserView::read_person(&mut context.pool(), view.recipient.id).await\n  else {\n    return Ok(());\n  };\n\n  let form = NotificationInsertForm::new_private_message(&view.private_message);\n  let notifications = Notification::create(&mut context.pool(), &[form]).await?;\n\n  if is_create {\n    plugin_hook_notification(notifications, context).await?;\n    let site_view = SiteView::read_local(&mut context.pool()).await?;\n    if !site_view.local_site.disable_email_notifications {\n      let d = NotificationEmailData::PrivateMessage {\n        sender: &view.creator,\n        content: &view.private_message.content,\n      };\n      send_notification_email(\n        local_recipient,\n        view.private_message.local_url(context.settings())?,\n        d,\n        context.settings(),\n      );\n    }\n  }\n  Ok(())\n}\n\npub fn notify_mod_action(actions: Vec<Modlog>, context: &LemmyContext) {\n  // Mod actions should notify the target person. If there is no target person then also no\n  // notification. This means each mod action can only notify a single person (eg it is not possible\n  // to notify all community mods when a community gets removed).\n  let actions: Vec<_> = actions\n    .into_iter()\n    .filter(|a| a.target_person_id.is_some())\n    .collect();\n  if actions.is_empty() {\n    return;\n  }\n\n  let context = context.clone();\n  spawn_try_task(async move {\n    for action in actions {\n      let Some(target_id) = action.target_person_id else {\n        continue;\n      };\n      let Ok(local_recipient) = LocalUserView::read_person(&mut context.pool(), target_id).await\n      else {\n        continue;\n      };\n\n      let form =\n        NotificationInsertForm::new_mod_action(action.id, local_recipient.person.id, action.mod_id);\n      let notifications = Notification::create(&mut context.pool(), &[form]).await?;\n      plugin_hook_notification(notifications, &context).await?;\n\n      let modlog_url = format!(\n        \"{}/modlog?userId={}&actionType={}\",\n        context.settings().get_protocol_and_hostname(),\n        local_recipient.person.id.0,\n        action.kind\n      );\n      let d = NotificationEmailData::ModAction {\n        kind: action.kind,\n        reason: action.reason.as_deref(),\n        is_revert: action.is_revert,\n      };\n      send_notification_email(\n        local_recipient,\n        Url::parse(&modlog_url)?.into(),\n        d,\n        context.settings(),\n      );\n    }\n    Ok(())\n  })\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use crate::{\n    context::LemmyContext,\n    notify::{NotifyData, notify_private_message_internal},\n  };\n  use lemmy_db_schema::{\n    NotificationTypeFilter,\n    assert_length,\n    source::{\n      comment::{Comment, CommentInsertForm},\n      community::{Community, CommunityInsertForm},\n      instance::{Instance, InstanceActions, InstancePersonsBlockForm},\n      notification::{Notification, NotificationInsertForm},\n      person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonUpdateForm},\n      post::{Post, PostInsertForm},\n      private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm},\n    },\n    traits::Blockable,\n  };\n  use lemmy_db_schema_file::enums::NotificationType;\n  use lemmy_db_views_local_user::LocalUserView;\n  use lemmy_db_views_notification::{NotificationData, NotificationView, impls::NotificationQuery};\n  use lemmy_db_views_private_message::PrivateMessageView;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    timmy: LocalUserView,\n    sara: LocalUserView,\n    jessica: Person,\n    community: Community,\n    timmy_post: Post,\n    jessica_post: Post,\n    timmy_comment: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"lemmy-alpha\").await?;\n\n    let timmy = LocalUserView::create_test_user(pool, \"timmy_pcv\", \"\", false).await?;\n\n    let sara = LocalUserView::create_test_user(pool, \"sara_pcv\", \"\", false).await?;\n\n    let jessica_form = PersonInsertForm::test_form(instance.id, \"jessica_mrv\");\n    let jessica = Person::create(pool, &jessica_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"test community pcv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let timmy_post_form =\n      PostInsertForm::new(\"timmy post prv\".into(), timmy.person.id, community.id);\n    let timmy_post = Post::create(pool, &timmy_post_form).await?;\n\n    let jessica_post_form =\n      PostInsertForm::new(\"jessica post prv\".into(), jessica.id, community.id);\n    let jessica_post = Post::create(pool, &jessica_post_form).await?;\n\n    let timmy_comment_form =\n      CommentInsertForm::new(timmy.person.id, timmy_post.id, \"timmy comment prv\".into());\n    let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?;\n\n    Ok(Data {\n      instance,\n      timmy,\n      sara,\n      jessica,\n      community,\n      timmy_post,\n      jessica_post,\n      timmy_comment,\n    })\n  }\n\n  async fn insert_private_message(\n    form: PrivateMessageInsertForm,\n    context: &LemmyContext,\n  ) -> LemmyResult<()> {\n    let pool = &mut context.pool();\n    let pm = PrivateMessage::create(pool, &form).await?;\n    let view = PrivateMessageView::read(pool, pm.id, None).await?;\n    notify_private_message_internal(&view, false, context).await?;\n    Ok(())\n  }\n  async fn setup_private_messages(data: &Data, context: &LemmyContext) -> LemmyResult<()> {\n    let sara_timmy_message_form = PrivateMessageInsertForm::new(\n      data.sara.person.id,\n      data.timmy.person.id,\n      \"sara to timmy\".into(),\n    );\n    insert_private_message(sara_timmy_message_form, context).await?;\n\n    let sara_jessica_message_form = PrivateMessageInsertForm::new(\n      data.sara.person.id,\n      data.jessica.id,\n      \"sara to jessica\".into(),\n    );\n    insert_private_message(sara_jessica_message_form, context).await?;\n\n    let timmy_sara_message_form = PrivateMessageInsertForm::new(\n      data.timmy.person.id,\n      data.sara.person.id,\n      \"timmy to sara\".into(),\n    );\n    insert_private_message(timmy_sara_message_form, context).await?;\n\n    let jessica_timmy_message_form = PrivateMessageInsertForm::new(\n      data.jessica.id,\n      data.timmy.person.id,\n      \"jessica to timmy\".into(),\n    );\n    insert_private_message(jessica_timmy_message_form, context).await?;\n\n    Ok(())\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n    Instance::delete(pool, data.timmy.person.instance_id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn replies() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = init_data(pool).await?;\n\n    // Sara replied to timmys comment with a mention\n    let sara_comment_form = CommentInsertForm::new(\n      data.sara.person.id,\n      data.timmy_post.id,\n      \"@timmy_notify@lemmy-alpha\".into(),\n    );\n    let sara_comment =\n      Comment::create(pool, &sara_comment_form, Some(&data.timmy_comment.path)).await?;\n    NotifyData {\n      post: data.timmy_post.clone(),\n      comment: Some(sara_comment.clone()),\n      creator: data.sara.person.clone(),\n      community: data.community.clone(),\n      do_send_email: false,\n      apub_mentions: None,\n    }\n    .send_internal(context.app_data().clone())\n    .await?;\n\n    // Ensure that reply + mention only generates a single notification\n    let timmy_unread_replies =\n      NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(1, timmy_unread_replies);\n\n    let timmy_inbox = NotificationQuery::default()\n      .list(pool, &data.timmy.person)\n      .await?;\n    assert_length!(1, timmy_inbox);\n\n    if let NotificationData::Comment(comment) = &timmy_inbox[0].data {\n      assert_eq!(sara_comment.id, comment.comment.id);\n      assert_eq!(data.timmy_post.id, comment.post.id);\n      assert_eq!(data.sara.person.id, comment.creator.id);\n      assert_eq!(\n        data.timmy.person.id,\n        timmy_inbox[0].notification.recipient_id\n      );\n      assert_eq!(NotificationType::Reply, timmy_inbox[0].notification.kind);\n    } else {\n      panic!(\"wrong type\")\n    };\n\n    // Mark it as read\n    Notification::mark_read_by_id_and_person(\n      pool,\n      timmy_inbox[0].notification.id,\n      data.timmy.person.id,\n      true,\n    )\n    .await?;\n\n    let timmy_unread_replies =\n      NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(0, timmy_unread_replies);\n\n    let timmy_inbox_unread = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy.person)\n    .await?;\n    assert_length!(0, timmy_inbox_unread);\n\n    // Make sure that marking as unread works\n    Notification::mark_read_by_id_and_person(\n      pool,\n      timmy_inbox[0].notification.id,\n      data.timmy.person.id,\n      false,\n    )\n    .await?;\n\n    let timmy_unread_replies =\n      NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(1, timmy_unread_replies);\n\n    let timmy_inbox_unread = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy.person)\n    .await?;\n    assert_length!(1, timmy_inbox_unread);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn mentions() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Timmy mentions sara in a comment\n    let timmy_mention_sara_form = NotificationInsertForm::new_comment(\n      &data.timmy_comment,\n      data.sara.person.id,\n      NotificationType::Mention,\n    );\n    Notification::create(pool, &[timmy_mention_sara_form]).await?;\n\n    // Jessica mentions sara in a post\n    let jessica_mention_sara_form = NotificationInsertForm::new_post(\n      &data.jessica_post,\n      data.sara.person.id,\n      NotificationType::Mention,\n    );\n    Notification::create(pool, &[jessica_mention_sara_form]).await?;\n\n    // Test to make sure counts and blocks work correctly\n    let sara_unread_mentions =\n      NotificationView::get_unread_count(pool, &data.sara.person, true).await?;\n    assert_eq!(2, sara_unread_mentions);\n\n    let sara_inbox = NotificationQuery::default()\n      .list(pool, &data.sara.person)\n      .await?;\n    assert_length!(2, sara_inbox);\n\n    if let NotificationData::Post(post) = &sara_inbox[0].data {\n      assert_eq!(data.jessica_post.id, post.post.id);\n      assert_eq!(data.jessica.id, post.creator.id);\n    } else {\n      panic!(\"wrong type\")\n    }\n    assert_eq!(data.sara.person.id, sara_inbox[0].notification.recipient_id);\n    assert_eq!(NotificationType::Mention, sara_inbox[0].notification.kind);\n\n    if let NotificationData::Comment(comment) = &sara_inbox[1].data {\n      assert_eq!(data.timmy_comment.id, comment.comment.id);\n      assert_eq!(data.timmy_post.id, comment.post.id);\n      assert_eq!(data.timmy.person.id, comment.creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    assert_eq!(data.sara.person.id, sara_inbox[1].notification.recipient_id);\n    assert_eq!(NotificationType::Mention, sara_inbox[1].notification.kind);\n\n    // Sara blocks timmy, and make sure these counts are now empty\n    let sara_blocks_timmy_form = PersonBlockForm::new(data.sara.person.id, data.timmy.person.id);\n    PersonActions::block(pool, &sara_blocks_timmy_form).await?;\n\n    let sara_unread_mentions_after_block =\n      NotificationView::get_unread_count(pool, &data.sara.person, true).await?;\n    assert_eq!(1, sara_unread_mentions_after_block);\n\n    let sara_inbox_after_block = NotificationQuery::default()\n      .list(pool, &data.sara.person)\n      .await?;\n    assert_length!(1, sara_inbox_after_block);\n\n    // Make sure the comment mention which timmy made is the hidden one\n    assert_eq!(\n      NotificationType::Mention,\n      sara_inbox_after_block[0].notification.kind\n    );\n\n    // Unblock user so we can reuse the same person\n    PersonActions::unblock(pool, &sara_blocks_timmy_form).await?;\n\n    // Test the type filter\n    let sara_inbox_mentions_only = NotificationQuery {\n      type_: Some(NotificationTypeFilter::Other(NotificationType::Mention)),\n      ..Default::default()\n    }\n    .list(pool, &data.sara.person)\n    .await?;\n    assert_length!(2, sara_inbox_mentions_only);\n\n    assert_eq!(\n      NotificationType::Mention,\n      sara_inbox_mentions_only[0].notification.kind\n    );\n\n    // Turn Jessica into a bot account\n    let person_update_form = PersonUpdateForm {\n      bot_account: Some(true),\n      ..Default::default()\n    };\n    Person::update(pool, data.jessica.id, &person_update_form).await?;\n\n    // Make sure sara hides bot\n    let sara_unread_mentions_after_hide_bots =\n      NotificationView::get_unread_count(pool, &data.sara.person, false).await?;\n    assert_eq!(1, sara_unread_mentions_after_hide_bots);\n\n    let sara_inbox_after_hide_bots = NotificationQuery::default()\n      .list(pool, &data.sara.person)\n      .await?;\n    assert_length!(1, sara_inbox_after_hide_bots);\n\n    // Make sure the post mention which jessica made is the hidden one\n    assert_eq!(\n      NotificationType::Mention,\n      sara_inbox_after_hide_bots[0].notification.kind\n    );\n\n    // Mark them all as read\n    Notification::mark_all_as_read(pool, data.sara.person.id).await?;\n\n    // Make sure none come back\n    let sara_unread_mentions =\n      NotificationView::get_unread_count(pool, &data.sara.person, true).await?;\n    assert_eq!(0, sara_unread_mentions);\n\n    let sara_inbox_unread = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.sara.person)\n    .await?;\n    assert_length!(0, sara_inbox_unread);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  /// Useful in combination with filter_map\n  fn to_pm(x: NotificationView) -> Option<PrivateMessageView> {\n    if let NotificationData::PrivateMessage(v) = x.data {\n      Some(v)\n    } else {\n      None\n    }\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn read_private_messages() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = init_data(pool).await?;\n    setup_private_messages(&data, &context).await?;\n\n    let timmy_messages: Vec<_> = NotificationQuery::default()\n      .list(pool, &data.timmy.person)\n      .await?\n      .into_iter()\n      .filter_map(to_pm)\n      .collect();\n\n    // The read even shows timmy's sent messages\n    assert_length!(3, &timmy_messages);\n    assert_eq!(timmy_messages[0].creator.id, data.jessica.id);\n    assert_eq!(timmy_messages[0].recipient.id, data.timmy.person.id);\n    assert_eq!(timmy_messages[1].creator.id, data.timmy.person.id);\n    assert_eq!(timmy_messages[1].recipient.id, data.sara.person.id);\n    assert_eq!(timmy_messages[2].creator.id, data.sara.person.id);\n    assert_eq!(timmy_messages[2].recipient.id, data.timmy.person.id);\n\n    let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(2, timmy_unread);\n\n    let timmy_unread_messages: Vec<_> = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy.person)\n    .await?\n    .into_iter()\n    .filter_map(to_pm)\n    .collect();\n\n    // The unread hides timmy's sent messages\n    assert_length!(2, &timmy_unread_messages);\n    assert_eq!(timmy_unread_messages[0].creator.id, data.jessica.id);\n    assert_eq!(timmy_unread_messages[0].recipient.id, data.timmy.person.id);\n    assert_eq!(timmy_unread_messages[1].creator.id, data.sara.person.id);\n    assert_eq!(timmy_unread_messages[1].recipient.id, data.timmy.person.id);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn ensure_private_message_person_block() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = init_data(pool).await?;\n    setup_private_messages(&data, &context).await?;\n\n    // Make sure blocks are working\n    let timmy_blocks_sara_form = PersonBlockForm::new(data.timmy.person.id, data.sara.person.id);\n\n    let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?;\n\n    assert_eq!(\n      (data.timmy.person.id, data.sara.person.id, true),\n      (\n        inserted_block.person_id,\n        inserted_block.target_id,\n        inserted_block.blocked_at.is_some()\n      )\n    );\n\n    let timmy_messages: Vec<_> = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy.person)\n    .await?\n    .into_iter()\n    .filter_map(to_pm)\n    .collect();\n\n    assert_length!(1, &timmy_messages);\n\n    let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(1, timmy_unread);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn ensure_private_message_instance_block() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = init_data(pool).await?;\n    setup_private_messages(&data, &context).await?;\n\n    // Make sure instance_blocks are working\n    let timmy_blocks_instance_form =\n      InstancePersonsBlockForm::new(data.timmy.person.id, data.jessica.instance_id);\n\n    let inserted_instance_block =\n      InstanceActions::block_persons(pool, &timmy_blocks_instance_form).await?;\n\n    assert_eq!(\n      (data.timmy.person.id, data.jessica.instance_id, true),\n      (\n        inserted_instance_block.person_id,\n        inserted_instance_block.instance_id,\n        inserted_instance_block.blocked_persons_at.is_some()\n      )\n    );\n\n    let timmy_messages: Vec<_> = NotificationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy.person)\n    .await?\n    .into_iter()\n    .filter_map(to_pm)\n    .collect();\n\n    // Messages from Jessica are blocked, only messages from Sara are going through.\n    assert_length!(1, &timmy_messages);\n    assert_eq!(data.sara.person.id, timmy_messages[0].creator.id);\n\n    let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?;\n    assert_eq!(1, timmy_unread);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn private_message_delete_by_recipient() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n    let data = init_data(pool).await?;\n    setup_private_messages(&data, &context).await?;\n\n    let timmy_messages: Vec<_> = NotificationQuery::default()\n      .list(pool, &data.timmy.person)\n      .await?\n      .into_iter()\n      .filter_map(to_pm)\n      .collect();\n\n    let timmy_recipient = timmy_messages\n      .iter()\n      .find(|x| x.recipient.id == data.timmy.person.id);\n    let pm = timmy_recipient.map(|x| &x.private_message);\n\n    // make sure the private message to timmy is found\n    assert_ne!(pm, None);\n\n    if let Some(pm) = pm {\n      let view = PrivateMessageView::read(pool, pm.id, None).await?;\n      let num_sender_messages_before = NotificationQuery::default()\n        .list(pool, &view.creator)\n        .await?\n        .into_iter()\n        .filter_map(to_pm)\n        .count();\n\n      let form = PrivateMessageUpdateForm {\n        deleted_by_recipient: Some(true),\n        ..Default::default()\n      };\n\n      let _pm = PrivateMessage::update(&mut context.pool(), pm.id, &form).await?;\n\n      let timmy_messages_after: Vec<_> = NotificationQuery::default()\n        .list(pool, &data.timmy.person)\n        .await?\n        .into_iter()\n        .filter_map(to_pm)\n        .collect();\n\n      let pm_exists = timmy_messages_after\n        .iter()\n        .find(|x| x.private_message.id == pm.id);\n\n      // the private message should no longer exist\n      assert_eq!(pm_exists, None);\n\n      let num_sender_messages_after = NotificationQuery::default()\n        .list(pool, &view.creator)\n        .await?\n        .into_iter()\n        .filter_map(to_pm)\n        .count();\n\n      // the sender should have the same # of messages\n      assert_eq!(num_sender_messages_before, num_sender_messages_after);\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/plugins.rs",
    "content": "use crate::context::LemmyContext;\nuse anyhow::anyhow;\nuse extism::{\n  FromBytesOwned,\n  Manifest,\n  PluginBuilder,\n  Pool,\n  PoolPlugin,\n  ToBytes,\n  Wasm,\n  WasmMetadata,\n};\nuse extism_convert::Json;\nuse extism_manifest::HttpRequest;\nuse lemmy_db_schema::source::{notification::Notification, person::Person};\nuse lemmy_db_views_notification::NotificationView;\nuse lemmy_db_views_registration_applications::api::CaptchaAnswer;\nuse lemmy_db_views_site::api::{CaptchaResponse, PluginMetadata};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  VERSION,\n  error::{LemmyError, LemmyErrorType, LemmyResult},\n  settings::{SETTINGS, structs::PluginSettings},\n};\nuse serde::{Deserialize, Serialize};\nuse std::{\n  env::var,\n  ops::Deref,\n  path::PathBuf,\n  sync::{LazyLock, OnceLock},\n  time::Duration,\n};\nuse tokio::task::spawn_blocking;\nuse tracing::{error, warn};\nuse url::Url;\n\nconst GET_PLUGIN_TIMEOUT: Duration = Duration::from_secs(1);\n\n/// Call a plugin hook without rewriting data\npub fn plugin_hook_after<T>(name: &'static str, data: &T)\nwhere\n  T: Clone + Serialize + for<'b> Deserialize<'b> + Sync + Send + 'static,\n{\n  let plugins = LemmyPlugins::get_or_init();\n  if !plugins.function_exists(name) {\n    return;\n  }\n\n  let data = data.clone();\n  spawn_blocking(move || run_plugin_hook_after(name, data));\n}\n\n/// Calls plugin hook for the given notifications Loads additional data via\n/// NotificationView, but only if a plugin is active.\npub async fn plugin_hook_notification(\n  notifications: Vec<Notification>,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let name = \"notification_after_create\";\n  let plugins = LemmyPlugins::get_or_init();\n  if !plugins.function_exists(name) {\n    return Ok(());\n  }\n\n  for n in notifications {\n    let person = Person::read(&mut context.pool(), n.recipient_id).await?;\n    let view = NotificationView::read(&mut context.pool(), n.id, &person).await?;\n    spawn_blocking(move || run_plugin_hook_after(name, view));\n  }\n  Ok(())\n}\n\npub async fn plugin_get_captcha() -> LemmyResult<CaptchaResponse> {\n  call_captcha_plugin(\"get_captcha\", ()).await\n}\n\npub async fn plugin_validate_captcha(answer: String, uuid: String) -> LemmyResult<()> {\n  call_captcha_plugin(\"validate_captcha\", CaptchaAnswer { answer, uuid }).await\n}\n\nasync fn call_captcha_plugin<\n  'a,\n  T: ToBytes<'a> + Send + 'static,\n  R: FromBytesOwned + Send + 'static,\n>(\n  name: &'static str,\n  params: T,\n) -> LemmyResult<R> {\n  let plugins = LemmyPlugins::get_or_init();\n  let Some(captcha_plugin) = plugins.captcha_plugin else {\n    return Err(LemmyErrorType::PluginError(\"plugin not loaded\".to_string()).into());\n  };\n\n  spawn_blocking(move || {\n    if let Some(p) = captcha_plugin.pool.get(GET_PLUGIN_TIMEOUT)? {\n      let res = p\n        .call(name, params)\n        .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?;\n      return Ok(res);\n    }\n    Err(LemmyErrorType::PluginError(\"plugin not loaded\".to_string()).into())\n  })\n  .await?\n}\n\npub fn is_captcha_plugin_loaded() -> bool {\n  LemmyPlugins::get_or_init().captcha_plugin.is_some()\n}\n\nfn run_plugin_hook_after<T>(name: &'static str, data: T) -> LemmyResult<()>\nwhere\n  T: Clone + Serialize + for<'b> Deserialize<'b>,\n{\n  let plugins = LemmyPlugins::get_or_init();\n  for p in plugins.plugins {\n    if let Some(plugin) = p.get(name)? {\n      let params: Json<T> = data.clone().into();\n      plugin\n        .call::<Json<T>, ()>(name, params)\n        .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?;\n    }\n  }\n  Ok(())\n}\n\n/// Call a plugin hook which can rewrite data\npub async fn plugin_hook_before<T>(name: &'static str, data: T) -> LemmyResult<T>\nwhere\n  T: Clone + Serialize + for<'a> Deserialize<'a> + Sync + Send + 'static,\n{\n  let plugins = LemmyPlugins::get_or_init();\n  if !plugins.function_exists(name) {\n    return Ok(data);\n  }\n\n  spawn_blocking(move || {\n    let mut res: Json<T> = data.into();\n    for p in plugins.plugins {\n      if let Some(plugin) = p.get(name)? {\n        let r = plugin\n          .call(name, res)\n          .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?;\n        res = r;\n      }\n    }\n    Ok::<_, LemmyError>(res.0)\n  })\n  .await?\n}\n\npub fn plugin_metadata() -> Vec<PluginMetadata> {\n  static METADATA: OnceLock<Vec<PluginMetadata>> = OnceLock::new();\n  if let Some(m) = METADATA.get() {\n    m.clone()\n  } else {\n    // Loading metadata can take multiple seconds. Do this in background task to avoid blocking\n    // /api/v4/site endpoint.\n    std::thread::spawn(|| {\n      METADATA.get_or_init(|| {\n        let mut metadata = vec![];\n        for plugin in LemmyPlugins::get_or_init().plugins {\n          let run = match plugin.pool.get(GET_PLUGIN_TIMEOUT) {\n            Ok(p) => p,\n            Err(e) => {\n              error!(\"Failed to load plugin {}: {e}\", plugin.filename);\n              continue;\n            }\n          };\n          let m = run.and_then(|run| run.call(\"metadata\", 0).ok());\n          if let Some(m) = m {\n            metadata.push(m);\n          } else {\n            // Failed to load plugin metadata, use placeholder\n            metadata.push(PluginMetadata {\n              name: plugin.filename,\n              url: None,\n              description: None,\n            });\n          }\n        }\n        metadata\n      });\n    });\n    // Return empty metadata until loading is finished\n    vec![]\n  }\n}\n\n#[derive(Clone)]\nstruct LemmyPlugins {\n  plugins: Vec<LemmyPlugin>,\n  captcha_plugin: Option<LemmyPlugin>,\n}\n\n#[derive(Clone)]\nstruct LemmyPlugin {\n  pool: Pool,\n  filename: String,\n}\n\nimpl LemmyPlugin {\n  fn init(settings: PluginSettings) -> LemmyResult<Self> {\n    let hash = if cfg!(debug_assertions) || var(\"DANGER_PLUGIN_SKIP_HASH_CHECK\").is_ok() {\n      None\n    } else {\n      // if no hash was provided in config, set a dummy value here to enforce hash check\n      Some(settings.hash.unwrap_or_else(|| \"dummy\".to_string()))\n    };\n    let meta = WasmMetadata { hash, name: None };\n    let (wasm, filename) = if settings.file.starts_with(\"http\") {\n      let name: Option<String> = Url::parse(&settings.file)?\n        .path_segments()\n        .and_then(|mut p| p.next_back())\n        .map(std::string::ToString::to_string);\n      let req = HttpRequest {\n        url: settings.file.clone(),\n        headers: Default::default(),\n        method: None,\n      };\n      (Wasm::Url { req, meta }, name)\n    } else {\n      let path = PathBuf::from(settings.file.clone());\n      let name: Option<String> = path.file_name().map(|n| n.to_string_lossy().to_string());\n      (Wasm::File { path, meta }, name)\n    };\n    let mut manifest = Manifest {\n      wasm: vec![wasm],\n      config: settings.config,\n      allowed_hosts: settings.allowed_hosts,\n      memory: Default::default(),\n      allowed_paths: None,\n      timeout_ms: None,\n    };\n    manifest.config.insert(\n      \"lemmy_url\".to_string(),\n      format!(\"http://{}:{}/\", SETTINGS.bind, SETTINGS.port),\n    );\n    manifest\n      .config\n      .insert(\"lemmy_version\".to_string(), VERSION.to_string());\n    let builder = move || PluginBuilder::new(manifest.clone()).with_wasi(true).build();\n    let pool = Pool::new(builder);\n    Ok(LemmyPlugin {\n      pool,\n      filename: filename.unwrap_or(settings.file),\n    })\n  }\n\n  #[expect(clippy::if_then_some_else_none)]\n  fn get(&self, name: &'static str) -> LemmyResult<Option<PoolPlugin>> {\n    let p = self\n      .pool\n      .get(GET_PLUGIN_TIMEOUT)?\n      .ok_or(anyhow!(\"plugin timeout\"))?;\n\n    Ok(if p.plugin().function_exists(name) {\n      Some(p)\n    } else {\n      None\n    })\n  }\n}\n\nimpl LemmyPlugins {\n  /// Load and initialize all plugins\n  fn get_or_init() -> Self {\n    static PLUGINS: LazyLock<LemmyPlugins> = LazyLock::new(|| {\n      let mut plugins: Vec<_> = SETTINGS\n        .plugins\n        .iter()\n        .flat_map(|p| {\n          LemmyPlugin::init(p.clone())\n            .inspect_err(|e| warn!(\"Failed to load plugin {}: {e}\", p.file))\n            .ok()\n        })\n        .collect();\n\n      let mut captcha_plugin = None;\n      for (i, p) in plugins.iter().enumerate() {\n        let is_captcha = p\n          .pool\n          .function_exists(\"validate_captcha\", GET_PLUGIN_TIMEOUT)\n          .unwrap_or_default()\n          && p\n            .pool\n            .function_exists(\"validate_captcha\", GET_PLUGIN_TIMEOUT)\n            .unwrap_or_default();\n        if is_captcha {\n          captcha_plugin = Some(plugins.remove(i));\n          break;\n        }\n      }\n\n      // Need to put captcha plugin back in so it can be shown in the active plugins list.\n      if let Some(captcha_plugin) = &captcha_plugin {\n        plugins.push(captcha_plugin.clone());\n      }\n      LemmyPlugins {\n        plugins,\n        captcha_plugin,\n      }\n    });\n    PLUGINS.deref().clone()\n  }\n\n  /// Return early if no plugin is loaded for the given hook name\n  fn function_exists(&self, name: &'static str) -> bool {\n    self.plugins.iter().any(|p| {\n      p.pool\n        .function_exists(name, GET_PLUGIN_TIMEOUT)\n        .unwrap_or(false)\n    })\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/request.rs",
    "content": "use crate::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::proxy_image_link,\n};\nuse activitypub_federation::config::Data;\nuse chrono::{DateTime, Utc};\nuse encoding_rs::{Encoding, UTF_8};\nuse futures::StreamExt;\nuse lemmy_db_schema::source::{\n  images::{ImageDetailsInsertForm, LocalImage, LocalImageForm},\n  local_site::LocalSite,\n  post::{Post, PostUpdateForm},\n};\nuse lemmy_db_schema_file::enums::ImageMode;\nuse lemmy_db_views_post::api::{LinkMetadata, OpenGraphData};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  REQWEST_TIMEOUT,\n  VERSION,\n  error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError},\n  settings::structs::Settings,\n};\nuse mime::{Mime, TEXT_HTML};\nuse reqwest::{\n  Client,\n  ClientBuilder,\n  Response,\n  header::{CONTENT_TYPE, LOCATION, RANGE},\n  redirect::Policy,\n};\nuse reqwest_middleware::ClientWithMiddleware;\nuse serde::{Deserialize, Serialize};\nuse std::net::{IpAddr, Ipv4Addr, Ipv6Addr};\nuse tokio::net::lookup_host;\nuse tracing::{info, warn};\nuse url::Url;\nuse urlencoding::encode;\nuse webpage::{HTML, OpengraphObject};\n\npub fn client_builder(settings: &Settings) -> ClientBuilder {\n  // https://github.com/seanmonstar/reqwest/issues/2924\n  let _ = rustls::crypto::ring::default_provider().install_default();\n\n  let user_agent = format!(\n    \"Lemmy/{}; +{}\",\n    *VERSION,\n    settings.get_protocol_and_hostname()\n  );\n\n  Client::builder()\n    .user_agent(user_agent.clone())\n    .timeout(REQWEST_TIMEOUT)\n    .connect_timeout(REQWEST_TIMEOUT)\n    .redirect(Policy::none())\n}\n\n/// Fetches metadata for the given link and optionally generates thumbnail.\npub async fn fetch_link_metadata(\n  url: &Url,\n  context: &LemmyContext,\n  recursion: bool,\n) -> LemmyResult<LinkMetadata> {\n  if url.scheme() != \"http\" && url.scheme() != \"https\" {\n    return Err(LemmyErrorType::InvalidUrl.into());\n  }\n\n  // Resolve the domain and throw an error if it points to any internal IP,\n  // using logic from nightly IpAddr::is_global.\n  if !cfg!(debug_assertions) {\n    // TODO: Replace with IpAddr::is_global() once stabilized\n    //       https://doc.rust-lang.org/std/net/enum.IpAddr.html#method.is_global\n    let domain = url.domain().ok_or(UntranslatedError::UrlWithoutDomain)?;\n    let invalid_ip = lookup_host((domain.to_owned(), 80))\n      .await?\n      .any(|addr| match addr.ip() {\n        IpAddr::V4(addr) => v4_is_invalid(addr),\n        IpAddr::V6(addr) => v6_is_invalid(addr),\n      });\n    if invalid_ip {\n      return Err(LemmyErrorType::InvalidUrl.into());\n    }\n  }\n\n  info!(\"Fetching site metadata for url: {}\", url);\n  // We only fetch the first MB of data in order to not waste bandwidth especially for large\n  // binary files. This high limit is particularly needed for youtube, which includes a lot of\n  // javascript code before the opengraph tags. Mastodon also uses a 1 MB limit:\n  // https://github.com/mastodon/mastodon/blob/295ad6f19a016b3f16e1201ffcbb1b3ad6b455a2/app/lib/request.rb#L213\n  let bytes_to_fetch = 1024 * 1024;\n  let response = context\n    .client()\n    .get(url.as_str())\n    // we only need the first chunk of data. Note that we do not check for Accept-Range so the\n    // server may ignore this and still respond with the full response\n    .header(RANGE, format!(\"bytes=0-{}\", bytes_to_fetch - 1)) /* -1 because inclusive */\n    .send()\n    .await?\n    .error_for_status()?;\n\n  // Manually follow one redirect, using internal IP check. Further redirects are ignored.\n  let location = response\n    .headers()\n    .get(LOCATION)\n    .and_then(|l| l.to_str().ok());\n  if let (Some(location), false) = (location, recursion) {\n    let url = location.parse()?;\n    return Box::pin(fetch_link_metadata(&url, context, true)).await;\n  }\n\n  let mut content_type: Option<Mime> = response\n    .headers()\n    .get(CONTENT_TYPE)\n    .and_then(|h| h.to_str().ok())\n    .and_then(|h| h.parse().ok())\n    // If we don't get a content_type from the response (e.g. if the server is down),\n    // then try to infer the content_type from the file extension.\n    .or(mime_guess::from_path(url.path()).first());\n\n  let opengraph_data = {\n    let is_html = content_type\n      .as_ref()\n      .map(|c| {\n        // application/xhtml+xml is a subset of HTML\n        let application_xhtml: Mime = \"application/xhtml+xml\".parse::<Mime>().unwrap_or(TEXT_HTML);\n        let allowed_mime_types = [TEXT_HTML.essence_str(), application_xhtml.essence_str()];\n        allowed_mime_types.contains(&c.essence_str())\n      })\n      .unwrap_or_default();\n\n    if is_html {\n      // Can't use .text() here, because it only checks the content header, not the actual bytes\n      // https://github.com/LemmyNet/lemmy/issues/1964\n      // So we want to do deep inspection of the actually returned bytes but need to be careful\n      // not spend too much time parsing binary data as HTML\n      // only take first bytes regardless of how many bytes the server returns\n      let html_bytes = collect_bytes_until_limit(response, bytes_to_fetch).await?;\n      extract_opengraph_data(&html_bytes, url)\n        .map_err(|e| info!(\"{e}\"))\n        .unwrap_or_default()\n    } else {\n      let is_octet_type = content_type\n        .as_ref()\n        .map(|c| c.subtype() == \"octet-stream\")\n        .unwrap_or_default();\n\n      // Overwrite the content type if its an octet type\n      if is_octet_type {\n        // Don't need to fetch as much data for this as we do with opengraph\n        let octet_bytes = collect_bytes_until_limit(response, 512).await?;\n        content_type =\n          infer::get(&octet_bytes).map_or(content_type, |t| t.mime_type().parse().ok());\n      }\n\n      Default::default()\n    }\n  };\n\n  Ok(LinkMetadata {\n    opengraph_data,\n    content_type: content_type.map(|c| c.to_string()),\n  })\n}\n\nfn v4_is_invalid(v4: Ipv4Addr) -> bool {\n  v4.is_private()\n    || v4.is_loopback()\n    || v4.is_link_local()\n    || v4.is_multicast()\n    || v4.is_documentation()\n    || v4.is_unspecified()\n    || v4.is_broadcast()\n}\n\nfn v6_is_invalid(v6: Ipv6Addr) -> bool {\n  let is_documentation = matches!(\n    v6.segments(),\n    [0x2001, 0xdb8, ..] | [0x3fff, 0..=0x0fff, ..]\n  );\n  is_documentation\n    || v6.is_loopback()\n    || v6.is_multicast()\n    || v6.is_unique_local()\n    || v6.is_unicast_link_local()\n    || v6.is_unspecified()\n    || v6.to_ipv4_mapped().is_some_and(v4_is_invalid)\n}\n\nasync fn collect_bytes_until_limit(\n  response: Response,\n  requested_bytes: usize,\n) -> Result<Vec<u8>, LemmyError> {\n  let mut stream = response.bytes_stream();\n  let mut bytes = Vec::with_capacity(requested_bytes);\n  while let Some(chunk) = stream.next().await {\n    let chunk = chunk.map_err(LemmyError::from)?;\n    // we may go over the requested size here but the important part is we don't keep aggregating\n    // more chunks than needed\n    bytes.extend_from_slice(&chunk);\n    if bytes.len() >= requested_bytes {\n      bytes.truncate(requested_bytes);\n      break;\n    }\n  }\n  Ok(bytes)\n}\n\n/// Generates and saves a post thumbnail and metadata.\n///\n/// Takes a callback to generate a send activity task, so that post can be federated with metadata.\n///\n/// TODO: `federated_thumbnail` param can be removed once we federate full metadata and can\n///       write it to db directly, without calling this function.\n///       https://github.com/LemmyNet/lemmy/issues/4598\npub async fn generate_post_link_metadata(\n  post: Post,\n  custom_thumbnail: Option<Url>,\n  send_activity: impl FnOnce(Post) -> Option<SendActivityData> + Send + 'static,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let metadata = match &post.url {\n    Some(url) => fetch_link_metadata(url, &context, false)\n      .await\n      .unwrap_or_default(),\n    _ => Default::default(),\n  };\n\n  let is_image_post = metadata\n    .content_type\n    .as_ref()\n    .is_some_and(|content_type| content_type.starts_with(\"image\"));\n\n  // Decide if we are allowed to generate local thumbnail\n  let SiteView {\n    site, local_site, ..\n  } = SiteView::read_local(&mut context.pool()).await?;\n  let allow_sensitive = site.content_warning.is_some();\n  let allow_generate_thumbnail = allow_sensitive || !post.nsfw;\n\n  // Proxy the post url itself if it is an image\n  let url = if let (true, Some(url)) = (is_image_post, post.url.clone()) {\n    Some(Some(\n      proxy_image_link(url.into(), &local_site, false, &context).await?,\n    ))\n  } else {\n    None\n  };\n\n  let image_url = if is_image_post {\n    post.url.clone()\n  } else {\n    metadata.opengraph_data.image.clone()\n  };\n\n  // Attempt to generate a thumbnail depending on the instance settings. Either by proxying,\n  // storing image persistently in pict-rs or returning the remote url directly as thumbnail.\n  let thumbnail_url = if let (false, Some(url)) = (is_image_post, custom_thumbnail) {\n    proxy_image_link(url.clone(), &local_site, true, &context)\n      .await\n      .map_err(|e| warn!(\"Failed to proxy thumbnail: {e}\"))\n      .ok()\n      .or(Some(url.into()))\n  } else if let (true, Some(url)) = (allow_generate_thumbnail, image_url.clone()) {\n    generate_pictrs_thumbnail(&post, &url, &local_site, &context)\n      .await\n      .map_err(|e| warn!(\"Failed to generate thumbnail: {e}\"))\n      .ok()\n      .map(Into::into)\n      .or(image_url)\n  } else {\n    image_url.clone()\n  };\n\n  let form = PostUpdateForm {\n    url,\n    embed_title: Some(metadata.opengraph_data.title),\n    embed_description: Some(metadata.opengraph_data.description),\n    embed_video_url: Some(metadata.opengraph_data.embed_video_url),\n    embed_video_width: Some(metadata.opengraph_data.video_width.map(i32::from)),\n    embed_video_height: Some(metadata.opengraph_data.video_height.map(i32::from)),\n    thumbnail_url: Some(thumbnail_url),\n    url_content_type: Some(metadata.content_type),\n    ..Default::default()\n  };\n  let updated_post = Post::update(&mut context.pool(), post.id, &form).await?;\n  if let Some(send_activity) = send_activity(updated_post) {\n    ActivityChannel::submit_activity(send_activity, &context)?;\n  }\n  Ok(())\n}\n\n/// Extract site metadata from HTML Opengraph attributes.\nfn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraphData> {\n  let html = String::from_utf8_lossy(html_bytes);\n\n  let mut page = HTML::from_string(html.to_string(), None)?;\n\n  // If the web page specifies that it isn't actually UTF-8, re-decode the received bytes with the\n  // proper encoding. If the specified encoding cannot be found, fall back to the original UTF-8\n  // version.\n  if let Some(charset) = page.meta.get(\"charset\")\n    && charset != UTF_8.name()\n    && let Some(encoding) = Encoding::for_label(charset.as_bytes())\n  {\n    page = HTML::from_string(encoding.decode(html_bytes).0.into(), None)?;\n  }\n\n  let page_title = page.title;\n  let page_description = page.description;\n\n  let og_description = page\n    .opengraph\n    .properties\n    .get(\"description\")\n    .map(std::string::ToString::to_string);\n  let og_title = page\n    .opengraph\n    .properties\n    .get(\"title\")\n    .map(std::string::ToString::to_string);\n  let og_image = page\n    .opengraph\n    .images\n    .first()\n    .filter(|v| !v.url.is_empty())\n    // join also works if the target URL is absolute\n    .and_then(|ogo| url.join(&ogo.url).ok());\n\n  let (og_image_width, og_image_height) =\n    extract_opengraph_width_and_height(page.opengraph.images.first());\n\n  let og_embed_url = page\n    .opengraph\n    .videos\n    .first()\n    // Sometime sites provide `og:video` tags with empty content\n    .filter(|v| !v.url.is_empty())\n    // join also works if the target URL is absolute\n    .and_then(|v| url.join(&v.url).ok());\n\n  let (og_video_width, og_video_height) =\n    extract_opengraph_width_and_height(page.opengraph.videos.first());\n\n  Ok(OpenGraphData {\n    title: og_title.or(page_title),\n    description: og_description.or(page_description),\n    image: og_image.map(Into::into),\n    image_width: og_image_width,\n    image_height: og_image_height,\n    embed_video_url: og_embed_url.map(Into::into),\n    video_width: og_video_width,\n    video_height: og_video_height,\n  })\n}\n\nfn extract_opengraph_width_and_height(ogo: Option<&OpengraphObject>) -> (Option<u16>, Option<u16>) {\n  (\n    ogo.and_then(|ogo| extract_opengraph_int_field(ogo, \"width\")),\n    ogo.and_then(|ogo| extract_opengraph_int_field(ogo, \"height\")),\n  )\n}\n\nfn extract_opengraph_int_field(ogo: &OpengraphObject, field: &str) -> Option<u16> {\n  let w = ogo.properties.get(field)?;\n  w.parse::<u16>().ok()\n}\n\n#[derive(Deserialize, Serialize, Debug)]\npub struct PictrsResponse {\n  #[serde(default)]\n  pub files: Vec<PictrsFile>,\n  pub msg: String,\n}\n\n#[derive(Deserialize, Serialize, Debug)]\npub struct PictrsFile {\n  pub file: String,\n  pub details: PictrsFileDetails,\n}\n\nimpl PictrsFile {\n  pub fn image_url(&self, protocol_and_hostname: &str) -> Result<Url, url::ParseError> {\n    Url::parse(&format!(\n      \"{protocol_and_hostname}/api/v4/image/{}\",\n      self.file\n    ))\n  }\n}\n\n/// Stores extra details about a Pictrs image.\n#[derive(Deserialize, Serialize, Debug)]\npub struct PictrsFileDetails {\n  /// In pixels\n  pub width: u16,\n  /// In pixels\n  pub height: u16,\n  pub content_type: String,\n  pub created_at: DateTime<Utc>,\n  pub blurhash: Option<String>,\n}\n\nimpl PictrsFileDetails {\n  /// Builds the image form. This should always use the thumbnail_url,\n  /// Because the post_view joins to it\n  pub fn build_image_details_form(&self, thumbnail_url: &Url) -> ImageDetailsInsertForm {\n    ImageDetailsInsertForm {\n      link: thumbnail_url.clone().into(),\n      width: self.width.into(),\n      height: self.height.into(),\n      content_type: self.content_type.clone(),\n      blurhash: self.blurhash.clone(),\n    }\n  }\n}\n\n#[derive(Deserialize, Serialize, Debug)]\nstruct PictrsPurgeResponse {\n  msg: String,\n  aliases: Vec<String>,\n}\n\n/// Purges an image from pictrs\n/// Note: This should often be coerced from a Result to .ok() in order to fail softly, because:\n/// - It might fail due to image being not local\n/// - It might not be an image\n/// - Pictrs might not be set up\npub async fn purge_image_from_pictrs_url(\n  image_url: &Url,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  is_image_content_type(context.pictrs_client(), image_url).await?;\n\n  let alias = image_url\n    .path_segments()\n    .ok_or(UntranslatedError::PurgeInvalidImageUrl)?\n    .next_back()\n    .ok_or(UntranslatedError::PurgeInvalidImageUrl)?;\n\n  purge_image_from_pictrs(alias, context).await\n}\n\npub async fn purge_image_from_pictrs(alias: &str, context: &LemmyContext) -> LemmyResult<()> {\n  let pictrs_config = context.settings().pictrs()?;\n  let purge_url = format!(\"{}internal/purge?alias={}\", pictrs_config.url, alias);\n\n  let pictrs_api_key = pictrs_config\n    .api_key\n    .ok_or(LemmyErrorType::PictrsApiKeyNotProvided)?;\n  let response = context\n    .pictrs_client()\n    .post(&purge_url)\n    .timeout(REQWEST_TIMEOUT)\n    .header(\"x-api-token\", pictrs_api_key)\n    .send()\n    .await?\n    .error_for_status()?;\n\n  let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?;\n\n  // Pictrs purges return all aliases.\n  let aliases = response.aliases;\n\n  // Delete db rows of aliases.\n  LocalImage::delete_by_aliases(&mut context.pool(), &aliases)\n    .await\n    .ok();\n\n  match response.msg.as_str() {\n    \"ok\" => Ok(()),\n    _ => Err(LemmyErrorType::PictrsPurgeResponseError(response.msg).into()),\n  }\n}\n\n/// Deletes an alias for an image from the local db and pictrs. If it's not the last / only alias,\n/// the image might remain.\n///\n/// # Security Warning\n/// This is a low-level function that doesn't check if the user is allowed to delete the image\n/// alias. Callers MUST check if the user has permission to delete the alias\n/// before calling this function (the user is an admin or the image belongs to the user).\npub async fn delete_image_alias(alias: &str, context: &LemmyContext) -> LemmyResult<()> {\n  let pictrs_config = context.settings().pictrs()?;\n  let url = format!(\"{}internal/delete?alias={}\", pictrs_config.url, &alias);\n\n  // Send the delete request to pictrs.\n  context\n    .pictrs_client()\n    .post(&url)\n    .header(\"X-Api-Token\", pictrs_config.api_key.unwrap_or_default())\n    .timeout(REQWEST_TIMEOUT)\n    .send()\n    .await?\n    .error_for_status()?;\n\n  // Delete db row if any (old Lemmy versions didn't generate this).\n  LocalImage::delete_by_alias(&mut context.pool(), alias)\n    .await\n    .ok();\n  Ok(())\n}\n\n/// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.\nasync fn generate_pictrs_thumbnail(\n  post: &Post,\n  image_url: &Url,\n  local_site: &LocalSite,\n  context: &LemmyContext,\n) -> LemmyResult<Url> {\n  match local_site.image_mode {\n    ImageMode::None => return Ok(image_url.clone()),\n    ImageMode::ProxyAllImages => {\n      return Ok(\n        proxy_image_link(image_url.clone(), local_site, true, context)\n          .await?\n          .into(),\n      );\n    }\n    _ => {}\n  };\n\n  // fetch remote non-pictrs images for persistent thumbnail link\n  let fetch_url = format!(\n    \"{}image/download?url={}&resize={}\",\n    context.settings().pictrs()?.url,\n    encode(image_url.as_str()),\n    local_site.image_max_thumbnail_size\n  );\n\n  let res = context\n    .pictrs_client()\n    .get(&fetch_url)\n    .timeout(REQWEST_TIMEOUT)\n    .send()\n    .await?\n    .error_for_status()?\n    .json::<PictrsResponse>()\n    .await?;\n\n  let image = res\n    .files\n    .first()\n    .ok_or(LemmyErrorType::PictrsResponseError(res.msg))?;\n\n  let form = LocalImageForm {\n    pictrs_alias: image.file.clone(),\n    // For thumbnails, the person_id is the post creator\n    person_id: post.creator_id,\n    thumbnail_for_post_id: Some(Some(post.id)),\n  };\n  let protocol_and_hostname = context.settings().get_protocol_and_hostname();\n  let thumbnail_url = image.image_url(&protocol_and_hostname)?;\n\n  // Also store the details for the image\n  let details_form = image.details.build_image_details_form(&thumbnail_url);\n  LocalImage::create(&mut context.pool(), &form, &details_form).await?;\n\n  Ok(thumbnail_url)\n}\n\n/// Fetches the image details for pictrs proxied images\n///\n/// We don't need to check for image mode, as that's already been done\npub async fn fetch_pictrs_proxied_image_details(\n  image_url: &Url,\n  context: &LemmyContext,\n) -> LemmyResult<PictrsFileDetails> {\n  let pictrs_url = context.settings().pictrs()?.url;\n  let encoded_image_url = encode(image_url.as_str());\n\n  // Pictrs needs you to fetch the proxied image before you can fetch the details\n  let proxy_url = format!(\"{pictrs_url}image/original?proxy={encoded_image_url}\");\n\n  context\n    .pictrs_client()\n    .get(&proxy_url)\n    .timeout(REQWEST_TIMEOUT)\n    .send()\n    .await?\n    .error_for_status()\n    .with_lemmy_type(LemmyErrorType::NotAnImageType)?;\n\n  let details_url = format!(\"{pictrs_url}image/details/original?proxy={encoded_image_url}\");\n\n  let res = context\n    .pictrs_client()\n    .get(&details_url)\n    .timeout(REQWEST_TIMEOUT)\n    .send()\n    .await?\n    .error_for_status()?\n    .json()\n    .await?;\n\n  Ok(res)\n}\n\n// TODO: get rid of this by reading content type from db\n\nasync fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> LemmyResult<()> {\n  let response = client.get(url.as_str()).send().await?;\n  if response\n    .headers()\n    .get(\"Content-Type\")\n    .ok_or(LemmyErrorType::NoContentTypeHeader)?\n    .to_str()?\n    .starts_with(\"image/\")\n  {\n    Ok(())\n  } else {\n    Err(LemmyErrorType::NotAnImageType.into())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::{\n    context::LemmyContext,\n    request::{extract_opengraph_data, fetch_link_metadata},\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n  use url::Url;\n\n  // These helped with testing\n  #[tokio::test]\n  #[serial]\n  async fn test_link_metadata() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let sample_url = Url::parse(\"https://gitlab.com/IzzyOnDroid/repo/-/wikis/FAQ\")?;\n    let sample_res = fetch_link_metadata(&sample_url, &context, false).await?;\n    assert_eq!(\n      Some(\"FAQ · Wiki · IzzyOnDroid / repo · GitLab\".to_string()),\n      sample_res.opengraph_data.title\n    );\n    assert_eq!(\n      Some(\"The F-Droid compatible repo at https://apt.izzysoft.de/fdroid/\".to_string()),\n      sample_res.opengraph_data.description\n    );\n    assert_eq!(\n      Some(\n        Url::parse(\"https://gitlab.com/uploads/-/system/project/avatar/4877469/iod_logo.png\")?\n          .into()\n      ),\n      sample_res.opengraph_data.image\n    );\n    assert_eq!(None, sample_res.opengraph_data.embed_video_url);\n    assert_eq!(\n      Some(mime::TEXT_HTML_UTF_8.to_string()),\n      sample_res.content_type\n    );\n\n    Ok(())\n  }\n\n  #[test]\n  fn test_resolve_image_url() -> LemmyResult<()> {\n    // url that lists the opengraph fields\n    let url = Url::parse(\"https://example.com/one/two.html\")?;\n\n    // root relative url\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content='/image.jpg'></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(\n      metadata.image,\n      Some(Url::parse(\"https://example.com/image.jpg\")?.into())\n    );\n\n    // base relative url\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content='image.jpg'></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(\n      metadata.image,\n      Some(Url::parse(\"https://example.com/one/image.jpg\")?.into())\n    );\n\n    // absolute url\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content='https://cdn.host.com/image.jpg'></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(\n      metadata.image,\n      Some(Url::parse(\"https://cdn.host.com/image.jpg\")?.into())\n    );\n\n    // protocol relative url\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content='//example.com/image.jpg'></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(\n      metadata.image,\n      Some(Url::parse(\"https://example.com/image.jpg\")?.into())\n    );\n\n    // image width and height\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content='/image.jpg'><meta property='og:image:width' content='400' /><meta property='og:image:height' content='200' /></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(\n      (metadata.image_width, metadata.image_height),\n      (Some(400), Some(200))\n    );\n\n    // Empty urls shouldn't return anything\n    let html_bytes = b\"<!DOCTYPE html><html><head><meta property='og:image' content=''></head><body></body></html>\";\n    let metadata = extract_opengraph_data(html_bytes, &url)?;\n    assert_eq!(metadata.image, None);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/send_activity.rs",
    "content": "use crate::context::LemmyContext;\nuse activitypub_federation::config::Data;\nuse either::Either;\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{\n    comment::Comment,\n    community::Community,\n    multi_community::MultiCommunity,\n    person::Person,\n    post::Post,\n    private_message::PrivateMessage,\n    site::Site,\n  },\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_community::api::BanFromCommunity;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse lemmy_utils::error::LemmyResult;\nuse std::sync::LazyLock;\nuse tokio::{\n  sync::{\n    Mutex,\n    mpsc,\n    mpsc::{UnboundedReceiver, UnboundedSender, WeakUnboundedSender},\n  },\n  task::JoinHandle,\n};\nuse url::Url;\n\n#[derive(Debug)]\npub enum SendActivityData {\n  CreatePost(Post),\n  UpdatePost(Post),\n  DeletePost(Post, Person, Community),\n  RemovePost {\n    post: Post,\n    moderator: Person,\n    reason: String,\n    removed: bool,\n    with_replies: bool,\n  },\n  LockPost(Post, Person, bool, String),\n  FeaturePost(Post, Person, bool),\n  CreateComment(Comment),\n  UpdateComment(Comment),\n  DeleteComment(Comment, Person, Community),\n  RemoveComment {\n    comment: Comment,\n    moderator: Person,\n    community: Community,\n    reason: String,\n    with_replies: bool,\n  },\n  LockComment(Comment, Person, bool, String),\n  LikePostOrComment {\n    object_id: DbUrl,\n    actor: Person,\n    community: Community,\n    previous_is_upvote: Option<bool>,\n    new_is_upvote: Option<bool>,\n  },\n  FollowCommunity(Community, Person, bool),\n  FollowMultiCommunity(MultiCommunity, Person, bool),\n  AcceptFollower(CommunityId, PersonId),\n  RejectFollower(CommunityId, PersonId),\n  UpdateCommunity(Person, Community),\n  DeleteCommunity(Person, Community, bool),\n  RemoveCommunity {\n    moderator: Person,\n    community: Community,\n    reason: String,\n    removed: bool,\n  },\n  AddModToCommunity {\n    moderator: Person,\n    community_id: CommunityId,\n    target: PersonId,\n    added: bool,\n  },\n  BanFromCommunity {\n    moderator: Person,\n    community_id: CommunityId,\n    target: Person,\n    data: BanFromCommunity,\n  },\n  BanFromSite {\n    moderator: Person,\n    banned_user: Person,\n    reason: String,\n    remove_or_restore_data: Option<bool>,\n    ban: bool,\n    expires_at: Option<i64>,\n  },\n  CreatePrivateMessage(PrivateMessageView),\n  UpdatePrivateMessage(PrivateMessageView),\n  DeletePrivateMessage(Person, PrivateMessage, bool),\n  DeleteUser(Person, bool),\n  CreateReport {\n    object_id: Url,\n    actor: Person,\n    receiver: Either<Site, Community>,\n    reason: String,\n  },\n  SendResolveReport {\n    object_id: Url,\n    actor: Person,\n    report_creator: Person,\n    receiver: Either<Site, Community>,\n  },\n  UpdateMultiCommunity(MultiCommunity, Person),\n}\n\n// TODO: instead of static, move this into LemmyContext. make sure that stopping the process with\n//       ctrl+c still works.\nstatic ACTIVITY_CHANNEL: LazyLock<ActivityChannel> = LazyLock::new(|| {\n  let (sender, receiver) = mpsc::unbounded_channel();\n  let weak_sender = sender.downgrade();\n  ActivityChannel {\n    weak_sender,\n    receiver: Mutex::new(receiver),\n    keepalive_sender: Mutex::new(Some(sender)),\n  }\n});\n\npub struct ActivityChannel {\n  weak_sender: WeakUnboundedSender<SendActivityData>,\n  receiver: Mutex<UnboundedReceiver<SendActivityData>>,\n  keepalive_sender: Mutex<Option<UnboundedSender<SendActivityData>>>,\n}\n\nimpl ActivityChannel {\n  pub async fn retrieve_activity() -> Option<SendActivityData> {\n    let mut lock = ACTIVITY_CHANNEL.receiver.lock().await;\n    lock.recv().await\n  }\n\n  pub fn submit_activity(data: SendActivityData, _context: &Data<LemmyContext>) -> LemmyResult<()> {\n    // could do `ACTIVITY_CHANNEL.keepalive_sender.lock()` instead and get rid of weak_sender,\n    // not sure which way is more efficient\n    if let Some(sender) = ACTIVITY_CHANNEL.weak_sender.upgrade() {\n      sender.send(data)?;\n    }\n    Ok(())\n  }\n\n  pub async fn close(outgoing_activities_task: JoinHandle<()>) -> LemmyResult<()> {\n    ACTIVITY_CHANNEL.keepalive_sender.lock().await.take();\n    outgoing_activities_task.await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/api_utils/src/utils.rs",
    "content": "use crate::{\n  claims::Claims,\n  context::LemmyContext,\n  request::{delete_image_alias, fetch_pictrs_proxied_image_details, purge_image_from_pictrs_url},\n};\nuse actix_web::{HttpRequest, http::header::Header};\nuse actix_web_httpauth::headers::authorization::{Authorization, Bearer};\nuse chrono::{DateTime, Days, Local, TimeZone, Utc};\nuse enum_map::{EnumMap, enum_map};\nuse lemmy_db_schema::{\n  newtypes::{CommunityId, CommunityTagId, ModlogId, PostId, PostOrCommentId},\n  source::{\n    comment::{Comment, CommentActions, CommentLikeForm},\n    community::{Community, CommunityActions, CommunityUpdateForm},\n    community_tag::{CommunityTag, PostCommunityTag},\n    images::{ImageDetails, RemoteImage},\n    instance::InstanceActions,\n    local_site::LocalSite,\n    local_site_rate_limit::LocalSiteRateLimit,\n    local_site_url_blocklist::LocalSiteUrlBlocklist,\n    modlog::{Modlog, ModlogInsertForm},\n    oauth_account::OAuthAccount,\n    person::{Person, PersonUpdateForm},\n    post::{Post, PostActions, PostLikeForm, PostReadCommentsForm},\n    private_message::PrivateMessage,\n    registration_application::RegistrationApplication,\n    site::Site,\n  },\n  traits::Likeable,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{FederationMode, ImageMode, RegistrationMode},\n};\nuse lemmy_db_views_community_follower_approval::PendingFollowerView;\nuse lemmy_db_views_community_moderator::{CommunityModeratorView, CommunityPersonBanView};\nuse lemmy_db_views_local_image::LocalImageView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl, traits::Crud};\nuse lemmy_utils::{\n  CACHE_DURATION_FEDERATION,\n  CacheLock,\n  MAX_COMMENT_DEPTH_LIMIT,\n  error::{\n    LemmyError,\n    LemmyErrorExt,\n    LemmyErrorExt2,\n    LemmyErrorType,\n    LemmyResult,\n    UntranslatedError,\n  },\n  rate_limit::{ActionType, BucketConfig},\n  settings::SETTINGS,\n  spawn_try_task,\n  utils::{\n    markdown::{image_links::markdown_rewrite_image_links, markdown_check_for_blocked_urls},\n    slurs::remove_slurs,\n    validation::{build_and_check_regex, clean_urls_in_text},\n  },\n};\nuse moka::future::Cache;\nuse regex::{Regex, RegexSet, escape};\nuse std::{collections::HashSet, sync::LazyLock};\nuse tracing::Instrument;\nuse url::{ParseError, Url};\nuse urlencoding::encode;\nuse webmention::{Webmention, WebmentionError};\n\npub const AUTH_COOKIE_NAME: &str = \"jwt\";\n\npub async fn check_is_mod_or_admin(\n  pool: &mut DbPool<'_>,\n  person_id: PersonId,\n  community_id: CommunityId,\n) -> LemmyResult<()> {\n  let is_mod = CommunityModeratorView::check_is_community_moderator(pool, community_id, person_id)\n    .await\n    .is_ok();\n  let is_admin = LocalUserView::read_person(pool, person_id)\n    .await\n    .is_ok_and(|t| t.local_user.admin);\n\n  if is_mod || is_admin {\n    Ok(())\n  } else {\n    Err(LemmyErrorType::NotAModOrAdmin.into())\n  }\n}\n\n/// Checks if a person is an admin, or moderator of any community.\npub(crate) async fn check_is_mod_of_any_or_admin(\n  pool: &mut DbPool<'_>,\n  person_id: PersonId,\n) -> LemmyResult<()> {\n  let is_mod_of_any = CommunityModeratorView::is_community_moderator_of_any(pool, person_id)\n    .await\n    .is_ok();\n  let is_admin = LocalUserView::read_person(pool, person_id)\n    .await\n    .is_ok_and(|t| t.local_user.admin);\n\n  if is_mod_of_any || is_admin {\n    Ok(())\n  } else {\n    Err(LemmyErrorType::NotAModOrAdmin.into())\n  }\n}\n\npub async fn is_mod_or_admin(\n  pool: &mut DbPool<'_>,\n  local_user_view: &LocalUserView,\n  community_id: CommunityId,\n) -> LemmyResult<()> {\n  check_local_user_valid(local_user_view)?;\n  check_is_mod_or_admin(pool, local_user_view.person.id, community_id).await\n}\n\npub async fn is_mod_or_admin_opt(\n  pool: &mut DbPool<'_>,\n  local_user_view: Option<&LocalUserView>,\n  community_id: Option<CommunityId>,\n) -> LemmyResult<()> {\n  if let Some(local_user_view) = local_user_view {\n    if let Some(community_id) = community_id {\n      is_mod_or_admin(pool, local_user_view, community_id).await\n    } else {\n      is_admin(local_user_view)\n    }\n  } else {\n    Err(LemmyErrorType::NotAModOrAdmin.into())\n  }\n}\n\n/// Check that a person is either a mod of any community, or an admin\n///\n/// Should only be used for read operations\npub async fn check_community_mod_of_any_or_admin_action(\n  local_user_view: &LocalUserView,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  let person = &local_user_view.person;\n\n  check_local_user_valid(local_user_view)?;\n  check_is_mod_of_any_or_admin(pool, person.id).await\n}\n\npub fn is_admin(local_user_view: &LocalUserView) -> LemmyResult<()> {\n  check_local_user_valid(local_user_view)?;\n  if !local_user_view.local_user.admin {\n    Err(LemmyErrorType::NotAnAdmin.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn is_top_mod(\n  local_user_view: &LocalUserView,\n  community_mods: &[CommunityModeratorView],\n) -> LemmyResult<()> {\n  check_local_user_valid(local_user_view)?;\n  if local_user_view.person.id\n    != community_mods\n      .first()\n      .map(|cm| cm.moderator.id)\n      .unwrap_or(PersonId(0))\n  {\n    Err(LemmyErrorType::NotTopMod.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Updates the read comment count for a post. Usually done when reading or creating a new comment.\npub async fn update_read_comments(\n  person_id: PersonId,\n  post_id: PostId,\n  read_comments: i32,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  let person_post_agg_form = PostReadCommentsForm::new(post_id, person_id, read_comments);\n  PostActions::update_read_comments(pool, &person_post_agg_form).await?;\n\n  Ok(())\n}\n\npub fn check_local_user_valid(local_user_view: &LocalUserView) -> LemmyResult<()> {\n  // Check for a site ban\n  if local_user_view.banned {\n    return Err(LemmyErrorType::SiteBan.into());\n  }\n  check_local_user_deleted(local_user_view)\n}\n\n/// Check for account deletion\npub fn check_local_user_deleted(local_user_view: &LocalUserView) -> LemmyResult<()> {\n  if local_user_view.person.deleted {\n    Err(LemmyErrorType::Deleted.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Check if the user's email is verified if email verification is turned on\n/// However, skip checking verification if the user is an admin\npub fn check_email_verified(\n  local_user_view: &LocalUserView,\n  site_view: &SiteView,\n) -> LemmyResult<()> {\n  if !local_user_view.local_user.admin\n    && site_view.local_site.require_email_verification\n    && !local_user_view.local_user.email_verified\n  {\n    return Err(LemmyErrorType::EmailNotVerified.into());\n  }\n  Ok(())\n}\n\npub async fn check_registration_application(\n  local_user_view: &LocalUserView,\n  local_site: &LocalSite,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  if (local_site.registration_mode == RegistrationMode::RequireApplication\n    || local_site.registration_mode == RegistrationMode::Closed)\n    && !local_user_view.local_user.accepted_application\n    && !local_user_view.local_user.admin\n  {\n    // Fetch the registration application. If no admin id is present its still pending. Otherwise it\n    // was processed (either accepted or denied).\n    let local_user_id = local_user_view.local_user.id;\n    let registration = RegistrationApplication::find_by_local_user_id(pool, local_user_id).await?;\n    if registration.admin_id.is_some() {\n      return Err(\n        LemmyErrorType::RegistrationDenied(registration.deny_reason.unwrap_or_default()).into(),\n      );\n    } else {\n      return Err(LemmyErrorType::RegistrationApplicationIsPending.into());\n    }\n  }\n  Ok(())\n}\n\n/// Checks that a normal user action (eg posting or voting) is allowed in a given community.\n///\n/// In particular it checks that neither the user nor community are banned or deleted, and that\n/// the user isn't banned.\npub async fn check_community_user_action(\n  local_user_view: &LocalUserView,\n  community: &Community,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  check_local_user_valid(local_user_view)?;\n  check_community_deleted_removed(community)?;\n  CommunityPersonBanView::check(pool, local_user_view.person.id, community.id).await?;\n  PendingFollowerView::check_private_community_action(pool, local_user_view.person.id, community)\n    .await?;\n  InstanceActions::check_ban(pool, local_user_view.person.id, community.instance_id).await?;\n  Ok(())\n}\n\npub fn check_community_deleted_removed(community: &Community) -> LemmyResult<()> {\n  if community.deleted || community.removed {\n    return Err(LemmyErrorType::Deleted.into());\n  }\n  Ok(())\n}\n\n/// Check that the given user can perform a mod action in the community.\n///\n/// In particular it checks that they're an admin or mod, wasn't banned and the community isn't\n/// removed/deleted.\npub async fn check_community_mod_action(\n  local_user_view: &LocalUserView,\n  community: &Community,\n  allow_deleted: bool,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  is_mod_or_admin(pool, local_user_view, community.id).await?;\n  CommunityPersonBanView::check(pool, local_user_view.person.id, community.id).await?;\n\n  // it must be possible to restore deleted community\n  if !allow_deleted {\n    check_community_deleted_removed(community)?;\n  }\n  Ok(())\n}\n\n/// Don't allow creating reports for removed / deleted posts\npub fn check_post_deleted_or_removed(post: &Post) -> LemmyResult<()> {\n  if post.deleted || post.removed {\n    Err(LemmyErrorType::Deleted.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn check_comment_deleted_or_removed(comment: &Comment) -> LemmyResult<()> {\n  if comment.deleted || comment.removed {\n    Err(LemmyErrorType::Deleted.into())\n  } else {\n    Ok(())\n  }\n}\n\npub async fn check_local_vote_mode(\n  is_upvote: Option<bool>,\n  post_or_comment_id: PostOrCommentId,\n  local_site: &LocalSite,\n  person_id: PersonId,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  let (downvote_setting, upvote_setting) = match post_or_comment_id {\n    PostOrCommentId::Post(_) => (local_site.post_downvotes, local_site.post_upvotes),\n    PostOrCommentId::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes),\n  };\n\n  let downvote_fail = is_upvote == Some(false) && downvote_setting == FederationMode::Disable;\n  let upvote_fail = is_upvote == Some(true) && upvote_setting == FederationMode::Disable;\n\n  // Undo previous vote for item if new vote fails\n  if downvote_fail || upvote_fail {\n    match post_or_comment_id {\n      PostOrCommentId::Post(post_id) => {\n        let form = PostLikeForm::new(post_id, person_id, None);\n        PostActions::like(pool, &form).await?;\n      }\n      PostOrCommentId::Comment(comment_id) => {\n        let form = CommentLikeForm::new(comment_id, person_id, None);\n        CommentActions::like(pool, &form).await?;\n      }\n    };\n  }\n  Ok(())\n}\n\n/// Dont allow bots to do certain actions, like voting\npub fn check_bot_account(person: &Person) -> LemmyResult<()> {\n  if person.bot_account {\n    Err(LemmyErrorType::InvalidBotAction.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn check_private_instance(\n  local_user_view: &Option<LocalUserView>,\n  local_site: &LocalSite,\n) -> LemmyResult<()> {\n  if local_user_view.is_none() && local_site.private_instance {\n    Err(LemmyErrorType::InstanceIsPrivate.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// If private messages are disabled, dont allow them to be sent / received\npub fn check_private_messages_enabled(local_user_view: &LocalUserView) -> Result<(), LemmyError> {\n  if !local_user_view.local_user.enable_private_messages {\n    Err(LemmyErrorType::CouldntCreate.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Checks the password length\npub fn password_length_check(pass: &str) -> LemmyResult<()> {\n  if !(10..=60).contains(&pass.chars().count()) {\n    Err(LemmyErrorType::InvalidPassword.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Checks for a honeypot. If this field is filled, fail the rest of the function\npub fn honeypot_check(honeypot: &Option<String>) -> LemmyResult<()> {\n  if honeypot.is_some() && honeypot != &Some(String::new()) {\n    Err(LemmyErrorType::HoneypotFailed.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn local_site_rate_limit_to_rate_limit_config(\n  l: &LocalSiteRateLimit,\n) -> EnumMap<ActionType, BucketConfig> {\n  enum_map! {\n    ActionType::Message => (l.message_max_requests, l.message_interval_seconds),\n    ActionType::Post => (l.post_max_requests, l.post_interval_seconds),\n    ActionType::Register => (l.register_max_requests, l.register_interval_seconds),\n    ActionType::Image => (l.image_max_requests, l.image_interval_seconds),\n    ActionType::Comment => (l.comment_max_requests, l.comment_interval_seconds),\n    ActionType::Search => (l.search_max_requests, l.search_interval_seconds),\n    ActionType::ImportUserSettings => (l.import_user_settings_max_requests, l.import_user_settings_interval_seconds),\n  }\n  .map(|_key, (max_requests, interval)| BucketConfig {\n    max_requests: u32::try_from(max_requests).unwrap_or(0),\n    interval: u32::try_from(interval).unwrap_or(0),\n  })\n}\n\npub async fn slur_regex(context: &LemmyContext) -> LemmyResult<Regex> {\n  static CACHE: CacheLock<Regex> = LazyLock::new(|| {\n    Cache::builder()\n      .max_capacity(1)\n      .time_to_live(CACHE_DURATION_FEDERATION)\n      .build()\n  });\n  Ok(\n    CACHE\n      .try_get_with((), async {\n        let local_site = SiteView::read_local(&mut context.pool())\n          .await\n          .ok()\n          .map(|s| s.local_site);\n        build_and_check_regex(local_site.and_then(|s| s.slur_filter_regex).as_deref())\n      })\n      .await\n      .map_err(|e| anyhow::anyhow!(\"Failed to construct regex: {e}\"))?,\n  )\n}\n\npub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult<RegexSet> {\n  static URL_BLOCKLIST: CacheLock<RegexSet> = LazyLock::new(|| {\n    Cache::builder()\n      .max_capacity(1)\n      .time_to_live(CACHE_DURATION_FEDERATION)\n      .build()\n  });\n\n  Ok(\n    URL_BLOCKLIST\n      .try_get_with::<_, LemmyError>((), async {\n        let urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?;\n\n        // The urls are already validated on saving, so just escape them.\n        // If this regex creation changes it must be synced with\n        // lemmy_utils::utils::markdown::create_url_blocklist_test_regex_set.\n        let regexes = urls.iter().map(|url| format!(r\"\\b{}\\b\", escape(&url.url)));\n\n        let set = RegexSet::new(regexes)?;\n        Ok(set)\n      })\n      .await\n      .map_err(|e| anyhow::anyhow!(\"Failed to build URL blocklist due to `{}`\", e))?,\n  )\n}\n\n// `local_site` is optional so that tests work easily\npub fn check_nsfw_allowed(nsfw: Option<bool>, local_site: Option<&LocalSite>) -> LemmyResult<()> {\n  let is_nsfw = nsfw.unwrap_or_default();\n  let nsfw_disallowed = local_site.is_some_and(|s| s.disallow_nsfw_content);\n\n  if nsfw_disallowed && is_nsfw {\n    return Err(LemmyErrorType::NsfwNotAllowed.into());\n  }\n\n  Ok(())\n}\n\n/// Read the site for an ap_id.\n///\n/// Used for GetCommunityResponse and GetPersonDetails\npub async fn read_site_for_actor(\n  ap_id: DbUrl,\n  context: &LemmyContext,\n) -> LemmyResult<Option<Site>> {\n  let site_id = Site::instance_ap_id_from_url(ap_id.clone().into());\n  let site = Site::read_from_apub_id(&mut context.pool(), &site_id.into()).await?;\n  Ok(site)\n}\n\npub async fn purge_post_images(\n  url: Option<DbUrl>,\n  thumbnail_url: Option<DbUrl>,\n  context: &LemmyContext,\n) {\n  if let Some(url) = url {\n    purge_image_from_pictrs_url(&url, context).await.ok();\n  }\n  if let Some(thumbnail_url) = thumbnail_url {\n    purge_image_from_pictrs_url(&thumbnail_url, context)\n      .await\n      .ok();\n  }\n}\n\n/// Delete local images attributed to a person\nfn delete_local_user_images(person_id: PersonId, context: &LemmyContext) {\n  let context_ = context.clone();\n  spawn_try_task(async move {\n    let pictrs_uploads =\n      LocalImageView::get_all_by_person_id(&mut context_.pool(), person_id).await?;\n\n    // Delete their images\n    for upload in pictrs_uploads {\n      delete_image_alias(&upload.local_image.pictrs_alias, &context_)\n        .await\n        .ok();\n    }\n    Ok(())\n  });\n}\n\n/// Removes or restores user data.\npub async fn remove_or_restore_user_data(\n  mod_person_id: PersonId,\n  banned_person_id: PersonId,\n  removed: bool,\n  reason: &str,\n  bulk_action_parent_id: ModlogId,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let pool = &mut context.pool();\n\n  // These actions are only possible when removing, not restoring\n  if removed {\n    delete_local_user_images(banned_person_id, context);\n\n    // Update the fields to None\n    Person::update(\n      pool,\n      banned_person_id,\n      &PersonUpdateForm {\n        avatar: Some(None),\n        banner: Some(None),\n        bio: Some(None),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    // Communities\n    // Remove all communities where they're the top mod\n    // for now, remove the communities manually\n    let first_mod_communities = CommunityModeratorView::get_community_first_mods(pool).await?;\n\n    // Filter to only this banned users top communities\n    let banned_user_first_communities: Vec<CommunityModeratorView> = first_mod_communities\n      .into_iter()\n      .filter(|fmc| fmc.moderator.id == banned_person_id)\n      .collect();\n\n    for first_mod_community in banned_user_first_communities {\n      let community_id = first_mod_community.community.id;\n      Community::update(\n        pool,\n        community_id,\n        &CommunityUpdateForm {\n          removed: Some(removed),\n          ..Default::default()\n        },\n      )\n      .await?;\n\n      // Update the fields to None\n      Community::update(\n        pool,\n        community_id,\n        &CommunityUpdateForm {\n          icon: Some(None),\n          banner: Some(None),\n          ..Default::default()\n        },\n      )\n      .await?;\n    }\n\n    // Remove post and comment votes\n    PostActions::remove_all_likes(pool, banned_person_id).await?;\n    CommentActions::remove_all_likes(pool, banned_person_id).await?;\n  }\n\n  // Posts\n  let removed_or_restored_posts =\n    Post::update_removed_for_creator(pool, banned_person_id, removed).await?;\n  create_modlog_entries_for_removed_or_restored_posts(\n    pool,\n    mod_person_id,\n    &removed_or_restored_posts,\n    removed,\n    reason,\n    bulk_action_parent_id,\n  )\n  .await?;\n\n  // Comments\n  let removed_or_restored_comments =\n    Comment::update_removed_for_creator(pool, banned_person_id, removed).await?;\n  create_modlog_entries_for_removed_or_restored_comments(\n    pool,\n    mod_person_id,\n    &removed_or_restored_comments,\n    removed,\n    reason,\n    bulk_action_parent_id,\n  )\n  .await?;\n\n  // Private messages\n  PrivateMessage::update_removed_for_creator(pool, banned_person_id, removed).await?;\n\n  Ok(())\n}\n\nasync fn create_modlog_entries_for_removed_or_restored_posts(\n  pool: &mut DbPool<'_>,\n  mod_person_id: PersonId,\n  posts: &[Post],\n  removed: bool,\n  reason: &str,\n  bulk_action_parent_id: ModlogId,\n) -> LemmyResult<()> {\n  // Build the forms\n  let forms: Vec<_> = posts\n    .iter()\n    .map(|post| {\n      ModlogInsertForm::mod_remove_post(\n        mod_person_id,\n        post,\n        removed,\n        reason,\n        Some(bulk_action_parent_id),\n      )\n    })\n    .collect();\n\n  Modlog::create(pool, &forms).await?;\n\n  Ok(())\n}\n\nasync fn create_modlog_entries_for_removed_or_restored_comments(\n  pool: &mut DbPool<'_>,\n  mod_person_id: PersonId,\n  comments: &[Comment],\n  removed: bool,\n  reason: &str,\n  bulk_action_parent_id: ModlogId,\n) -> LemmyResult<()> {\n  let mut forms: Vec<ModlogInsertForm> = Vec::new();\n\n  for comment in comments {\n    // This is extremely unfortunate, but since the comment table doesn't have community id,\n    // you need to query the post table to get each of them, as they could be in any community\n    let community_id = Post::read(pool, comment.post_id).await?.community_id;\n    let form = ModlogInsertForm::mod_remove_comment(\n      mod_person_id,\n      comment,\n      community_id,\n      removed,\n      reason,\n      Some(bulk_action_parent_id),\n    );\n    forms.push(form);\n  }\n\n  Modlog::create(pool, &forms).await?;\n\n  Ok(())\n}\n\nasync fn create_modlog_entries_for_removed_or_restored_comments_in_community(\n  pool: &mut DbPool<'_>,\n  mod_person_id: PersonId,\n  comments: &[Comment],\n  community_id: CommunityId,\n  removed: bool,\n  reason: &str,\n  bulk_action_parent_id: ModlogId,\n) -> LemmyResult<()> {\n  // Build the forms\n  let forms: Vec<_> = comments\n    .iter()\n    .map(|comment| {\n      ModlogInsertForm::mod_remove_comment(\n        mod_person_id,\n        comment,\n        community_id,\n        removed,\n        reason,\n        Some(bulk_action_parent_id),\n      )\n    })\n    .collect();\n\n  Modlog::create(pool, &forms).await?;\n\n  Ok(())\n}\n\npub async fn remove_or_restore_user_data_in_community(\n  community_id: CommunityId,\n  mod_person_id: PersonId,\n  banned_person_id: PersonId,\n  remove: bool,\n  reason: &str,\n  bulk_action_parent_id: ModlogId,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  // These actions are only possible when removing, not restoring\n  if remove {\n    // Remove post and comment votes\n    PostActions::remove_likes_in_community(pool, banned_person_id, community_id).await?;\n    CommentActions::remove_likes_in_community(pool, banned_person_id, community_id).await?;\n  }\n\n  // Posts\n  let posts =\n    Post::update_removed_for_creator_and_community(pool, banned_person_id, community_id, remove)\n      .await?;\n\n  create_modlog_entries_for_removed_or_restored_posts(\n    pool,\n    mod_person_id,\n    &posts,\n    remove,\n    reason,\n    bulk_action_parent_id,\n  )\n  .await?;\n\n  // Comments\n  let removed_comments =\n    Comment::update_removed_for_creator_and_community(pool, banned_person_id, community_id, remove)\n      .await?;\n\n  create_modlog_entries_for_removed_or_restored_comments_in_community(\n    pool,\n    mod_person_id,\n    &removed_comments,\n    community_id,\n    remove,\n    reason,\n    bulk_action_parent_id,\n  )\n  .await?;\n\n  Ok(())\n}\n\npub async fn purge_user_account(\n  person_id: PersonId,\n  local_instance_id: InstanceId,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let pool = &mut context.pool();\n\n  // Delete their local images, if they're a local user\n  // No need to update avatar and banner, those are handled in Person::delete_account\n  delete_local_user_images(person_id, context);\n\n  // Comments\n  Comment::permadelete_for_creator(pool, person_id)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n  // Posts\n  Post::permadelete_for_creator(pool, person_id)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n  // Leave communities they mod\n  CommunityActions::leave_mod_team_for_all_communities(pool, person_id).await?;\n\n  // Delete the oauth accounts linked to the local user\n  if let Ok(local_user) = LocalUserView::read_person(pool, person_id).await {\n    OAuthAccount::delete_user_accounts(pool, local_user.local_user.id).await?;\n  }\n\n  Person::delete_account(pool, person_id, local_instance_id).await?;\n\n  Ok(())\n}\n\npub fn generate_followers_url(ap_id: &DbUrl) -> Result<DbUrl, ParseError> {\n  Ok(Url::parse(&format!(\"{ap_id}/followers\"))?.into())\n}\n\npub fn generate_inbox_url() -> LemmyResult<DbUrl> {\n  let url = format!(\"{}/inbox\", SETTINGS.get_protocol_and_hostname());\n  Ok(Url::parse(&url)?.into())\n}\n\npub fn generate_outbox_url(ap_id: &DbUrl) -> Result<DbUrl, ParseError> {\n  Ok(Url::parse(&format!(\"{ap_id}/outbox\"))?.into())\n}\n\npub fn generate_featured_url(ap_id: &DbUrl) -> Result<DbUrl, ParseError> {\n  Ok(Url::parse(&format!(\"{ap_id}/featured\"))?.into())\n}\n\npub fn generate_moderators_url(community_id: &DbUrl) -> LemmyResult<DbUrl> {\n  Ok(Url::parse(&format!(\"{community_id}/moderators\"))?.into())\n}\n\n/// Ensure that ban/block expiry is in valid range. If its in past, throw error. If its more\n/// than 10 years in future, convert to permanent ban. Otherwise return the same value.\npub fn check_expire_time(expires_unix_opt: Option<i64>) -> LemmyResult<Option<DateTime<Utc>>> {\n  if let Some(expires_unix) = expires_unix_opt {\n    let expires = Utc\n      .timestamp_opt(expires_unix, 0)\n      .single()\n      .ok_or(LemmyErrorType::InvalidUnixTime)?;\n\n    limit_expire_time(expires)\n  } else {\n    Ok(None)\n  }\n}\n\nfn limit_expire_time(expires: DateTime<Utc>) -> LemmyResult<Option<DateTime<Utc>>> {\n  const MAX_BAN_TERM: Days = Days::new(10 * 365);\n\n  if expires < Local::now() {\n    Err(LemmyErrorType::BanExpirationInPast.into())\n  } else if expires > Local::now() + MAX_BAN_TERM {\n    Ok(None)\n  } else {\n    Ok(Some(expires))\n  }\n}\n\npub fn check_conflicting_like_filters(\n  liked_only: Option<bool>,\n  disliked_only: Option<bool>,\n) -> LemmyResult<()> {\n  if liked_only.unwrap_or_default() && disliked_only.unwrap_or_default() {\n    Err(LemmyErrorType::ContradictingFilters.into())\n  } else {\n    Ok(())\n  }\n}\n\npub async fn process_markdown(\n  text: &str,\n  slur_regex: &Regex,\n  url_blocklist: &RegexSet,\n  local_site: &LocalSite,\n  context: &LemmyContext,\n) -> LemmyResult<String> {\n  let text = remove_slurs(text, slur_regex);\n  let text = clean_urls_in_text(&text);\n\n  markdown_check_for_blocked_urls(&text, url_blocklist)?;\n\n  if local_site.image_mode == ImageMode::ProxyAllImages {\n    let (text, links) = markdown_rewrite_image_links(text);\n    RemoteImage::create(&mut context.pool(), links.clone()).await?;\n\n    // Create images and image detail rows\n    for link in links {\n      // Insert image details for the remote image\n      let details_res = fetch_pictrs_proxied_image_details(&link, context).await;\n      if let Ok(details) = details_res {\n        let proxied = build_proxied_image_url(&link, false, local_site, context)?;\n        let details_form = details.build_image_details_form(&proxied);\n        ImageDetails::create(&mut context.pool(), &details_form).await?;\n      }\n    }\n    Ok(text)\n  } else {\n    Ok(text)\n  }\n}\n\npub async fn process_markdown_opt(\n  text: &Option<String>,\n  slur_regex: &Regex,\n  url_blocklist: &RegexSet,\n  local_site: &LocalSite,\n  context: &LemmyContext,\n) -> LemmyResult<Option<String>> {\n  match text {\n    Some(t) => process_markdown(t, slur_regex, url_blocklist, local_site, context)\n      .await\n      .map(Some),\n    None => Ok(None),\n  }\n}\n\n/// A wrapper for `proxy_image_link` for use in tests.\n///\n/// The parameter `force_image_proxy` is the config value of `pictrs.image_proxy`. Its necessary to\n/// pass as separate parameter so it can be changed in tests.\nasync fn proxy_image_link_internal(\n  link: Url,\n  local_site: &LocalSite,\n  is_thumbnail: bool,\n  context: &LemmyContext,\n) -> LemmyResult<DbUrl> {\n  // Dont rewrite links pointing to local domain.\n  if link.domain() == Some(&context.settings().hostname) {\n    Ok(link.into())\n  } else if local_site.image_mode == ImageMode::ProxyAllImages {\n    RemoteImage::create(&mut context.pool(), vec![link.clone()]).await?;\n\n    let proxied = build_proxied_image_url(&link, is_thumbnail, local_site, context)?;\n    // This should fail softly, since pictrs might not even be running\n    let details_res = fetch_pictrs_proxied_image_details(&link, context).await;\n\n    if let Ok(details) = details_res {\n      let details_form = details.build_image_details_form(&proxied);\n      ImageDetails::create(&mut context.pool(), &details_form).await?;\n    };\n\n    Ok(proxied.into())\n  } else {\n    Ok(link.into())\n  }\n}\n\n/// Rewrite a link to go through `/api/v4/image_proxy` endpoint. This is only for remote urls and\n/// if image_proxy setting is enabled.\npub async fn proxy_image_link(\n  link: Url,\n  local_site: &LocalSite,\n  is_thumbnail: bool,\n  context: &LemmyContext,\n) -> LemmyResult<DbUrl> {\n  proxy_image_link_internal(link, local_site, is_thumbnail, context).await\n}\n\npub async fn proxy_image_link_opt_apub(\n  link: Option<Url>,\n  local_site: &LocalSite,\n  context: &LemmyContext,\n) -> LemmyResult<Option<DbUrl>> {\n  if let Some(l) = link {\n    proxy_image_link(l, local_site, false, context)\n      .await\n      .map(Some)\n  } else {\n    Ok(None)\n  }\n}\n\nfn build_proxied_image_url(\n  link: &Url,\n  is_thumbnail: bool,\n  local_site: &LocalSite,\n  context: &LemmyContext,\n) -> LemmyResult<Url> {\n  let mut url = format!(\n    \"{}/api/v4/image/proxy?url={}\",\n    context.settings().get_protocol_and_hostname(),\n    encode(link.as_str()),\n  );\n  if is_thumbnail {\n    url = format!(\"{url}&max_size={}\", local_site.image_max_thumbnail_size);\n  }\n  Ok(Url::parse(&url)?)\n}\n\npub async fn local_user_view_from_jwt(\n  jwt: &str,\n  context: &LemmyContext,\n) -> LemmyResult<LocalUserView> {\n  let local_user_id = Claims::validate(jwt, context)\n    .await\n    .with_lemmy_type(LemmyErrorType::NotLoggedIn)?;\n  let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;\n  check_local_user_deleted(&local_user_view)?;\n\n  Ok(local_user_view)\n}\n\npub fn read_auth_token(req: &HttpRequest) -> LemmyResult<Option<String>> {\n  // Try reading jwt from auth header\n  if let Ok(header) = Authorization::<Bearer>::parse(req) {\n    Ok(Some(header.as_ref().token().to_string()))\n  }\n  // If that fails, try to read from cookie\n  else if let Some(cookie) = &req.cookie(AUTH_COOKIE_NAME) {\n    Ok(Some(cookie.value().to_string()))\n  }\n  // Otherwise, there's no auth\n  else {\n    Ok(None)\n  }\n}\n\npub fn send_webmention(post: Post, community: &Community) {\n  if let Some(url) = post.url.clone()\n    && community.visibility.can_view_without_login()\n  {\n    spawn_try_task(async move {\n      let mut webmention = Webmention::new::<Url>(post.ap_id.clone().into(), url.clone().into())?;\n      webmention.set_checked(true);\n      match webmention\n        .send()\n        .instrument(tracing::info_span!(\"Sending webmention\"))\n        .await\n      {\n        Err(WebmentionError::NoEndpointDiscovered(_)) => Ok(()),\n        Ok(_) => Ok(()),\n        Err(e) => Err(e).with_lemmy_type(UntranslatedError::CouldntSendWebmention.into()),\n      }\n    });\n  };\n}\n\n/// Returns error if new comment exceeds maximum depth.\n///\n/// Top-level comments have a path like `0.123` where 123 is the comment id. At the second level\n/// it is `0.123.456`, containing the parent id and current comment id.\npub fn check_comment_depth(comment: &Comment) -> LemmyResult<()> {\n  let path = &comment.path.0;\n  let length = path.split('.').count();\n  // Need to increment by one because the path always starts with 0\n  if length > MAX_COMMENT_DEPTH_LIMIT + 1 {\n    Err(LemmyErrorType::MaxCommentDepthReached.into())\n  } else {\n    Ok(())\n  }\n}\n\npub async fn update_post_tags(\n  post: &Post,\n  community_tag_ids: &[CommunityTagId],\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  // validate tags\n  let community_tags = CommunityTag::read_for_community(&mut context.pool(), post.community_id)\n    .await?\n    .into_iter()\n    .map(|t| t.id)\n    .collect::<HashSet<_>>();\n  if !community_tags.is_superset(&community_tag_ids.iter().copied().collect()) {\n    return Err(LemmyErrorType::TagNotInCommunity.into());\n  }\n  PostCommunityTag::update(&mut context.pool(), post, community_tag_ids).await?;\n  Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use diesel_ltree::Ltree;\n  use lemmy_db_schema::{\n    newtypes::{CommentId, LanguageId},\n    test_data::TestData,\n  };\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[test]\n  #[rustfmt::skip]\n  fn password_length() {\n    assert!(password_length_check(\"Õ¼¾°3yË,o¸ãtÌÈú|ÇÁÙAøüÒI©·¤(T]/ð>æºWæ[C¤bªWöaÃÎñ·{=û³&§½K/c\").is_ok());\n    assert!(password_length_check(\"1234567890\").is_ok());\n    assert!(password_length_check(\"short\").is_err());\n    assert!(password_length_check(\"looooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\").is_err());\n  }\n\n  #[test]\n  fn honeypot() {\n    assert!(honeypot_check(&None).is_ok());\n    assert!(honeypot_check(&Some(String::new())).is_ok());\n    assert!(honeypot_check(&Some(\"1\".to_string())).is_err());\n    assert!(honeypot_check(&Some(\"message\".to_string())).is_err());\n  }\n\n  #[test]\n  fn test_limit_ban_term() -> LemmyResult<()> {\n    // Ban expires in past, should throw error\n    assert!(limit_expire_time(Utc::now() - Days::new(5)).is_err());\n\n    // Legitimate ban term, return same value\n    let fourteen_days = Utc::now() + Days::new(14);\n    assert_eq!(limit_expire_time(fourteen_days)?, Some(fourteen_days));\n    let nine_years = Utc::now() + Days::new(365 * 9);\n    assert_eq!(limit_expire_time(nine_years)?, Some(nine_years));\n\n    // Too long ban term, changes to None (permanent ban)\n    assert_eq!(limit_expire_time(Utc::now() + Days::new(365 * 11))?, None);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_proxy_image_link() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n\n    let pool = &mut context.pool();\n    let test_data = TestData::create(pool).await?;\n    let local_site = &test_data.local_site;\n\n    // image from local domain is unchanged\n    let local_url = Url::parse(\"http://lemmy-alpha/image.png\")?;\n    let proxied = proxy_image_link_internal(local_url.clone(), local_site, false, &context).await?;\n    assert_eq!(&local_url, proxied.inner());\n\n    // image from remote domain is proxied\n    let remote_image = Url::parse(\"http://lemmy-beta/image.png\")?;\n    let proxied =\n      proxy_image_link_internal(remote_image.clone(), local_site, false, &context).await?;\n    assert_eq!(\n      \"https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Flemmy-beta%2Fimage.png\",\n      proxied.as_str()\n    );\n\n    // This fails, because the details can't be fetched without pictrs running,\n    // And a remote image won't be inserted.\n    assert!(\n      RemoteImage::validate(&mut context.pool(), remote_image.into())\n        .await\n        .is_ok()\n    );\n\n    test_data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_comment_depth() -> LemmyResult<()> {\n    let mut comment = Comment {\n      id: CommentId(0),\n      creator_id: PersonId(0),\n      post_id: PostId(0),\n      content: String::new(),\n      removed: false,\n      published_at: Utc::now(),\n      updated_at: None,\n      deleted: false,\n      ap_id: Url::parse(\"http://example.com\")?.into(),\n      local: false,\n      path: Ltree(\"0.123\".to_string()),\n      distinguished: false,\n      language_id: LanguageId(0),\n      score: 0,\n      upvotes: 0,\n      downvotes: 0,\n      child_count: 0,\n      hot_rank: 0.0,\n      controversy_rank: 0.0,\n      report_count: 0,\n      unresolved_report_count: 0,\n      federation_pending: false,\n      locked: false,\n    };\n    assert!(check_comment_depth(&comment).is_ok());\n    comment.path = Ltree(\"0.123.456\".to_string());\n    assert!(check_comment_depth(&comment).is_ok());\n\n    // build path with items 1 to 50 which is still acceptable\n    let mut path = \"0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50\".to_string();\n    comment.path = Ltree(path.clone());\n    assert!(check_comment_depth(&comment).is_ok());\n\n    // add one more item and we exceed the max depth\n    path.push_str(\".51\");\n    comment.path = Ltree(path);\n    assert!(check_comment_depth(&comment).is_err());\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/api/routes/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api_routes\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\npublish = false\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\ndefault = []\n\n[dependencies]\nlemmy_api = { workspace = true }\nlemmy_api_crud = { workspace = true }\nlemmy_utils = { workspace = true }\nlemmy_routes = { workspace = true }\nactix-web = { workspace = true }\n"
  },
  {
    "path": "crates/api/routes/src/lib.rs",
    "content": "use actix_web::{guard, web::*};\nuse lemmy_api::{\n  comment::{\n    distinguish::distinguish_comment,\n    like::like_comment,\n    list_comment_likes::list_comment_likes,\n    lock::lock_comment,\n    save::save_comment,\n    warning::create_comment_warning,\n  },\n  community::{\n    add_mod::add_mod_to_community,\n    ban::ban_from_community,\n    block::user_block_community,\n    follow::follow_community,\n    multi_community_follow::follow_multi_community,\n    pending_follows::{approve::post_pending_follows_approve, list::get_pending_follows_list},\n    random::get_random_community,\n    tag::{create_community_tag, delete_community_tag, edit_community_tag},\n    transfer::transfer_community,\n    update_notifications::edit_community_notifications,\n  },\n  federation::{\n    list_comments::{list_comments, list_comments_slim},\n    list_person_content::list_person_content,\n    list_posts::list_posts,\n    read_community::get_community,\n    read_multi_community::read_multi_community,\n    read_person::read_person,\n    resolve_object::resolve_object,\n    search::search,\n    user_settings_backup::{export_settings, import_settings},\n  },\n  local_user::{\n    add_admin::add_admin,\n    ban_person::ban_from_site,\n    block::user_block_person,\n    change_password::change_password,\n    change_password_after_reset::change_password_after_reset,\n    donation_dialog_shown::donation_dialog_shown,\n    export_data::export_data,\n    generate_totp_secret::generate_totp_secret,\n    get_captcha::get_captcha,\n    list_hidden::list_person_hidden,\n    list_liked::list_person_liked,\n    list_logins::list_logins,\n    list_media::list_media,\n    list_read::list_person_read,\n    list_saved::list_person_saved,\n    login::login,\n    logout::logout,\n    note_person::user_note_person,\n    notifications::{\n      list::list_notifications,\n      mark_all_read::mark_all_notifications_read,\n      mark_notification_read::mark_notification_as_read,\n    },\n    resend_verification_email::resend_verification_email,\n    reset_password::reset_password,\n    save_settings::save_user_settings,\n    unread_counts::get_unread_counts,\n    update_totp::edit_totp,\n    user_block_instance::{user_block_instance_communities, user_block_instance_persons},\n    validate_auth::validate_auth,\n    verify_email::verify_email,\n  },\n  post::{\n    feature::feature_post,\n    get_link_metadata::get_link_metadata,\n    hide::hide_post,\n    like::like_post,\n    list_post_likes::list_post_likes,\n    lock::lock_post,\n    mark_many_read::mark_posts_as_read,\n    mark_read::mark_post_as_read,\n    mod_update::mod_edit_post,\n    save::save_post,\n    update_notifications::edit_post_notifications,\n    warning::create_post_warning,\n  },\n  reports::{\n    comment_report::{create::create_comment_report, resolve::resolve_comment_report},\n    community_report::{create::create_community_report, resolve::resolve_community_report},\n    post_report::{create::create_post_report, resolve::resolve_post_report},\n    private_message_report::{create::create_pm_report, resolve::resolve_pm_report},\n    report_combined::list::list_reports,\n  },\n  site::{\n    admin_allow_instance::admin_allow_instance,\n    admin_block_instance::admin_block_instance,\n    admin_list_users::admin_list_users,\n    federated_instances::get_federated_instances,\n    list_all_media::list_all_media,\n    mod_log::get_mod_log,\n    purge::{\n      comment::purge_comment,\n      community::purge_community,\n      person::purge_person,\n      post::purge_post,\n    },\n    registration_applications::{\n      approve::approve_registration_application,\n      get::get_registration_application,\n      list::list_registration_applications,\n    },\n  },\n};\nuse lemmy_api_crud::{\n  comment::{\n    create::create_comment,\n    delete::delete_comment,\n    read::get_comment,\n    remove::remove_comment,\n    update::edit_comment,\n  },\n  community::{\n    create::create_community,\n    delete::delete_community,\n    list::list_communities,\n    remove::remove_community,\n    update::edit_community,\n  },\n  custom_emoji::{\n    create::create_custom_emoji,\n    delete::delete_custom_emoji,\n    list::list_custom_emojis,\n    update::edit_custom_emoji,\n  },\n  multi_community::{\n    create::create_multi_community,\n    create_entry::create_multi_community_entry,\n    delete_entry::delete_multi_community_entry,\n    list::list_multi_communities,\n    update::edit_multi_community,\n  },\n  oauth_provider::{\n    create::create_oauth_provider,\n    delete::delete_oauth_provider,\n    update::edit_oauth_provider,\n  },\n  post::{\n    create::create_post,\n    delete::delete_post,\n    read::get_post,\n    remove::remove_post,\n    update::edit_post,\n  },\n  private_message::{\n    create::create_private_message,\n    delete::delete_private_message,\n    update::edit_private_message,\n  },\n  site::{create::create_site, read::get_site, update::edit_site},\n  tagline::{\n    create::create_tagline,\n    delete::delete_tagline,\n    list::list_taglines,\n    update::edit_tagline,\n  },\n  user::{\n    create::{authenticate_with_oauth, register},\n    delete::delete_account,\n    my_user::get_my_user,\n  },\n};\nuse lemmy_routes::images::{\n  delete::{\n    delete_community_banner,\n    delete_community_icon,\n    delete_image,\n    delete_image_admin,\n    delete_site_banner,\n    delete_site_icon,\n    delete_user_avatar,\n    delete_user_banner,\n  },\n  download::{get_image, image_proxy},\n  pictrs_health,\n  upload::{\n    upload_community_banner,\n    upload_community_icon,\n    upload_image,\n    upload_site_banner,\n    upload_site_icon,\n    upload_user_avatar,\n    upload_user_banner,\n  },\n};\nuse lemmy_utils::rate_limit::RateLimit;\n\npub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) {\n  cfg.service(\n    scope(\"/api/v4\")\n      .wrap(rate_limit.message())\n      // Site\n      .service(\n        scope(\"/site\")\n          .route(\"\", get().to(get_site))\n          .route(\"\", post().to(create_site))\n          .route(\"\", put().to(edit_site))\n          .route(\"/icon\", post().to(upload_site_icon))\n          .route(\"/icon\", delete().to(delete_site_icon))\n          .route(\"/banner\", post().to(upload_site_banner))\n          .route(\"/banner\", delete().to(delete_site_banner)),\n      )\n      .route(\"/modlog\", get().to(get_mod_log))\n      .service(\n        resource(\"/search\")\n          .wrap(rate_limit.search())\n          .route(get().to(search)),\n      )\n      .service(\n        resource(\"/resolve_object\")\n          .wrap(rate_limit.search())\n          .route(get().to(resolve_object)),\n      )\n      // Community\n      .service(\n        resource(\"/community\")\n          .guard(guard::Post())\n          .wrap(rate_limit.register())\n          .route(post().to(create_community)),\n      )\n      .service(\n        scope(\"/community\")\n          .route(\"\", get().to(get_community))\n          .route(\"\", put().to(edit_community))\n          .route(\"\", delete().to(delete_community))\n          .route(\"/random\", get().to(get_random_community))\n          .route(\"/list\", get().to(list_communities))\n          .route(\"/follow\", post().to(follow_community))\n          .route(\"/report\", post().to(create_community_report))\n          .route(\"/report/resolve\", put().to(resolve_community_report))\n          // Mod Actions\n          .route(\"/remove\", post().to(remove_community))\n          .route(\"/transfer\", post().to(transfer_community))\n          .route(\"/ban_user\", post().to(ban_from_community))\n          .route(\"/mod\", post().to(add_mod_to_community))\n          .route(\"/icon\", post().to(upload_community_icon))\n          .route(\"/icon\", delete().to(delete_community_icon))\n          .route(\"/banner\", post().to(upload_community_banner))\n          .route(\"/banner\", delete().to(delete_community_banner))\n          .route(\"/tag\", post().to(create_community_tag))\n          .route(\"/tag\", put().to(edit_community_tag))\n          .route(\"/tag\", delete().to(delete_community_tag))\n          .route(\"/notifications\", post().to(edit_community_notifications))\n          .service(\n            scope(\"/pending_follows\")\n              .route(\"/list\", get().to(get_pending_follows_list))\n              .route(\"/approve\", post().to(post_pending_follows_approve)),\n          ),\n      )\n      .service(\n        scope(\"/multi_community\")\n          .route(\"\", post().to(create_multi_community))\n          .route(\"\", put().to(edit_multi_community))\n          .route(\"\", get().to(read_multi_community))\n          .route(\"/entry\", post().to(create_multi_community_entry))\n          .route(\"/entry\", delete().to(delete_multi_community_entry))\n          .route(\"/list\", get().to(list_multi_communities))\n          .route(\"/follow\", post().to(follow_multi_community)),\n      )\n      .route(\"/federated_instances\", get().to(get_federated_instances))\n      // Post\n      .service(\n        resource(\"/post\")\n          // Handle POST to /post separately to add the post() rate limitter\n          .guard(guard::Post())\n          .wrap(rate_limit.post())\n          .route(post().to(create_post)),\n      )\n      .service(\n        resource(\"/post/site_metadata\")\n          .wrap(rate_limit.search())\n          .route(get().to(get_link_metadata)),\n      )\n      .service(\n        scope(\"/post\")\n          .route(\"\", get().to(get_post))\n          .route(\"\", put().to(edit_post))\n          .route(\"\", delete().to(delete_post))\n          .route(\"/remove\", post().to(remove_post))\n          .route(\"/mark_as_read\", post().to(mark_post_as_read))\n          .route(\"/mark_as_read/many\", post().to(mark_posts_as_read))\n          .route(\"/hide\", post().to(hide_post))\n          .route(\"/lock\", post().to(lock_post))\n          .route(\"/feature\", post().to(feature_post))\n          .route(\"/list\", get().to(list_posts))\n          .route(\"/like\", post().to(like_post))\n          .route(\"/like/list\", get().to(list_post_likes))\n          .route(\"/save\", put().to(save_post))\n          .route(\"/report\", post().to(create_post_report))\n          .route(\"/report/resolve\", put().to(resolve_post_report))\n          .route(\"/notifications\", post().to(edit_post_notifications))\n          .route(\"/mod_edit\", put().to(mod_edit_post))\n          .route(\"/warn\", post().to(create_post_warning)),\n      )\n      // Comment\n      .service(\n        // Handle POST to /comment separately to add the comment() rate limitter\n        resource(\"/comment\")\n          .guard(guard::Post())\n          .wrap(rate_limit.comment())\n          .route(post().to(create_comment)),\n      )\n      .service(\n        scope(\"/comment\")\n          .route(\"\", get().to(get_comment))\n          .route(\"\", put().to(edit_comment))\n          .route(\"\", delete().to(delete_comment))\n          .route(\"/remove\", post().to(remove_comment))\n          .route(\"/distinguish\", post().to(distinguish_comment))\n          .route(\"/like\", post().to(like_comment))\n          .route(\"/like/list\", get().to(list_comment_likes))\n          .route(\"/save\", put().to(save_comment))\n          .route(\"/lock\", post().to(lock_comment))\n          .route(\"/list\", get().to(list_comments))\n          .route(\"/list/slim\", get().to(list_comments_slim))\n          .route(\"/warn\", post().to(create_comment_warning))\n          .route(\"/report\", post().to(create_comment_report))\n          .route(\"/report/resolve\", put().to(resolve_comment_report)),\n      )\n      // Private Message\n      .service(\n        scope(\"/private_message\")\n          .route(\"\", post().to(create_private_message))\n          .route(\"\", put().to(edit_private_message))\n          .route(\"\", delete().to(delete_private_message))\n          .route(\"/report\", post().to(create_pm_report))\n          .route(\"/report/resolve\", put().to(resolve_pm_report)),\n      )\n      // Reports\n      .service(\n        scope(\"/report\")\n          .wrap(rate_limit.message())\n          .route(\"/list\", get().to(list_reports)),\n      )\n      // User\n      .service(\n        scope(\"/account/auth\")\n          .guard(guard::Post())\n          .wrap(rate_limit.register())\n          .route(\"/register\", post().to(register))\n          .route(\"/login\", post().to(login))\n          .route(\"/logout\", post().to(logout))\n          .route(\"/password_reset\", post().to(reset_password))\n          .route(\"/password_change\", post().to(change_password_after_reset))\n          .route(\"/change_password\", put().to(change_password))\n          .route(\"/totp/generate\", post().to(generate_totp_secret))\n          .route(\"/totp/edit\", post().to(edit_totp))\n          .route(\"/verify_email\", post().to(verify_email))\n          .route(\n            \"/resend_verification_email\",\n            post().to(resend_verification_email),\n          ),\n      )\n      .service(\n        scope(\"/account\")\n          .route(\"/auth/get_captcha\", get().to(get_captcha))\n          .route(\"\", get().to(get_my_user))\n          .route(\"/unread_counts\", get().to(get_unread_counts))\n          .service(\n            scope(\"/media\")\n              .route(\"\", delete().to(delete_image))\n              .route(\"/list\", get().to(list_media)),\n          )\n          .service(\n            scope(\"/notification\")\n              .route(\"/list\", get().to(list_notifications))\n              .route(\"/mark_as_read/all\", post().to(mark_all_notifications_read))\n              .route(\"/mark_as_read\", post().to(mark_notification_as_read)),\n          )\n          .route(\"\", delete().to(delete_account))\n          .route(\"/login/list\", get().to(list_logins))\n          .route(\"/validate_auth\", get().to(validate_auth))\n          .route(\"/donation_dialog_shown\", post().to(donation_dialog_shown))\n          .route(\"/avatar\", post().to(upload_user_avatar))\n          .route(\"/avatar\", delete().to(delete_user_avatar))\n          .route(\"/banner\", post().to(upload_user_banner))\n          .route(\"/banner\", delete().to(delete_user_banner))\n          .service(\n            scope(\"/block\")\n              .route(\"/person\", post().to(user_block_person))\n              .route(\"/community\", post().to(user_block_community))\n              .route(\n                \"/instance/communities\",\n                post().to(user_block_instance_communities),\n              )\n              .route(\"/instance/persons\", post().to(user_block_instance_persons)),\n          )\n          .route(\"/saved\", get().to(list_person_saved))\n          .route(\"/read\", get().to(list_person_read))\n          .route(\"/hidden\", get().to(list_person_hidden))\n          .route(\"/liked\", get().to(list_person_liked))\n          .route(\"/settings/save\", put().to(save_user_settings))\n          // Account settings import / export have a strict rate limit\n          .service(\n            scope(\"/settings\")\n              .wrap(rate_limit.import_user_settings())\n              .route(\"/export\", get().to(export_settings))\n              .route(\"/import\", post().to(import_settings)),\n          )\n          .service(\n            resource(\"/data/export\")\n              .wrap(rate_limit.import_user_settings())\n              .route(get().to(export_data)),\n          ),\n      )\n      // User actions\n      .service(\n        scope(\"/person\")\n          .route(\"\", get().to(read_person))\n          .route(\"/content\", get().to(list_person_content))\n          .route(\"/note\", post().to(user_note_person)),\n      )\n      // Admin Actions\n      .service(\n        scope(\"/admin\")\n          .route(\"/add\", post().to(add_admin))\n          .service(\n            scope(\"/registration_application\")\n              .route(\"\", get().to(get_registration_application))\n              .route(\"/list\", get().to(list_registration_applications))\n              .route(\"/approve\", put().to(approve_registration_application)),\n          )\n          .service(\n            scope(\"/purge\")\n              .route(\"/person\", post().to(purge_person))\n              .route(\"/community\", post().to(purge_community))\n              .route(\"/post\", post().to(purge_post))\n              .route(\"/comment\", post().to(purge_comment)),\n          )\n          .service(\n            scope(\"/tagline\")\n              .route(\"\", post().to(create_tagline))\n              .route(\"\", put().to(edit_tagline))\n              .route(\"\", delete().to(delete_tagline))\n              .route(\"/list\", get().to(list_taglines)),\n          )\n          .route(\"/ban\", post().to(ban_from_site))\n          .route(\"/users\", get().to(admin_list_users))\n          .service(\n            scope(\"/instance\")\n              .route(\"/block\", post().to(admin_block_instance))\n              .route(\"/allow\", post().to(admin_allow_instance)),\n          ),\n      )\n      .service(\n        scope(\"/custom_emoji\")\n          .route(\"\", post().to(create_custom_emoji))\n          .route(\"\", put().to(edit_custom_emoji))\n          .route(\"\", delete().to(delete_custom_emoji))\n          .route(\"/list\", get().to(list_custom_emojis)),\n      )\n      .service(\n        scope(\"/oauth_provider\")\n          .route(\"\", post().to(create_oauth_provider))\n          .route(\"\", put().to(edit_oauth_provider))\n          .route(\"\", delete().to(delete_oauth_provider)),\n      )\n      .service(\n        scope(\"/oauth\")\n          .wrap(rate_limit.register())\n          .route(\"/authenticate\", post().to(authenticate_with_oauth)),\n      )\n      .service(\n        scope(\"/image\")\n          .service(\n            resource(\"\")\n              .wrap(rate_limit.image())\n              .route(post().to(upload_image))\n              .route(delete().to(delete_image_admin)),\n          )\n          .route(\"/proxy\", get().to(image_proxy))\n          .route(\"/health\", get().to(pictrs_health))\n          .route(\"/list\", get().to(list_all_media))\n          .route(\"/{filename}\", get().to(get_image)),\n      ),\n  );\n}\n"
  },
  {
    "path": "crates/api/routes_v3/Cargo.toml",
    "content": "[package]\nname = \"lemmy_api_routes_v3\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\npublish = false\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\ndefault = []\n\n[dependencies]\nlemmy_api = { workspace = true }\nlemmy_api_crud = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_db_views_site = { workspace = true }\nlemmy_db_views_post = { workspace = true }\nlemmy_utils = { workspace = true }\nlemmy_api_utils = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nlemmy_db_views_registration_applications = { workspace = true }\nactix-web = { workspace = true }\nchrono = { workspace = true }\nurl = { workspace = true }\nlemmy_api_019 = { package = \"lemmy_api_common\", version = \"0.19.12\" }\nlemmy_db_views_comment = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community = { workspace = true, features = [\"full\"] }\nlemmy_db_views_search_combined = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person = { workspace = true, features = [\"full\"] }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_report_combined = { workspace = true, features = [\"full\"] }\nactivitypub_federation = { workspace = true }\n"
  },
  {
    "path": "crates/api/routes_v3/src/convert.rs",
    "content": "use actix_web::web::Json;\nuse chrono::Utc;\nuse lemmy_api_019::{\n  comment::CommentResponse as CommentResponseV3,\n  lemmy_db_schema::{\n    CommentSortType as CommentSortTypeV3,\n    ListingType as ListingTypeV3,\n    RegistrationMode as RegistrationModeV3,\n    SearchType as SearchTypeV3,\n    SortType as SortTypeV3,\n    SubscribedType as SubscribedTypeV3,\n    aggregates::structs::{\n      CommentAggregates,\n      CommunityAggregates,\n      PersonAggregates,\n      PostAggregates,\n      SiteAggregates,\n    },\n    newtypes::{\n      CommentId as CommentIdV3,\n      CommunityId as CommunityIdV3,\n      DbUrl as DbUrlV3,\n      InstanceId,\n      LanguageId as LanguageIdV3,\n      LocalUserId as LocalUserIdV3,\n      PersonId as PersonIdV3,\n      PostId as PostIdV3,\n      SiteId as SiteIdV3,\n    },\n    sensitive::SensitiveString as SensitiveStringV3,\n    source::{\n      comment::Comment as CommentV3,\n      community::Community as CommunityV3,\n      local_site::LocalSite as LocalSiteV3,\n      local_site_rate_limit::LocalSiteRateLimit as LocalSiteRateLimitV3,\n      local_user::LocalUser as LocalUserV3,\n      local_user_vote_display_mode::LocalUserVoteDisplayMode as LocalUserVoteDisplayModeV3,\n      person::Person as PersonV3,\n      post::Post as PostV3,\n      site::Site as SiteV3,\n    },\n  },\n  lemmy_db_views::structs::{\n    CommentView as CommentViewV3,\n    LocalUserView as LocalUserViewV3,\n    PostView as PostViewV3,\n    SiteView as SiteViewV3,\n  },\n  lemmy_db_views_actor::structs::{CommunityView as CommunityViewV3, PersonView as PersonViewV3},\n  person::LoginResponse as LoginResponseV3,\n  post::PostResponse as PostResponseV3,\n  site::{MyUserInfo as MyUserInfoV3, SearchResponse as SearchResponseV3},\n};\nuse lemmy_api_utils::plugins::is_captcha_plugin_loaded;\nuse lemmy_db_schema::{\n  CommunitySortType,\n  newtypes::LanguageId,\n  source::{\n    comment::Comment,\n    community::Community,\n    local_site::LocalSite,\n    local_user::LocalUser,\n    person::Person,\n    post::Post,\n    site::Site,\n  },\n};\nuse lemmy_db_schema_file::enums::{\n  CommentSortType,\n  CommunityFollowerState,\n  ListingType,\n  PostSortType,\n  RegistrationMode,\n};\nuse lemmy_db_views_comment::{CommentView, api::CommentResponse};\nuse lemmy_db_views_community::CommunityView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::PersonView;\nuse lemmy_db_views_post::{PostView, api::PostResponse};\nuse lemmy_db_views_search_combined::SearchCombinedView;\nuse lemmy_db_views_site::{\n  SiteView,\n  api::{LoginResponse, MyUserInfo},\n};\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse lemmy_utils::error::LemmyResult;\nuse std::sync::LazyLock;\nuse url::Url;\n\n#[expect(clippy::expect_used)]\nstatic DUMMY_URL: LazyLock<DbUrlV3> = LazyLock::new(|| {\n  Url::parse(\"http://example.com\")\n    .expect(\"parse dummy url\")\n    .into()\n});\n\npub(crate) fn convert_local_user_view2(local_user_view: LocalUserView) -> LocalUserViewV3 {\n  let LocalUserView {\n    local_user, person, ..\n  } = local_user_view;\n  let (person, counts) = convert_person(person);\n  let local_user = convert_local_user(local_user);\n  LocalUserViewV3 {\n    local_user_vote_display_mode: LocalUserVoteDisplayModeV3 {\n      local_user_id: local_user.id,\n      score: false,\n      upvotes: true,\n      downvotes: true,\n      upvote_percentage: false,\n    },\n    local_user,\n    person,\n    counts,\n  }\n}\n\npub(crate) fn convert_local_user(local_user: LocalUser) -> LocalUserV3 {\n  let LocalUser {\n    id,\n    person_id,\n    show_nsfw,\n    theme,\n    interface_language,\n    show_avatars,\n    send_notifications_to_email,\n    show_bot_accounts,\n    show_read_posts,\n    email_verified,\n    accepted_application,\n    open_links_in_new_tab,\n    blur_nsfw,\n    infinite_scroll_enabled,\n    totp_2fa_enabled,\n    enable_animated_images,\n    collapse_bot_comments,\n    last_donation_notification_at,\n    ..\n  } = local_user;\n  LocalUserV3 {\n    id: LocalUserIdV3(id.0),\n    person_id: PersonIdV3(person_id.0),\n    password_encrypted: Default::default(),\n    email: None,\n    show_nsfw,\n    theme,\n    default_sort_type: Default::default(),\n    default_listing_type: Default::default(),\n    interface_language,\n    show_avatars,\n    send_notifications_to_email,\n    show_scores: false,\n    show_bot_accounts,\n    show_read_posts,\n    email_verified,\n    accepted_application,\n    totp_2fa_secret: None,\n    open_links_in_new_tab,\n    blur_nsfw,\n    auto_expand: false,\n    infinite_scroll_enabled,\n    admin: false,\n    post_listing_mode: Default::default(),\n    totp_2fa_enabled,\n    enable_keyboard_navigation: false,\n    enable_animated_images,\n    collapse_bot_comments,\n    last_donation_notification: last_donation_notification_at,\n  }\n}\n\npub(crate) fn convert_community_view(community_view: CommunityView) -> CommunityViewV3 {\n  let CommunityView {\n    community,\n    community_actions,\n    ..\n  } = community_view;\n  let counts = CommunityAggregates {\n    community_id: CommunityIdV3(community.id.0),\n    subscribers: community.subscribers.into(),\n    posts: community.posts.into(),\n    comments: community.comments.into(),\n    published: community.published_at,\n    users_active_day: community.users_active_day.into(),\n    users_active_week: community.users_active_week.into(),\n    users_active_month: community.users_active_month.into(),\n    users_active_half_year: community.users_active_half_year.into(),\n    hot_rank: community.hot_rank.into(),\n    subscribers_local: community.subscribers_local.into(),\n  };\n  CommunityViewV3 {\n    community: convert_community(community),\n    subscribed: convert_subscribed_type(community_actions.as_ref().and_then(|c| c.follow_state)),\n    blocked: community_actions\n      .as_ref()\n      .and_then(|c| c.blocked_at)\n      .is_some(),\n    counts,\n    banned_from_community: community_actions.and_then(|c| c.received_ban_at).is_some(),\n  }\n}\n\nfn convert_subscribed_type(state: Option<CommunityFollowerState>) -> SubscribedTypeV3 {\n  match state {\n    Some(CommunityFollowerState::Accepted) => SubscribedTypeV3::Subscribed,\n    Some(CommunityFollowerState::Pending) => SubscribedTypeV3::Pending,\n    Some(CommunityFollowerState::ApprovalRequired) => SubscribedTypeV3::Pending,\n    Some(CommunityFollowerState::Denied) => SubscribedTypeV3::NotSubscribed,\n    None => SubscribedTypeV3::NotSubscribed,\n  }\n}\n\npub(crate) fn convert_post_view(post_view: PostView) -> PostViewV3 {\n  let PostView {\n    post,\n    creator,\n    community,\n    creator_is_admin,\n    creator_is_moderator,\n    creator_banned_from_community,\n    post_actions,\n    community_actions,\n    ..\n  } = post_view;\n  let (post, counts) = convert_post(post);\n  let my_vote = post_actions\n    .as_ref()\n    .and_then(|pa| pa.vote_is_upvote)\n    .map(|vote_is_upvote| if vote_is_upvote { 1 } else { -1 });\n  PostViewV3 {\n    post,\n    creator: convert_person(creator).0,\n    community: convert_community(community),\n    image_details: None,\n    creator_banned_from_community,\n    banned_from_community: community_actions.and_then(|c| c.received_ban_at).is_some(),\n    creator_is_moderator,\n    creator_is_admin,\n    counts,\n    subscribed: SubscribedTypeV3::NotSubscribed,\n    saved: post_actions.as_ref().and_then(|p| p.saved_at).is_some(),\n    read: post_actions.as_ref().and_then(|p| p.read_at).is_some(),\n    hidden: post_actions.as_ref().and_then(|p| p.hidden_at).is_some(),\n    creator_blocked: false,\n    my_vote,\n    unread_comments: 0,\n  }\n}\n\npub(crate) fn convert_comment_view(comment_view: CommentView) -> CommentViewV3 {\n  let CommentView {\n    comment,\n    creator,\n    post,\n    community,\n    creator_is_admin,\n    creator_is_moderator,\n    creator_banned_from_community,\n    comment_actions,\n    ..\n  } = comment_view;\n  let (comment, counts) = convert_comment(comment);\n  let my_vote = comment_actions\n    .as_ref()\n    .and_then(|pa| pa.vote_is_upvote)\n    .map(|vote_is_upvote| if vote_is_upvote { 1 } else { -1 });\n  CommentViewV3 {\n    comment,\n    creator: convert_person(creator).0,\n    post: convert_post(post).0,\n    community: convert_community(community),\n    counts,\n    creator_banned_from_community,\n    banned_from_community: false,\n    creator_is_moderator,\n    creator_is_admin,\n    subscribed: SubscribedTypeV3::NotSubscribed,\n    saved: comment_actions.and_then(|c| c.saved_at).is_some(),\n    creator_blocked: false,\n    my_vote,\n  }\n}\n\npub(crate) fn convert_comment(comment: Comment) -> (CommentV3, CommentAggregates) {\n  let Comment {\n    id,\n    creator_id,\n    post_id,\n    content,\n    removed,\n    published_at,\n    updated_at,\n    deleted,\n    ap_id,\n    local,\n    path,\n    distinguished,\n    language_id,\n    score,\n    upvotes,\n    downvotes,\n    child_count,\n    hot_rank,\n    controversy_rank,\n    ..\n  } = comment;\n  let id = CommentIdV3(id.0);\n  (\n    CommentV3 {\n      id,\n      creator_id: PersonIdV3(creator_id.0),\n      post_id: PostIdV3(post_id.0),\n      content,\n      removed,\n      published: published_at,\n      updated: updated_at,\n      deleted,\n      ap_id: convert_db_url(ap_id),\n      local,\n      path: path.0,\n      distinguished,\n      language_id: LanguageIdV3(language_id.0),\n    },\n    CommentAggregates {\n      comment_id: id,\n      score: score.into(),\n      upvotes: upvotes.into(),\n      downvotes: downvotes.into(),\n      published: published_at,\n      child_count,\n      hot_rank: hot_rank.into(),\n      controversy_rank: controversy_rank.into(),\n    },\n  )\n}\n\npub(crate) fn convert_my_user(my_user: Option<MyUserInfo>) -> Option<MyUserInfoV3> {\n  if let Some(my_user) = my_user {\n    let MyUserInfo {\n      local_user_view, ..\n    } = my_user;\n    Some(MyUserInfoV3 {\n      local_user_view: convert_local_user_view2(local_user_view),\n      follows: vec![],\n      moderates: vec![],\n      community_blocks: vec![],\n      instance_blocks: vec![],\n      person_blocks: vec![],\n      discussion_languages: vec![],\n    })\n  } else {\n    None\n  }\n}\n\npub(crate) fn convert_person(person: Person) -> (PersonV3, PersonAggregates) {\n  let Person {\n    id,\n    name,\n    display_name,\n    avatar,\n    published_at,\n    updated_at,\n    ap_id,\n    bio,\n    local,\n    public_key,\n    last_refreshed_at,\n    banner,\n    deleted,\n    matrix_user_id,\n    bot_account,\n    post_count,\n    post_score,\n    comment_count,\n    comment_score,\n    ..\n  } = person;\n  let id = PersonIdV3(id.0);\n  (\n    PersonV3 {\n      id,\n      name,\n      display_name,\n      avatar: avatar.map(convert_db_url),\n      banned: false,\n      published: published_at,\n      updated: updated_at,\n      actor_id: convert_db_url(ap_id),\n      bio,\n      local,\n      private_key: Default::default(),\n      public_key,\n      last_refreshed_at,\n      banner: banner.map(convert_db_url),\n      deleted,\n      inbox_url: DUMMY_URL.clone(),\n      shared_inbox_url: None,\n      matrix_user_id,\n      bot_account,\n      ban_expires: None,\n      instance_id: Default::default(),\n    },\n    PersonAggregates {\n      person_id: id,\n      post_count: post_count.into(),\n      post_score: post_score.into(),\n      comment_count: comment_count.into(),\n      comment_score: comment_score.into(),\n    },\n  )\n}\n\npub(crate) fn convert_community(community: Community) -> CommunityV3 {\n  let Community {\n    id,\n    name,\n    title,\n    removed,\n    published_at,\n    updated_at,\n    deleted,\n    nsfw,\n    ap_id,\n    local,\n    public_key,\n    last_refreshed_at,\n    icon,\n    banner,\n    posting_restricted_to_mods,\n    instance_id,\n    summary: description,\n    ..\n  } = community;\n  CommunityV3 {\n    id: CommunityIdV3(id.0),\n    name,\n    title,\n    description,\n    removed,\n    published: published_at,\n    updated: updated_at,\n    deleted,\n    nsfw,\n    actor_id: convert_db_url(ap_id),\n    local,\n    private_key: None,\n    public_key,\n    last_refreshed_at,\n    icon: icon.map(convert_db_url),\n    banner: banner.map(convert_db_url),\n    followers_url: None,\n    inbox_url: DUMMY_URL.clone(),\n    shared_inbox_url: None,\n    hidden: false,\n    posting_restricted_to_mods,\n    instance_id: InstanceId(instance_id.0),\n    moderators_url: None,\n    featured_url: None,\n    visibility: Default::default(),\n  }\n}\n\npub(crate) fn convert_post(post: Post) -> (PostV3, PostAggregates) {\n  let Post {\n    id,\n    name,\n    url,\n    body,\n    creator_id,\n    community_id,\n    removed,\n    locked,\n    published_at,\n    updated_at,\n    deleted,\n    nsfw,\n    embed_title,\n    embed_description,\n    thumbnail_url,\n    ap_id,\n    local,\n    embed_video_url,\n    language_id,\n    featured_community,\n    featured_local,\n    url_content_type,\n    alt_text,\n    comments,\n    score,\n    upvotes,\n    downvotes,\n    hot_rank,\n    hot_rank_active,\n    controversy_rank,\n    scaled_rank,\n    ..\n  } = post;\n  let post_id = PostIdV3(id.0);\n  let creator_id = PersonIdV3(creator_id.0);\n  let community_id = CommunityIdV3(community_id.0);\n  (\n    PostV3 {\n      id: post_id,\n      name,\n      url: url.map(convert_db_url),\n      body,\n      creator_id,\n      community_id,\n      removed,\n      locked,\n      published: published_at,\n      updated: updated_at,\n      deleted,\n      nsfw,\n      embed_title,\n      embed_description,\n      thumbnail_url: thumbnail_url.map(convert_db_url),\n      ap_id: convert_db_url(ap_id),\n      local,\n      embed_video_url: embed_video_url.map(convert_db_url),\n      language_id: LanguageIdV3(language_id.0),\n      featured_community,\n      featured_local,\n      url_content_type,\n      alt_text,\n    },\n    PostAggregates {\n      post_id,\n      comments: comments.into(),\n      score: score.into(),\n      upvotes: upvotes.into(),\n      downvotes: downvotes.into(),\n      published: published_at,\n      newest_comment_time_necro: Utc::now(),\n      newest_comment_time: Utc::now(),\n      featured_community,\n      featured_local,\n      hot_rank: hot_rank.into(),\n      hot_rank_active: hot_rank_active.into(),\n      community_id,\n      creator_id,\n      controversy_rank: controversy_rank.into(),\n      instance_id: Default::default(),\n      scaled_rank: scaled_rank.into(),\n    },\n  )\n}\npub(crate) fn convert_site_view(site_view: SiteView) -> SiteViewV3 {\n  let SiteView {\n    site, local_site, ..\n  } = site_view;\n\n  let counts = SiteAggregates {\n    site_id: SiteIdV3(site.id.0),\n    users: local_site.users.into(),\n    posts: local_site.posts.into(),\n    comments: local_site.comments.into(),\n    communities: local_site.communities.into(),\n    users_active_day: local_site.users_active_day.into(),\n    users_active_week: local_site.users_active_week.into(),\n    users_active_month: local_site.users_active_month.into(),\n    users_active_half_year: local_site.users_active_half_year.into(),\n  };\n  SiteViewV3 {\n    site: convert_site(site),\n    local_site: convert_local_site(local_site),\n    local_site_rate_limit: dummy_local_site_rate_limit(),\n    counts,\n  }\n}\n\npub(crate) fn convert_site(site: Site) -> SiteV3 {\n  let Site {\n    id,\n    name,\n    sidebar,\n    published_at,\n    updated_at,\n    icon,\n    banner,\n    summary: description,\n    ap_id,\n    last_refreshed_at,\n    public_key,\n    content_warning,\n    ..\n  } = site;\n  SiteV3 {\n    id: SiteIdV3(id.0),\n    name,\n    sidebar,\n    published: published_at,\n    updated: updated_at,\n    icon: icon.map(convert_db_url),\n    banner: banner.map(convert_db_url),\n    description,\n    last_refreshed_at,\n    actor_id: convert_db_url(ap_id),\n    inbox_url: DUMMY_URL.clone(),\n    private_key: Default::default(),\n    public_key,\n    instance_id: Default::default(),\n    content_warning,\n  }\n}\n\npub(crate) fn convert_db_url(db_url: DbUrl) -> DbUrlV3 {\n  let url: Url = db_url.into();\n  url.into()\n}\n\npub(crate) fn convert_local_site(local_site: LocalSite) -> LocalSiteV3 {\n  let LocalSite {\n    site_id,\n    site_setup,\n    community_creation_admin_only,\n    require_email_verification,\n    application_question,\n    private_instance,\n    default_theme,\n    legal_information,\n    application_email_admins,\n    slur_filter_regex,\n    federation_enabled,\n    published_at,\n    updated_at,\n    reports_email_admins,\n    federation_signed_fetch,\n    registration_mode,\n    ..\n  } = local_site;\n  let registration_mode = match registration_mode {\n    RegistrationMode::Closed => RegistrationModeV3::Closed,\n    RegistrationMode::RequireApplication => RegistrationModeV3::RequireApplication,\n    RegistrationMode::Open => RegistrationModeV3::Open,\n  };\n  LocalSiteV3 {\n    id: Default::default(),\n    site_id: SiteIdV3(site_id.0),\n    site_setup,\n    enable_downvotes: true,\n    enable_nsfw: true,\n    community_creation_admin_only,\n    require_email_verification,\n    application_question,\n    private_instance,\n    default_theme,\n    default_post_listing_type: Default::default(),\n    legal_information,\n    hide_modlog_mod_names: true,\n    application_email_admins,\n    slur_filter_regex,\n    actor_name_max_length: 20,\n    federation_enabled,\n    captcha_enabled: is_captcha_plugin_loaded(),\n    captcha_difficulty: String::new(),\n    published: published_at,\n    updated: updated_at,\n    registration_mode,\n    reports_email_admins,\n    federation_signed_fetch,\n    default_post_listing_mode: Default::default(),\n    default_sort_type: Default::default(),\n  }\n}\n\nfn dummy_local_site_rate_limit() -> LocalSiteRateLimitV3 {\n  LocalSiteRateLimitV3 {\n    local_site_id: Default::default(),\n    message: 0,\n    message_per_second: 0,\n    post: 0,\n    post_per_second: 0,\n    register: 0,\n    register_per_second: 0,\n    image: 0,\n    image_per_second: 0,\n    comment: 0,\n    comment_per_second: 0,\n    search: 0,\n    search_per_second: 0,\n    published: Utc::now(),\n    updated: None,\n    import_user_settings: 0,\n    import_user_settings_per_second: 0,\n  }\n}\n\npub(crate) fn convert_person_view(person_view: PersonView) -> PersonViewV3 {\n  let PersonView { person, .. } = person_view;\n  let (person, counts) = convert_person(person);\n  PersonViewV3 {\n    person,\n    counts,\n    // explicitly set to false to hide all admin options from ui\n    is_admin: false,\n  }\n}\npub(crate) fn convert_sensitive(s: SensitiveString) -> SensitiveStringV3 {\n  SensitiveStringV3::from(s.into_inner())\n}\n\npub(crate) fn convert_score(score: i16) -> Option<bool> {\n  if score <= -1 {\n    Some(false)\n  } else if score >= 1 {\n    Some(true)\n  } else {\n    None\n  }\n}\npub(crate) fn convert_search_response(\n  views: Vec<SearchCombinedView>,\n  type_: Option<SearchTypeV3>,\n) -> SearchResponseV3 {\n  let mut res = SearchResponseV3 {\n    type_: type_.unwrap_or(SearchTypeV3::All),\n    comments: vec![],\n    posts: vec![],\n    communities: vec![],\n    users: vec![],\n  };\n  for v in views {\n    match v {\n      SearchCombinedView::Post(p) => res.posts.push(convert_post_view(p)),\n      SearchCombinedView::Comment(c) => res.comments.push(convert_comment_view(c)),\n      SearchCombinedView::Community(c) => res.communities.push(convert_community_view(c)),\n      SearchCombinedView::Person(p) => res.users.push(convert_person_view(p)),\n      SearchCombinedView::MultiCommunity(_) => continue,\n    }\n  }\n  res\n}\n\npub(crate) fn convert_post_listing_sort(\n  sort_type: Option<SortTypeV3>,\n) -> (Option<PostSortType>, Option<i32>) {\n  const HOUR: i32 = 60 * 60;\n  const DAY: i32 = 24 * HOUR;\n  const WEEK: i32 = 7 * DAY;\n  const MONTH: i32 = 30 * DAY;\n  const YEAR: i32 = 365 * DAY;\n\n  let Some(sort_type) = sort_type else {\n    return (None, None);\n  };\n  let max = |s| (Some(s), Some(i32::MAX));\n  let top = |t| (Some(PostSortType::Top), Some(t));\n  match sort_type {\n    SortTypeV3::Active => max(PostSortType::Active),\n    SortTypeV3::Hot => max(PostSortType::Hot),\n    SortTypeV3::New => max(PostSortType::New),\n    SortTypeV3::Old => max(PostSortType::Old),\n    SortTypeV3::Controversial => max(PostSortType::Controversial),\n    SortTypeV3::MostComments => max(PostSortType::MostComments),\n    SortTypeV3::NewComments => max(PostSortType::NewComments),\n    SortTypeV3::Scaled => max(PostSortType::Scaled),\n    SortTypeV3::TopHour => top(HOUR),\n    SortTypeV3::TopSixHour => top(6 * HOUR),\n    SortTypeV3::TopTwelveHour => top(12 * HOUR),\n    SortTypeV3::TopDay => top(DAY),\n    SortTypeV3::TopWeek => top(WEEK),\n    SortTypeV3::TopAll => top(i32::MAX),\n    SortTypeV3::TopMonth => top(MONTH),\n    SortTypeV3::TopThreeMonths => top(3 * MONTH),\n    SortTypeV3::TopSixMonths => top(6 * MONTH),\n    SortTypeV3::TopNineMonths => top(9 * MONTH),\n    SortTypeV3::TopYear => top(YEAR),\n  }\n}\n\npub(crate) fn convert_comment_listing_sort(sort_type: CommentSortTypeV3) -> CommentSortType {\n  match sort_type {\n    CommentSortTypeV3::Hot => CommentSortType::Hot,\n    CommentSortTypeV3::Top => CommentSortType::Top,\n    CommentSortTypeV3::New => CommentSortType::New,\n    CommentSortTypeV3::Old => CommentSortType::Old,\n    CommentSortTypeV3::Controversial => CommentSortType::Controversial,\n  }\n}\n\npub(crate) fn convert_community_listing_sort(\n  sort_type: Option<SortTypeV3>,\n) -> (Option<CommunitySortType>, Option<i32>) {\n  const HOUR: i32 = 60 * 60;\n  const DAY: i32 = 24 * HOUR;\n  const WEEK: i32 = 7 * DAY;\n  const MONTH: i32 = 30 * DAY;\n  const YEAR: i32 = 365 * DAY;\n\n  let Some(sort_type) = sort_type else {\n    return (Some(CommunitySortType::default()), Some(i32::MAX));\n  };\n  let max = |s| (Some(s), Some(i32::MAX));\n  let top = |t| (Some(CommunitySortType::Hot), Some(t));\n  match sort_type {\n    SortTypeV3::Active\n    | SortTypeV3::Hot\n    | SortTypeV3::MostComments\n    | SortTypeV3::NewComments\n    | SortTypeV3::Controversial\n    | SortTypeV3::Scaled => max(CommunitySortType::Hot),\n    SortTypeV3::New => max(CommunitySortType::New),\n    SortTypeV3::Old => max(CommunitySortType::Old),\n    SortTypeV3::TopHour => top(HOUR),\n    SortTypeV3::TopSixHour => top(6 * HOUR),\n    SortTypeV3::TopTwelveHour => top(12 * HOUR),\n    SortTypeV3::TopDay => top(DAY),\n    SortTypeV3::TopWeek => top(WEEK),\n    SortTypeV3::TopAll => top(i32::MAX),\n    SortTypeV3::TopMonth => top(MONTH),\n    SortTypeV3::TopThreeMonths => top(3 * MONTH),\n    SortTypeV3::TopSixMonths => top(6 * MONTH),\n    SortTypeV3::TopNineMonths => top(9 * MONTH),\n    SortTypeV3::TopYear => top(YEAR),\n  }\n}\n\npub(crate) fn convert_listing_type(listing_type: ListingTypeV3) -> ListingType {\n  match listing_type {\n    ListingTypeV3::All => ListingType::All,\n    ListingTypeV3::Local => ListingType::Local,\n    ListingTypeV3::Subscribed => ListingType::Subscribed,\n    ListingTypeV3::ModeratorView => ListingType::ModeratorView,\n  }\n}\npub(crate) fn convert_post_response(res: Json<PostResponse>) -> LemmyResult<Json<PostResponseV3>> {\n  Ok(Json(PostResponseV3 {\n    post_view: convert_post_view(res.0.post_view),\n  }))\n}\npub(crate) fn convert_comment_response(\n  res: Json<CommentResponse>,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  Ok(Json(CommentResponseV3 {\n    comment_view: convert_comment_view(res.0.comment_view),\n    recipient_ids: vec![],\n  }))\n}\n\npub(crate) fn convert_language_ids(data: Vec<LanguageId>) -> Vec<LanguageIdV3> {\n  data.into_iter().map(|l| LanguageIdV3(l.0)).collect()\n}\n\npub(crate) fn convert_login_response(res: LoginResponse) -> LemmyResult<Json<LoginResponseV3>> {\n  let LoginResponse {\n    jwt,\n    registration_created,\n    verify_email_sent,\n  } = res;\n  Ok(Json(LoginResponseV3 {\n    jwt: jwt.map(convert_sensitive),\n    registration_created,\n    verify_email_sent,\n  }))\n}\n"
  },
  {
    "path": "crates/api/routes_v3/src/handlers.rs",
    "content": "use crate::convert::{\n  convert_comment,\n  convert_comment_listing_sort,\n  convert_comment_response,\n  convert_comment_view,\n  convert_community,\n  convert_community_listing_sort,\n  convert_community_view,\n  convert_language_ids,\n  convert_listing_type,\n  convert_login_response,\n  convert_my_user,\n  convert_person,\n  convert_person_view,\n  convert_post,\n  convert_post_listing_sort,\n  convert_post_response,\n  convert_post_view,\n  convert_score,\n  convert_search_response,\n  convert_site,\n  convert_site_view,\n};\nuse activitypub_federation::config::Data as ApubData;\nuse actix_web::{HttpRequest, HttpResponse, web::*};\nuse lemmy_api::{\n  comment::{like::like_comment, save::save_comment},\n  community::{block::user_block_community, follow::follow_community},\n  federation::{\n    list_comments::list_comments,\n    list_posts::list_posts,\n    read_community::get_community,\n    resolve_object::resolve_object,\n    search::search,\n  },\n  local_user::{\n    block::user_block_person,\n    login::login,\n    logout::logout,\n    notifications::mark_all_read::mark_all_notifications_read,\n  },\n  post::{like::like_post, save::save_post},\n  reports::{\n    comment_report::create::create_comment_report,\n    post_report::create::create_post_report,\n  },\n};\nuse lemmy_api_019::{\n  comment::{\n    CommentReportResponse as CommentReportResponseV3,\n    CommentResponse as CommentResponseV3,\n    CreateCommentLike as CreateCommentLikeV3,\n    GetComments as GetCommentsV3,\n    GetCommentsResponse as GetCommentsResponseV3,\n  },\n  community::{\n    BlockCommunityResponse as BlockCommunityResponseV3,\n    CommunityResponse as CommunityResponseV3,\n    GetCommunityResponse as GetCommunityResponseV3,\n    ListCommunities as ListCommunitiesV3,\n    ListCommunitiesResponse as ListCommunitiesResponseV3,\n  },\n  lemmy_db_schema::{\n    SubscribedType as SubscribedTypeV3,\n    newtypes::LanguageId as LanguageIdV3,\n    source::{\n      comment_report::CommentReport as CommentReportV3,\n      language::Language as LanguageV3,\n      local_site_url_blocklist::LocalSiteUrlBlocklist as LocalSiteUrlBlocklistV3,\n      post_report::PostReport as PostReportV3,\n      tagline::Tagline as TaglineV3,\n    },\n  },\n  lemmy_db_views::structs::{\n    CommentReportView as CommentReportViewV3,\n    PostReportView as PostReportViewV3,\n  },\n  lemmy_db_views_actor::structs::CommunityModeratorView as CommunityModeratorViewV3,\n  person::{\n    BlockPersonResponse as BlockPersonResponseV3,\n    GetRepliesResponse as GetRepliesResponseV3,\n    GetUnreadCountResponse as GetUnreadCountResponseV3,\n    LoginResponse as LoginResponseV3,\n  },\n  post::{\n    CreatePost as CreatePostV3,\n    CreatePostLike as CreatePostLikeV3,\n    GetPostResponse as GetPostResponseV3,\n    GetPosts as GetPostsV3,\n    GetPostsResponse as GetPostsResponseV3,\n    PostReportResponse as PostReportResponseV3,\n    PostResponse as PostResponseV3,\n  },\n  site::{\n    GetSiteResponse as GetSiteResponseV3,\n    ResolveObjectResponse as ResolveObjectResponseV3,\n    Search as SearchV3,\n    SearchResponse as SearchResponseV3,\n  },\n};\nuse lemmy_api_crud::{\n  comment::{create::create_comment, delete::delete_comment, update::edit_comment},\n  community::list::list_communities,\n  post::{create::create_post, delete::delete_post, read::get_post, update::edit_post},\n  site::read::get_site,\n  user::{create::register, my_user::get_my_user},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::newtypes::{CommentId, CommunityId, LanguageId, PostId};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_comment::api::{\n  CreateComment,\n  CreateCommentLike,\n  DeleteComment,\n  EditComment,\n  GetComments,\n  SaveComment,\n};\nuse lemmy_db_views_community::api::{\n  BlockCommunity,\n  FollowCommunity,\n  GetCommunity,\n  ListCommunities,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::api::BlockPerson;\nuse lemmy_db_views_post::api::{\n  CreatePost,\n  CreatePostLike,\n  DeletePost,\n  EditPost,\n  GetPosts,\n  SavePost,\n};\nuse lemmy_db_views_registration_applications::api::Register;\nuse lemmy_db_views_report_combined::api::{CreateCommentReport, CreatePostReport};\nuse lemmy_db_views_search_combined::{Search, api::GetPost};\nuse lemmy_db_views_site::api::{GetSiteResponse, Login, ResolveObject};\nuse lemmy_utils::error::LemmyResult;\n\npub(crate) async fn get_post_v3(\n  data: Query<GetPost>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetPostResponseV3>> {\n  let post = get_post(data, context, local_user_view).await?.0;\n  Ok(Json(GetPostResponseV3 {\n    post_view: convert_post_view(post.post_view),\n    community_view: convert_community_view(post.community_view),\n    moderators: vec![],\n    cross_posts: post\n      .cross_posts\n      .into_iter()\n      .map(convert_post_view)\n      .collect(),\n  }))\n}\n\npub(crate) async fn list_posts_v3(\n  datav3: Query<GetPostsV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetPostsResponseV3>> {\n  let GetPostsV3 {\n    limit,\n    community_id,\n    community_name,\n    show_hidden,\n    show_read,\n    show_nsfw,\n    type_,\n    sort,\n    page,\n    ..\n  } = datav3.0;\n  let (sort, time_range_seconds) = convert_post_listing_sort(sort);\n  let data = GetPosts {\n    type_: type_.map(convert_listing_type),\n    sort,\n    time_range_seconds,\n    community_id: community_id.map(|id| CommunityId(id.0)),\n    community_name,\n    show_hidden,\n    show_read,\n    show_nsfw,\n    page,\n    limit,\n    ..Default::default()\n  };\n  let res = list_posts(Query(data), context, local_user_view).await?.0;\n  Ok(Json(GetPostsResponseV3 {\n    posts: res.into_iter().map(convert_post_view).collect(),\n    next_page: None,\n  }))\n}\n\npub(crate) async fn list_comments_v3(\n  Query(data): Query<GetCommentsV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetCommentsResponseV3>> {\n  let GetCommentsV3 {\n    max_depth,\n    limit,\n    community_id,\n    community_name,\n    post_id,\n    parent_id,\n    type_,\n    sort,\n    ..\n  } = data;\n  let sort = sort.map(convert_comment_listing_sort);\n  let data = GetComments {\n    type_: type_.map(convert_listing_type),\n    sort,\n    max_depth,\n    page_cursor: None,\n    limit,\n    community_id: community_id.map(|c| CommunityId(c.0)),\n    community_name,\n    post_id: post_id.map(|p| PostId(p.0)),\n    parent_id: parent_id.map(|p| CommentId(p.0)),\n    time_range_seconds: None,\n  };\n  let comments = list_comments(Query(data), context, local_user_view)\n    .await?\n    .0;\n  Ok(Json(GetCommentsResponseV3 {\n    comments: comments.into_iter().map(convert_comment_view).collect(),\n  }))\n}\n\npub(crate) async fn logout_v3(\n  req: HttpRequest,\n  local_user_view: LocalUserView,\n  context: ApubData<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  logout(req, local_user_view, context).await\n}\n\npub(crate) async fn get_site_v3(\n  local_user_view: Option<LocalUserView>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<GetSiteResponseV3>> {\n  let GetSiteResponse {\n    site_view,\n    admins,\n    version,\n    all_languages,\n    discussion_languages,\n    tagline,\n    blocked_urls,\n    ..\n  } = get_site(local_user_view.clone(), context.clone()).await?.0;\n  let my_user = if let Some(local_user_view) = local_user_view {\n    Some(get_my_user(local_user_view, context).await?.0)\n  } else {\n    None\n  };\n  Ok(Json(GetSiteResponseV3 {\n    site_view: convert_site_view(site_view),\n    admins: admins.into_iter().map(convert_person_view).collect(),\n    version,\n    my_user: convert_my_user(my_user),\n    all_languages: all_languages\n      .into_iter()\n      .map(|l| LanguageV3 {\n        id: LanguageIdV3(l.id.0),\n        code: l.code,\n        name: l.name,\n      })\n      .collect(),\n    discussion_languages: convert_language_ids(discussion_languages),\n    taglines: tagline\n      .into_iter()\n      .map(|t| TaglineV3 {\n        id: t.id.0,\n        local_site_id: Default::default(),\n        content: t.content,\n        published: t.published_at,\n        updated: t.updated_at,\n      })\n      .collect(),\n    custom_emojis: vec![],\n    blocked_urls: blocked_urls\n      .into_iter()\n      .map(|b| LocalSiteUrlBlocklistV3 {\n        id: b.id,\n        url: b.url,\n        published: b.published_at,\n        updated: b.updated_at,\n      })\n      .collect(),\n  }))\n}\n\npub(crate) async fn login_v3(\n  data: Json<Login>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<LoginResponseV3>> {\n  let res = login(data, req, context).await?.0;\n  convert_login_response(res)\n}\n\npub(crate) async fn like_comment_v3(\n  Json(data): Json<CreateCommentLikeV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  let CreateCommentLikeV3 { comment_id, score } = data;\n  let data = CreateCommentLike {\n    comment_id: CommentId(comment_id.0),\n    is_upvote: convert_score(score),\n  };\n  let res = like_comment(Json(data), context, local_user_view).await?;\n  convert_comment_response(res)\n}\n\npub(crate) async fn like_post_v3(\n  Json(data): Json<CreatePostLikeV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponseV3>> {\n  let CreatePostLikeV3 { post_id, score } = data;\n  let data = CreatePostLike {\n    post_id: PostId(post_id.0),\n    is_upvote: convert_score(score),\n  };\n  let res = like_post(Json(data), context, local_user_view).await?;\n  convert_post_response(res)\n}\n\npub(crate) async fn create_comment_v3(\n  data: Json<CreateComment>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  let res = Box::pin(create_comment(data, context, local_user_view)).await?;\n  convert_comment_response(res)\n}\n\npub(crate) async fn create_post_v3(\n  Json(data): Json<CreatePostV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponseV3>> {\n  let CreatePostV3 {\n    name,\n    community_id,\n    url,\n    body,\n    alt_text,\n    honeypot,\n    nsfw,\n    language_id,\n    custom_thumbnail,\n  } = data;\n  let data = CreatePost {\n    name,\n    community_id: CommunityId(community_id.0),\n    url,\n    body,\n    alt_text,\n    honeypot,\n    nsfw,\n    language_id: language_id.map(|l| LanguageId(l.0)),\n    custom_thumbnail,\n    tags: None,\n    scheduled_publish_time_at: None,\n  };\n  let res = Box::pin(create_post(Json(data), context, local_user_view)).await?;\n  convert_post_response(res)\n}\n\npub(crate) async fn search_v3(\n  Query(data): Query<SearchV3>,\n  context: ApubData<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<SearchResponseV3>> {\n  let SearchV3 {\n    q,\n    community_id,\n    community_name,\n    creator_id,\n    limit,\n    type_,\n    ..\n  } = data;\n  let data = Search {\n    q,\n    community_id: community_id.map(|i| CommunityId(i.0)),\n    community_name,\n    creator_id: creator_id.map(|i| PersonId(i.0)),\n    limit,\n    ..Default::default()\n  };\n  let res = search(Query(data), context, local_user_view).await?;\n  Ok(Json(convert_search_response(res.0.search, type_)))\n}\n\npub(crate) async fn resolve_object_v3(\n  data: Query<ResolveObject>,\n  context: ApubData<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<ResolveObjectResponseV3>> {\n  let res = resolve_object(data, context, local_user_view).await?;\n  let mut conv = convert_search_response(res.0.resolve.into_iter().collect(), None);\n  Ok(Json(ResolveObjectResponseV3 {\n    comment: conv.comments.pop(),\n    post: conv.posts.pop(),\n    community: conv.communities.pop(),\n    person: conv.users.pop(),\n  }))\n}\n\npub(crate) async fn save_post_v3(\n  data: Json<SavePost>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponseV3>> {\n  let res = save_post(data, context, local_user_view).await?;\n  convert_post_response(res)\n}\n\npub(crate) async fn save_comment_v3(\n  data: Json<SaveComment>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  let res = save_comment(data, context, local_user_view).await?;\n  convert_comment_response(res)\n}\n\npub async fn unread_count_v3(\n  _context: Data<LemmyContext>,\n  _local_user_view: LocalUserView,\n) -> LemmyResult<Json<GetUnreadCountResponseV3>> {\n  // Hardcoded to 0 because new notifications cant be returned via old api.\n  Ok(Json(GetUnreadCountResponseV3 {\n    replies: 0,\n    mentions: 0,\n    private_messages: 0,\n  }))\n}\n\npub async fn mark_all_notifications_read_v3(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<GetRepliesResponseV3>> {\n  mark_all_notifications_read(context, local_user_view).await?;\n  Ok(Json(GetRepliesResponseV3 { replies: vec![] }))\n}\n\npub async fn create_post_report_v3(\n  data: Json<CreatePostReport>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostReportResponseV3>> {\n  let res = Box::pin(create_post_report(data, context, local_user_view))\n    .await?\n    .0\n    .post_report_view;\n  let (post, counts) = convert_post(res.post);\n  let post_report = PostReportV3 {\n    id: Default::default(),\n    creator_id: Default::default(),\n    post_id: Default::default(),\n    original_post_name: Default::default(),\n    original_post_url: Default::default(),\n    original_post_body: Default::default(),\n    reason: Default::default(),\n    resolved: Default::default(),\n    resolver_id: Default::default(),\n    published: Default::default(),\n    updated: Default::default(),\n  };\n  Ok(Json(PostReportResponseV3 {\n    post_report_view: PostReportViewV3 {\n      post_report,\n      post,\n      community: convert_community(res.community),\n      creator: convert_person(res.creator).0,\n      post_creator: convert_person(res.post_creator).0,\n      creator_banned_from_community: false,\n      creator_is_moderator: false,\n      creator_is_admin: false,\n      subscribed: SubscribedTypeV3::NotSubscribed,\n      saved: false,\n      read: false,\n      hidden: false,\n      creator_blocked: false,\n      my_vote: None,\n      unread_comments: 0,\n      counts,\n      resolver: None,\n    },\n  }))\n}\n\npub async fn create_comment_report_v3(\n  data: Json<CreateCommentReport>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentReportResponseV3>> {\n  let res = Box::pin(create_comment_report(data, context, local_user_view))\n    .await?\n    .0\n    .comment_report_view;\n  let (comment, counts) = convert_comment(res.comment);\n  let comment_report = CommentReportV3 {\n    id: Default::default(),\n    creator_id: Default::default(),\n    comment_id: Default::default(),\n    original_comment_text: Default::default(),\n    reason: Default::default(),\n    resolved: Default::default(),\n    resolver_id: Default::default(),\n    published: Default::default(),\n    updated: Default::default(),\n  };\n  Ok(Json(CommentReportResponseV3 {\n    comment_report_view: CommentReportViewV3 {\n      comment_report,\n      comment,\n      post: convert_post(res.post).0,\n      community: convert_community(res.community),\n      creator: convert_person(res.creator).0,\n      comment_creator: convert_person(res.comment_creator).0,\n      creator_banned_from_community: false,\n      creator_is_moderator: false,\n      creator_is_admin: false,\n      subscribed: SubscribedTypeV3::NotSubscribed,\n      saved: false,\n      creator_blocked: false,\n      my_vote: None,\n      counts,\n      resolver: None,\n    },\n  }))\n}\n\npub(crate) async fn get_community_v3(\n  data: Query<GetCommunity>,\n  context: ApubData<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<GetCommunityResponseV3>> {\n  let res = get_community(data, context, local_user_view).await?.0;\n  Ok(Json(GetCommunityResponseV3 {\n    community_view: convert_community_view(res.community_view),\n    site: res.site.map(convert_site),\n    moderators: res\n      .moderators\n      .into_iter()\n      .map(|m| CommunityModeratorViewV3 {\n        community: convert_community(m.community),\n        moderator: convert_person(m.moderator).0,\n      })\n      .collect(),\n    discussion_languages: convert_language_ids(res.discussion_languages),\n  }))\n}\n\npub(crate) async fn follow_community_v3(\n  data: Json<FollowCommunity>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommunityResponseV3>> {\n  let res = follow_community(data, context, local_user_view).await?.0;\n  Ok(Json(CommunityResponseV3 {\n    community_view: convert_community_view(res.community_view),\n    discussion_languages: convert_language_ids(res.discussion_languages),\n  }))\n}\n\npub(crate) async fn block_community_v3(\n  data: Json<BlockCommunity>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<BlockCommunityResponseV3>> {\n  let blocked = data.block;\n  let res = user_block_community(data, context, local_user_view)\n    .await?\n    .0;\n  Ok(Json(BlockCommunityResponseV3 {\n    community_view: convert_community_view(res.community_view),\n    blocked,\n  }))\n}\n\npub(crate) async fn delete_post_v3(\n  data: Json<DeletePost>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponseV3>> {\n  let res = delete_post(data, context, local_user_view).await?;\n  convert_post_response(res)\n}\npub(crate) async fn update_post_v3(\n  data: Json<EditPost>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<PostResponseV3>> {\n  let res = Box::pin(edit_post(data, context, local_user_view)).await?;\n  convert_post_response(res)\n}\npub(crate) async fn delete_comment_v3(\n  data: Json<DeleteComment>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  let res = delete_comment(data, context, local_user_view).await?;\n  convert_comment_response(res)\n}\npub(crate) async fn update_comment_v3(\n  data: Json<EditComment>,\n  context: ApubData<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<CommentResponseV3>> {\n  let res = Box::pin(edit_comment(data, context, local_user_view)).await?;\n  convert_comment_response(res)\n}\npub(crate) async fn list_communities_v3(\n  Query(data): Query<ListCommunitiesV3>,\n  context: Data<LemmyContext>,\n  local_user_view: Option<LocalUserView>,\n) -> LemmyResult<Json<ListCommunitiesResponseV3>> {\n  let ListCommunitiesV3 {\n    type_,\n    sort,\n    show_nsfw,\n    limit,\n    ..\n  } = data;\n  let (sort, time_range_seconds) = convert_community_listing_sort(sort);\n  let data = ListCommunities {\n    type_: type_.map(convert_listing_type),\n    sort,\n    time_range_seconds,\n    show_nsfw,\n    page_cursor: None,\n    limit,\n  };\n  let res = list_communities(Query(data), context, local_user_view)\n    .await?\n    .0;\n  Ok(Json(ListCommunitiesResponseV3 {\n    communities: res.into_iter().map(convert_community_view).collect(),\n  }))\n}\n\npub(crate) async fn register_v3(\n  data: Json<Register>,\n  req: HttpRequest,\n  context: ApubData<LemmyContext>,\n) -> LemmyResult<Json<LoginResponseV3>> {\n  let res = Box::pin(register(data, req, context)).await?.0;\n  convert_login_response(res)\n}\n\npub(crate) async fn block_person_v3(\n  data: Json<BlockPerson>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<BlockPersonResponseV3>> {\n  let blocked = data.block;\n  let res = user_block_person(data, context, local_user_view).await?.0;\n  Ok(Json(BlockPersonResponseV3 {\n    person_view: convert_person_view(res.person_view),\n    blocked,\n  }))\n}\n"
  },
  {
    "path": "crates/api/routes_v3/src/lib.rs",
    "content": "use crate::handlers::{\n  block_community_v3,\n  block_person_v3,\n  create_comment_report_v3,\n  create_comment_v3,\n  create_post_report_v3,\n  create_post_v3,\n  delete_comment_v3,\n  delete_post_v3,\n  follow_community_v3,\n  get_community_v3,\n  get_post_v3,\n  get_site_v3,\n  like_comment_v3,\n  like_post_v3,\n  list_comments_v3,\n  list_communities_v3,\n  list_posts_v3,\n  login_v3,\n  logout_v3,\n  mark_all_notifications_read_v3,\n  register_v3,\n  resolve_object_v3,\n  save_comment_v3,\n  save_post_v3,\n  search_v3,\n  unread_count_v3,\n  update_comment_v3,\n  update_post_v3,\n};\nuse actix_web::{guard, web::*};\nuse lemmy_api::local_user::donation_dialog_shown::donation_dialog_shown;\nuse lemmy_utils::rate_limit::RateLimit;\n\nmod convert;\nmod handlers;\n\npub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) {\n  cfg.service(\n    scope(\"/api/v3\")\n      .wrap(rate_limit.message())\n      // Site\n      .service(scope(\"/site\").route(\"\", get().to(get_site_v3)))\n      .service(\n        resource(\"/search\")\n          .wrap(rate_limit.search())\n          .route(get().to(search_v3)),\n      )\n      .service(\n        resource(\"/resolve_object\")\n          .wrap(rate_limit.message())\n          .route(get().to(resolve_object_v3)),\n      )\n      .service(\n        scope(\"/community\")\n          .wrap(rate_limit.message())\n          .route(\"\", get().to(get_community_v3))\n          .route(\"/list\", get().to(list_communities_v3))\n          .route(\"/follow\", post().to(follow_community_v3))\n          .route(\"/block\", post().to(block_community_v3)),\n      )\n      .service(\n        resource(\"/post\")\n          .guard(guard::Post())\n          .wrap(rate_limit.post())\n          .route(post().to(create_post_v3)),\n      )\n      .service(\n        scope(\"/post\")\n          .wrap(rate_limit.message())\n          .route(\"\", get().to(get_post_v3))\n          .route(\"\", put().to(update_post_v3))\n          .route(\"/delete\", post().to(delete_post_v3))\n          .route(\"/list\", get().to(list_posts_v3))\n          .route(\"/like\", post().to(like_post_v3))\n          .route(\"/save\", put().to(save_post_v3))\n          .route(\"/report\", post().to(create_post_report_v3)),\n      )\n      .service(\n        resource(\"/comment\")\n          .guard(guard::Post())\n          .wrap(rate_limit.comment())\n          .route(post().to(create_comment_v3)),\n      )\n      .service(\n        scope(\"/comment\")\n          .wrap(rate_limit.message())\n          .route(\"\", put().to(update_comment_v3))\n          .route(\"/delete\", post().to(delete_comment_v3))\n          .route(\"/like\", post().to(like_comment_v3))\n          .route(\"/list\", get().to(list_comments_v3))\n          .route(\"/save\", put().to(save_comment_v3))\n          .route(\"/report\", post().to(create_comment_report_v3)),\n      )\n      .service(\n        resource(\"/user/login\")\n          .guard(guard::Post())\n          .wrap(rate_limit.register())\n          .route(post().to(login_v3)),\n      )\n      .service(\n        resource(\"/user/register\")\n          .guard(guard::Post())\n          .wrap(rate_limit.register())\n          .route(post().to(register_v3)),\n      )\n      .service(\n        scope(\"/user\")\n          .wrap(rate_limit.message())\n          .route(\"/logout\", post().to(logout_v3))\n          .route(\"/unread_count\", get().to(unread_count_v3))\n          .route(\"/block\", post().to(block_person_v3))\n          .route(\n            \"/mark_all_as_read\",\n            post().to(mark_all_notifications_read_v3),\n          )\n          .route(\"/donation_dialog_shown\", post().to(donation_dialog_shown)),\n      ),\n  );\n}\n"
  },
  {
    "path": "crates/apub/activities/Cargo.toml",
    "content": "[package]\nname = \"lemmy_apub_activities\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_apub_activities\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[features]\nfull = []\n\n[lints]\nworkspace = true\n\n[dependencies]\nlemmy_db_views_community = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_moderator = { workspace = true, features = [\"full\"] }\nlemmy_db_views_post = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_views_private_message = { workspace = true, features = [\"full\"] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true, features = [\"full\"] }\nlemmy_apub_objects = { workspace = true }\nactivitypub_federation = { workspace = true }\nlemmy_db_schema_file = { workspace = true }\ndiesel = { workspace = true }\nchrono = { workspace = true }\nserde_json = { workspace = true }\nserde = { workspace = true }\ntracing = { workspace = true }\nstrum = { workspace = true }\nurl = { workspace = true }\nfutures = { workspace = true }\nfutures-util = { workspace = true }\nuuid = { workspace = true }\nasync-trait = { workspace = true }\nanyhow = { workspace = true }\nserde_with.workspace = true\nenum_delegate = \"0.2.0\"\neither = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\n\n[dev-dependencies]\n\n[package.metadata.cargo-shear]\nignored = [\"futures\", \"futures-util\"]\n"
  },
  {
    "path": "crates/apub/activities/src/activity_lists.rs",
    "content": "use crate::protocol::{\n  block::{block_user::BlockUser, undo_block_user::UndoBlockUser},\n  community::{\n    announce::{AnnounceActivity, RawAnnouncableActivities},\n    collection_add::CollectionAdd,\n    collection_remove::CollectionRemove,\n    lock::{LockPageOrNote, UndoLockPageOrNote},\n    report::Report,\n    resolve_report::ResolveReport,\n    update::Update,\n  },\n  create_or_update::{note_wrapper::CreateOrUpdateNoteWrapper, page::CreateOrUpdatePage},\n  deletion::{delete::Delete, undo_delete::UndoDelete},\n  following::{\n    accept::AcceptFollow,\n    follow::Follow,\n    reject::RejectFollow,\n    undo_follow::UndoFollow,\n  },\n  voting::{undo_vote::UndoVote, vote::Vote},\n};\nuse activitypub_federation::{config::Data, traits::Activity};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::community::ApubCommunity,\n  protocol::page::Page,\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n/// List of activities which the shared inbox can handle.\n///\n/// This could theoretically be defined as an enum with variants `GroupInboxActivities` and\n/// `PersonInboxActivities`. In practice we need to write it out manually so that priorities\n/// are handled correctly.\n#[derive(Debug, Deserialize, Serialize, Clone)]\n#[serde(untagged)]\n#[enum_delegate::implement(Activity)]\npub enum SharedInboxActivities {\n  Follow(Follow),\n  AcceptFollow(AcceptFollow),\n  RejectFollow(RejectFollow),\n  UndoFollow(UndoFollow),\n  Report(Report),\n  ResolveReport(ResolveReport),\n  AnnounceActivity(AnnounceActivity),\n  /// This is a catch-all and needs to be last\n  RawAnnouncableActivities(RawAnnouncableActivities),\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\n#[enum_delegate::implement(Activity)]\npub enum AnnouncableActivities {\n  CreateOrUpdateNoteWrapper(CreateOrUpdateNoteWrapper),\n  CreateOrUpdatePost(CreateOrUpdatePage),\n  Vote(Vote),\n  UndoVote(UndoVote),\n  Delete(Delete),\n  UndoDelete(UndoDelete),\n  UpdateCommunity(Box<Update>),\n  BlockUser(BlockUser),\n  UndoBlockUser(UndoBlockUser),\n  CollectionAdd(CollectionAdd),\n  CollectionRemove(CollectionRemove),\n  Lock(LockPageOrNote),\n  UndoLock(UndoLockPageOrNote),\n  Report(Report),\n  ResolveReport(ResolveReport),\n  // For compatibility with Pleroma/Mastodon (send only)\n  Page(Page),\n}\n\nimpl InCommunity for AnnouncableActivities {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    use AnnouncableActivities::*;\n    match self {\n      CreateOrUpdateNoteWrapper(a) => a.community(context).await,\n      CreateOrUpdatePost(a) => a.community(context).await,\n      Vote(a) => a.community(context).await,\n      UndoVote(a) => a.object.community(context).await,\n      Delete(a) => a.community(context).await,\n      UndoDelete(a) => a.object.community(context).await,\n      UpdateCommunity(a) => a.community(context).await,\n      BlockUser(a) => a.community(context).await,\n      UndoBlockUser(a) => a.object.community(context).await,\n      CollectionAdd(a) => a.community(context).await,\n      CollectionRemove(a) => a.community(context).await,\n      Lock(a) => a.community(context).await,\n      UndoLock(a) => a.object.community(context).await,\n      Report(a) => a.community(context).await,\n      ResolveReport(a) => a.object.community(context).await,\n      Page(_) => Err(LemmyErrorType::NotFound.into()),\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::activity_lists::SharedInboxActivities;\n  use lemmy_apub_objects::utils::test::{test_json, test_parse_lemmy_item};\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_shared_inbox() -> LemmyResult<()> {\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/deletion/delete_user.json\",\n    )?;\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/following/accept.json\",\n    )?;\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_comment.json\",\n    )?;\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_private_message.json\",\n    )?;\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/following/follow.json\",\n    )?;\n    test_parse_lemmy_item::<SharedInboxActivities>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_comment.json\",\n    )?;\n    test_json::<SharedInboxActivities>(\"../apub/assets/mastodon/activities/follow.json\")?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/block/block_user.rs",
    "content": "use super::{to, update_removed_for_instance};\nuse crate::{\n  MOD_ACTION_DEFAULT_REASON,\n  activity_lists::AnnouncableActivities,\n  block::{SiteOrCommunity, generate_cc},\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  generate_activity_id,\n  protocol::block::block_user::BlockUser,\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::BlockType,\n  traits::{Activity, Actor, Object},\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{remove_or_restore_user_data, remove_or_restore_user_data_in_community},\n};\nuse lemmy_apub_objects::{\n  objects::person::ApubPerson,\n  utils::functions::{verify_is_public, verify_mod_action, verify_visibility},\n};\nuse lemmy_db_schema::{\n  source::{\n    activity::ActivitySendTargets,\n    community::{CommunityActions, CommunityPersonBanForm},\n    instance::{InstanceActions, InstanceBanForm},\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::Bannable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};\nuse url::Url;\n\nimpl BlockUser {\n  pub(in crate::block) async fn new(\n    target: &SiteOrCommunity,\n    user: &ApubPerson,\n    mod_: &ApubPerson,\n    remove_data: Option<bool>,\n    reason: String,\n    expires: Option<DateTime<Utc>>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<BlockUser> {\n    let to = to(target)?;\n    Ok(BlockUser {\n      actor: mod_.id().clone().into(),\n      to,\n      object: user.id().clone().into(),\n      cc: generate_cc(target, &mut context.pool()).await?,\n      target: target.id().clone().into(),\n      kind: BlockType::Block,\n      remove_data,\n      summary: Some(reason),\n      id: generate_activity_id(BlockType::Block, context)?,\n      end_time: expires,\n      audience: target.as_ref().right().map(|c| c.ap_id.clone().into()),\n    })\n  }\n\n  pub async fn send(\n    target: &SiteOrCommunity,\n    user: &ApubPerson,\n    mod_: &ApubPerson,\n    remove_data: bool,\n    reason: String,\n    expires: Option<DateTime<Utc>>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let block = BlockUser::new(\n      target,\n      user,\n      mod_,\n      Some(remove_data),\n      reason,\n      expires,\n      context,\n    )\n    .await?;\n\n    match target {\n      SiteOrCommunity::Left(_) => {\n        let inboxes = ActivitySendTargets::to_all_instances();\n        send_lemmy_activity(context, block, mod_, inboxes, false).await\n      }\n      SiteOrCommunity::Right(c) => {\n        let activity = AnnouncableActivities::BlockUser(block);\n        let inboxes = ActivitySendTargets::to_inbox(user.shared_inbox_or_inbox());\n        send_activity_in_community(activity, mod_, c, inboxes, true, context).await\n      }\n    }\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for BlockUser {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    match self.target.dereference(context).await? {\n      SiteOrCommunity::Left(_site) => {\n        verify_is_public(&self.to, &self.cc)?;\n      }\n      SiteOrCommunity::Right(community) => {\n        verify_visibility(&self.to, &self.cc, &community)?;\n        verify_mod_action(&self.actor, &community, context).await?;\n        check_community_deleted_or_removed(&community)?;\n      }\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let expires_at = self.end_time;\n    let mod_person = self.actor.dereference(context).await?;\n    // Dereference local here so that deleted users can be banned as well.\n    let blocked_person = self.object.dereference_local(context).await?;\n    let target = self.target.dereference(context).await?;\n    let reason = self\n      .summary\n      .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string());\n    let pool = &mut context.pool();\n    match target {\n      SiteOrCommunity::Left(site) => {\n        let form = InstanceBanForm::new(blocked_person.id, site.instance_id, expires_at);\n        InstanceActions::ban(pool, &form).await?;\n\n        // Mod tables - create ban entry first so bulk actions can reference it as parent\n        let form =\n          ModlogInsertForm::admin_ban(&mod_person, blocked_person.id, true, expires_at, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id;\n        notify_mod_action(action, context);\n\n        if self.remove_data.unwrap_or(false) {\n          if blocked_person.instance_id == site.instance_id {\n            // user banned from home instance, remove all content\n            remove_or_restore_user_data(\n              mod_person.id,\n              blocked_person.id,\n              true,\n              &reason,\n              parent_id,\n              context,\n            )\n            .await?;\n          } else {\n            update_removed_for_instance(&blocked_person, &site, true, pool).await?;\n          }\n        }\n      }\n      SiteOrCommunity::Right(community) => {\n        let community_user_ban_form = CommunityPersonBanForm {\n          ban_expires_at: Some(expires_at),\n          ..CommunityPersonBanForm::new(community.id, blocked_person.id)\n        };\n        CommunityActions::ban(&mut context.pool(), &community_user_ban_form).await?;\n\n        // Dont unsubscribe the user so that we can receive a potential unban activity.\n        // If we unfollowed the community here, activities from the community would be rejected\n        // in [[can_accept_activity_in_community]] in case are no other local followers.\n\n        // Mod tables - create ban entry first so bulk actions can reference it as parent\n        let form = ModlogInsertForm::mod_ban_from_community(\n          mod_person.id,\n          community.id,\n          blocked_person.id,\n          true,\n          expires_at,\n          &reason,\n        );\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id;\n        notify_mod_action(action, context);\n\n        if self.remove_data.unwrap_or(false) {\n          remove_or_restore_user_data_in_community(\n            community.id,\n            mod_person.id,\n            blocked_person.id,\n            true,\n            &reason,\n            parent_id,\n            &mut context.pool(),\n          )\n          .await?;\n        }\n      }\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/block/mod.rs",
    "content": "use crate::protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser};\nuse activitypub_federation::{config::Data, kinds::public, traits::Object};\nuse either::Either;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_expire_time};\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, instance::ApubSite},\n  utils::functions::generate_to,\n};\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{comment::Comment, community::Community, person::Person, post::Post, site::Site},\n};\nuse lemmy_db_views_community::api::BanFromCommunity;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{connection::DbPool, traits::Crud};\nuse lemmy_utils::error::LemmyResult;\nuse url::Url;\n\npub mod block_user;\npub mod undo_block_user;\n\npub type SiteOrCommunity = Either<ApubSite, ApubCommunity>;\n\nasync fn generate_cc(target: &SiteOrCommunity, pool: &mut DbPool<'_>) -> LemmyResult<Vec<Url>> {\n  Ok(match target {\n    SiteOrCommunity::Left(_) => Site::read_remote_sites(pool)\n      .await?\n      .into_iter()\n      .map(|s| s.ap_id.into())\n      .collect(),\n    SiteOrCommunity::Right(c) => vec![c.id().clone()],\n  })\n}\n\npub(crate) async fn send_ban_from_site(\n  moderator: Person,\n  banned_user: Person,\n  reason: String,\n  remove_or_restore_data: Option<bool>,\n  ban: bool,\n  expires: Option<i64>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let site = SiteOrCommunity::Left(SiteView::read_local(&mut context.pool()).await?.site.into());\n  let expires = check_expire_time(expires)?;\n\n  if ban {\n    BlockUser::send(\n      &site,\n      &banned_user.into(),\n      &moderator.into(),\n      remove_or_restore_data.unwrap_or(false),\n      reason.clone(),\n      expires,\n      &context,\n    )\n    .await\n  } else {\n    UndoBlockUser::send(\n      &site,\n      &banned_user.into(),\n      &moderator.into(),\n      remove_or_restore_data.unwrap_or(false),\n      reason.clone(),\n      &context,\n    )\n    .await\n  }\n}\n\npub(crate) async fn send_ban_from_community(\n  mod_: Person,\n  community_id: CommunityId,\n  banned_person: Person,\n  data: BanFromCommunity,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let community: ApubCommunity = Community::read(&mut context.pool(), community_id)\n    .await?\n    .into();\n  let expires_at = check_expire_time(data.expires_at)?;\n\n  if data.ban {\n    BlockUser::send(\n      &SiteOrCommunity::Right(community),\n      &banned_person.into(),\n      &mod_.into(),\n      data.remove_or_restore_data.unwrap_or(false),\n      data.reason.clone(),\n      expires_at,\n      &context,\n    )\n    .await\n  } else {\n    UndoBlockUser::send(\n      &SiteOrCommunity::Right(community),\n      &banned_person.into(),\n      &mod_.into(),\n      data.remove_or_restore_data.unwrap_or(false),\n      data.reason.clone(),\n      &context,\n    )\n    .await\n  }\n}\n\nfn to(target: &SiteOrCommunity) -> LemmyResult<Vec<Url>> {\n  Ok(if let SiteOrCommunity::Right(c) = target {\n    generate_to(c)?\n  } else {\n    vec![public()]\n  })\n}\n\n// user banned from remote instance, remove content only in communities from that\n// instance\nasync fn update_removed_for_instance(\n  blocked_person: &Person,\n  site: &ApubSite,\n  removed: bool,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<()> {\n  Post::update_removed_for_creator_and_instance(pool, blocked_person.id, site.instance_id, removed)\n    .await?;\n  Comment::update_removed_for_creator_and_instance(\n    pool,\n    blocked_person.id,\n    site.instance_id,\n    removed,\n  )\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/block/undo_block_user.rs",
    "content": "use super::{to, update_removed_for_instance};\nuse crate::{\n  MOD_ACTION_DEFAULT_REASON,\n  activity_lists::AnnouncableActivities,\n  block::{SiteOrCommunity, generate_cc},\n  community::send_activity_in_community,\n  generate_activity_id,\n  protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser},\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::UndoType,\n  protocol::verification::verify_domains_match,\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{remove_or_restore_user_data, remove_or_restore_user_data_in_community},\n};\nuse lemmy_apub_objects::{\n  objects::person::ApubPerson,\n  utils::functions::{verify_is_public, verify_visibility},\n};\nuse lemmy_db_schema::{\n  source::{\n    activity::ActivitySendTargets,\n    community::{CommunityActions, CommunityPersonBanForm},\n    instance::{InstanceActions, InstanceBanForm},\n    modlog::{Modlog, ModlogInsertForm},\n  },\n  traits::Bannable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};\nuse url::Url;\n\nimpl UndoBlockUser {\n  pub async fn send(\n    target: &SiteOrCommunity,\n    user: &ApubPerson,\n    mod_: &ApubPerson,\n    restore_data: bool,\n    reason: String,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let block = BlockUser::new(target, user, mod_, None, reason, None, context).await?;\n    let to = to(target)?;\n\n    let id = generate_activity_id(UndoType::Undo, context)?;\n    let undo = UndoBlockUser {\n      actor: mod_.id().clone().into(),\n      to,\n      object: block,\n      cc: generate_cc(target, &mut context.pool()).await?,\n      kind: UndoType::Undo,\n      id: id.clone(),\n      restore_data: Some(restore_data),\n      audience: target.as_ref().right().map(|c| c.ap_id.clone().into()),\n    };\n\n    let mut inboxes = ActivitySendTargets::to_inbox(user.shared_inbox_or_inbox());\n    match target {\n      SiteOrCommunity::Left(_) => {\n        inboxes.set_all_instances();\n        send_lemmy_activity(context, undo, mod_, inboxes, false).await\n      }\n      SiteOrCommunity::Right(c) => {\n        let activity = AnnouncableActivities::UndoBlockUser(undo);\n        send_activity_in_community(activity, mod_, c, inboxes, true, context).await\n      }\n    }\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for UndoBlockUser {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    verify_domains_match(self.actor.inner(), self.object.actor.inner())?;\n    self.object.verify(context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let expires_at = self.object.end_time;\n    let mod_person = self.actor.dereference(context).await?;\n    let blocked_person = self.object.object.dereference_local(context).await?;\n    let reason = self\n      .object\n      .summary\n      .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string());\n    let pool = &mut context.pool();\n    match self.object.target.dereference(context).await? {\n      SiteOrCommunity::Left(site) => {\n        verify_is_public(&self.to, &self.cc)?;\n        let form = InstanceBanForm::new(blocked_person.id, site.instance_id, expires_at);\n        InstanceActions::unban(pool, &form).await?;\n\n        // Mod tables - create unban entry first so bulk actions can reference it as parent\n        let form =\n          ModlogInsertForm::admin_ban(&mod_person, blocked_person.id, false, expires_at, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id;\n        notify_mod_action(action, context.app_data());\n\n        if self.restore_data.unwrap_or(false) {\n          if blocked_person.instance_id == site.instance_id {\n            // user unbanned from home instance, restore all content\n            remove_or_restore_user_data(\n              mod_person.id,\n              blocked_person.id,\n              false,\n              &reason,\n              parent_id,\n              context,\n            )\n            .await?;\n          } else {\n            update_removed_for_instance(&blocked_person, &site, false, pool).await?;\n          }\n        }\n      }\n      SiteOrCommunity::Right(community) => {\n        verify_visibility(&self.to, &self.cc, &community)?;\n        let community_user_ban_form = CommunityPersonBanForm::new(community.id, blocked_person.id);\n        CommunityActions::unban(&mut context.pool(), &community_user_ban_form).await?;\n\n        // Mod tables - create unban entry first so bulk actions can reference it as parent\n        let form = ModlogInsertForm::mod_ban_from_community(\n          mod_person.id,\n          community.id,\n          blocked_person.id,\n          false,\n          expires_at,\n          &reason,\n        );\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id;\n        notify_mod_action(action, context.app_data());\n\n        if self.restore_data.unwrap_or(false) {\n          remove_or_restore_user_data_in_community(\n            community.id,\n            mod_person.id,\n            blocked_person.id,\n            false,\n            &reason,\n            parent_id,\n            &mut context.pool(),\n          )\n          .await?;\n        }\n      }\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/announce.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  generate_activity_id,\n  generate_announce_activity_id,\n  protocol::{\n    IdOrNestedObject,\n    community::announce::{AnnounceActivity, RawAnnouncableActivities},\n  },\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::AnnounceType,\n  traits::{Activity, Object},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::community::ApubCommunity,\n  utils::{\n    functions::{generate_to, verify_person_in_community, verify_visibility},\n    protocol::{Id, InCommunity},\n  },\n};\nuse lemmy_db_schema::source::{activity::ActivitySendTargets, community::CommunityActions};\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse serde_json::Value;\nuse url::Url;\n\n#[async_trait::async_trait]\nimpl Activity for RawAnnouncableActivities {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    &self.actor\n  }\n\n  async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    let activity: AnnouncableActivities = self.clone().try_into()?;\n\n    // This is only for sending, not receiving so we reject it.\n    if let AnnouncableActivities::Page(_) = activity {\n      return Err(UntranslatedError::CannotReceivePage.into());\n    }\n\n    // Need to treat community as optional here because `Delete/PrivateMessage` gets routed through\n    let community = activity.community(context).await.ok();\n    can_accept_activity_in_community(&community, context).await?;\n\n    // verify and receive activity\n    activity.verify(context).await?;\n    let ap_id = activity.actor().clone().into();\n    activity.receive(context).await?;\n\n    // if community is local, send activity to followers\n    if let Some(community) = community\n      && community.local\n    {\n      verify_person_in_community(&ap_id, &community, context).await?;\n      AnnounceActivity::send(self, &community, context).await?;\n    }\n\n    Ok(())\n  }\n}\n\nimpl Id for RawAnnouncableActivities {\n  fn id(&self) -> &Url {\n    &self.id\n  }\n}\n\nimpl AnnounceActivity {\n  pub fn new(\n    object: RawAnnouncableActivities,\n    community: &ApubCommunity,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<AnnounceActivity> {\n    let inner_kind = object\n      .other\n      .get(\"type\")\n      .and_then(serde_json::Value::as_str)\n      .unwrap_or(\"other\");\n    let id =\n      generate_announce_activity_id(inner_kind, &context.settings().get_protocol_and_hostname())?;\n    Ok(AnnounceActivity {\n      actor: community.id().clone().into(),\n      to: generate_to(community)?,\n      object: IdOrNestedObject::NestedObject(object),\n      cc: community\n        .followers_url\n        .clone()\n        .map(Into::into)\n        .into_iter()\n        .collect(),\n      kind: AnnounceType::Announce,\n      id,\n    })\n  }\n\n  pub async fn send(\n    object: RawAnnouncableActivities,\n    community: &ApubCommunity,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let announce = AnnounceActivity::new(object.clone(), community, context)?;\n    let inboxes = ActivitySendTargets::to_local_community_followers(community.id);\n    send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?;\n\n    // Pleroma and Mastodon can't handle activities like Announce/Create/Page. So for\n    // compatibility, we also send Announce/Page so that they can follow Lemmy communities.\n    let object_parsed = object.try_into()?;\n    if let AnnouncableActivities::CreateOrUpdatePost(c) = object_parsed {\n      // Hack: need to convert Page into a format which can be sent as activity, which requires\n      //       adding actor field.\n      let announcable_page = RawAnnouncableActivities {\n        id: generate_activity_id(AnnounceType::Announce, context)?,\n        actor: c.actor.clone().into_inner(),\n        other: serde_json::to_value(c.object)?\n          .as_object()\n          .ok_or(UntranslatedError::Unreachable)?\n          .clone(),\n      };\n      let announce_compat = AnnounceActivity::new(announcable_page, community, context)?;\n      send_lemmy_activity(context, announce_compat, community, inboxes, false).await?;\n    }\n    Ok(())\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for AnnounceActivity {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, _context: &Data<Self::DataType>) -> LemmyResult<()> {\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let object: AnnouncableActivities = self.object.object(context).await?.try_into()?;\n\n    // This is only for sending, not receiving so we reject it.\n    if let AnnouncableActivities::Page(_) = object {\n      return Err(UntranslatedError::CannotReceivePage.into());\n    }\n\n    let community = object.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    can_accept_activity_in_community(&Some(community), context).await?;\n\n    // verify here in order to avoid fetching the object twice over http\n    object.verify(context).await?;\n    object.receive(context).await\n  }\n}\n\nimpl TryFrom<RawAnnouncableActivities> for AnnouncableActivities {\n  type Error = serde_json::error::Error;\n\n  fn try_from(value: RawAnnouncableActivities) -> Result<Self, Self::Error> {\n    let mut map = value.other.clone();\n    map.insert(\"id\".to_string(), Value::String(value.id.to_string()));\n    map.insert(\"actor\".to_string(), Value::String(value.actor.to_string()));\n    serde_json::from_value(Value::Object(map))\n  }\n}\n\nimpl TryFrom<AnnouncableActivities> for RawAnnouncableActivities {\n  type Error = serde_json::error::Error;\n\n  fn try_from(value: AnnouncableActivities) -> Result<Self, Self::Error> {\n    serde_json::from_value(serde_json::to_value(value)?)\n  }\n}\n\n/// Check if an activity in the given community can be accepted. To return true, the community must\n/// either be local to this instance, or it must have at least one local follower.\n///\n/// TODO: This means mentions dont work if the community has no local followers. Can be fixed\n///       by checking if any local user is in to/cc fields of activity. Anyway this is a minor\n///       problem compared to receiving unsolicited posts.\nasync fn can_accept_activity_in_community(\n  community: &Option<ApubCommunity>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  if let Some(community) = community {\n    // Local only community can't federate\n    if !community.visibility.can_federate() {\n      return Err(LemmyErrorType::NotFound.into());\n    }\n    if !community.local {\n      CommunityActions::check_accept_activity_in_community(&mut context.pool(), community).await?\n    }\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/collection_add.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  generate_activity_id,\n  protocol::community::{collection_add::CollectionAdd, collection_remove::CollectionRemove},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::AddType,\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{generate_featured_url, generate_moderators_url},\n};\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},\n  utils::{\n    functions::{generate_to, verify_mod_action, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::{\n  impls::community::CollectionType,\n  newtypes::CommunityId,\n  source::{\n    activity::ActivitySendTargets,\n    community::{Community, CommunityActions, CommunityModeratorForm},\n    modlog::{Modlog, ModlogInsertForm},\n    person::Person,\n    post::{Post, PostUpdateForm},\n  },\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl CollectionAdd {\n  async fn send_add_mod(\n    community: &ApubCommunity,\n    added_mod: &ApubPerson,\n    actor: &ApubPerson,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let id = generate_activity_id(AddType::Add, context)?;\n    let add = CollectionAdd {\n      actor: actor.id().clone().into(),\n      to: generate_to(community)?,\n      object: added_mod.id().clone(),\n      target: generate_moderators_url(&community.ap_id)?.into(),\n      cc: vec![community.id().clone()],\n      kind: AddType::Add,\n      id: id.clone(),\n      audience: Some(community.ap_id.clone().into()),\n    };\n\n    let activity = AnnouncableActivities::CollectionAdd(add);\n    let inboxes = ActivitySendTargets::to_inbox(added_mod.shared_inbox_or_inbox());\n    send_activity_in_community(activity, actor, community, inboxes, true, context).await\n  }\n\n  async fn send_add_featured_post(\n    community: &ApubCommunity,\n    featured_post: &ApubPost,\n    actor: &ApubPerson,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let id = generate_activity_id(AddType::Add, context)?;\n    let add = CollectionAdd {\n      actor: actor.id().clone().into(),\n      to: generate_to(community)?,\n      object: featured_post.ap_id.clone().into(),\n      target: generate_featured_url(&community.ap_id)?.into(),\n      cc: vec![community.id().clone()],\n      kind: AddType::Add,\n      id: id.clone(),\n      audience: Some(community.ap_id.clone().into()),\n    };\n    let activity = AnnouncableActivities::CollectionAdd(add);\n    send_activity_in_community(\n      activity,\n      actor,\n      community,\n      ActivitySendTargets::empty(),\n      true,\n      context,\n    )\n    .await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for CollectionAdd {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let community = self.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    verify_mod_action(&self.actor, &community, context).await?;\n    check_community_deleted_or_removed(&community)?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let (community, collection_type) =\n      Community::get_by_collection_url(&mut context.pool(), &self.target.clone().into()).await?;\n\n    match collection_type {\n      CollectionType::Moderators => {\n        let new_mod = ObjectId::<ApubPerson>::from(self.object)\n          .dereference(context)\n          .await?;\n\n        // If we had to refetch the community while parsing the activity, then the new mod has\n        // already been added. Skip it here as it would result in a duplicate key error.\n        let new_mod_id = new_mod.id;\n        let moderated_communities =\n          CommunityActions::get_person_moderated_communities(&mut context.pool(), new_mod_id)\n            .await?;\n        if !moderated_communities.contains(&community.id) {\n          let form = CommunityModeratorForm::new(community.id, new_mod.id);\n          CommunityActions::join(&mut context.pool(), &form).await?;\n\n          // write mod log\n          let actor = self.actor.dereference(context).await?;\n          let form =\n            ModlogInsertForm::mod_add_to_community(actor.id, community.id, new_mod.id, false);\n          let action = Modlog::create(&mut context.pool(), &[form]).await?;\n          notify_mod_action(action, context);\n        }\n      }\n      CollectionType::Featured => {\n        let post = ObjectId::<ApubPost>::from(self.object)\n          .dereference(context)\n          .await?;\n        let form = PostUpdateForm {\n          featured_community: Some(true),\n          ..Default::default()\n        };\n        Post::update(&mut context.pool(), post.id, &form).await?;\n      }\n    }\n    Ok(())\n  }\n}\n\npub(crate) async fn send_add_mod_to_community(\n  actor: Person,\n  community_id: CommunityId,\n  updated_mod_id: PersonId,\n  added: bool,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let actor: ApubPerson = actor.into();\n  let community: ApubCommunity = Community::read(&mut context.pool(), community_id)\n    .await?\n    .into();\n  let updated_mod: ApubPerson = Person::read(&mut context.pool(), updated_mod_id)\n    .await?\n    .into();\n  if added {\n    CollectionAdd::send_add_mod(&community, &updated_mod, &actor, &context).await\n  } else {\n    CollectionRemove::send_remove_mod(&community, &updated_mod, &actor, &context).await\n  }\n}\n\npub(crate) async fn send_feature_post(\n  post: Post,\n  actor: Person,\n  featured: bool,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let actor: ApubPerson = actor.into();\n  let post: ApubPost = post.into();\n  let community = Community::read(&mut context.pool(), post.community_id)\n    .await?\n    .into();\n  if featured {\n    CollectionAdd::send_add_featured_post(&community, &post, &actor, &context).await\n  } else {\n    CollectionRemove::send_remove_featured_post(&community, &post, &actor, &context).await\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/collection_remove.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  generate_activity_id,\n  protocol::community::collection_remove::CollectionRemove,\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::RemoveType,\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_mod_action,\n  utils::{generate_featured_url, generate_moderators_url},\n};\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},\n  utils::{\n    functions::{generate_to, verify_mod_action, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::{\n  impls::community::CollectionType,\n  source::{\n    activity::ActivitySendTargets,\n    community::{Community, CommunityActions, CommunityModeratorForm},\n    modlog::{Modlog, ModlogInsertForm},\n    post::{Post, PostUpdateForm},\n  },\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl CollectionRemove {\n  pub(super) async fn send_remove_mod(\n    community: &ApubCommunity,\n    removed_mod: &ApubPerson,\n    actor: &ApubPerson,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let id = generate_activity_id(RemoveType::Remove, context)?;\n    let remove = CollectionRemove {\n      actor: actor.id().clone().into(),\n      to: generate_to(community)?,\n      object: removed_mod.id().clone(),\n      target: generate_moderators_url(&community.ap_id)?.into(),\n      id: id.clone(),\n      cc: vec![community.id().clone()],\n      kind: RemoveType::Remove,\n      audience: Some(community.ap_id.clone().into()),\n    };\n\n    let activity = AnnouncableActivities::CollectionRemove(remove);\n    let inboxes = ActivitySendTargets::to_inbox(removed_mod.shared_inbox_or_inbox());\n    send_activity_in_community(activity, actor, community, inboxes, true, context).await\n  }\n\n  pub(super) async fn send_remove_featured_post(\n    community: &ApubCommunity,\n    featured_post: &ApubPost,\n    actor: &ApubPerson,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let id = generate_activity_id(RemoveType::Remove, context)?;\n    let remove = CollectionRemove {\n      actor: actor.id().clone().into(),\n      to: generate_to(community)?,\n      object: featured_post.ap_id.clone().into(),\n      target: generate_featured_url(&community.ap_id)?.into(),\n      cc: vec![community.id().clone()],\n      kind: RemoveType::Remove,\n      id: id.clone(),\n      audience: Some(community.ap_id.clone().into()),\n    };\n    let activity = AnnouncableActivities::CollectionRemove(remove);\n    send_activity_in_community(\n      activity,\n      actor,\n      community,\n      ActivitySendTargets::empty(),\n      true,\n      context,\n    )\n    .await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for CollectionRemove {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let community = self.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    verify_mod_action(&self.actor, &community, context).await?;\n    check_community_deleted_or_removed(&community)?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let (community, collection_type) =\n      Community::get_by_collection_url(&mut context.pool(), &self.target.into()).await?;\n\n    match collection_type {\n      CollectionType::Moderators => {\n        let remove_mod = ObjectId::<ApubPerson>::from(self.object)\n          .dereference(context)\n          .await?;\n\n        let form = CommunityModeratorForm::new(community.id, remove_mod.id);\n        CommunityActions::leave(&mut context.pool(), &form).await?;\n\n        // write mod log\n        let actor = self.actor.dereference(context).await?;\n        let form =\n          ModlogInsertForm::mod_add_to_community(actor.id, community.id, remove_mod.id, true);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context);\n      }\n      CollectionType::Featured => {\n        let post = ObjectId::<ApubPost>::from(self.object)\n          .dereference(context)\n          .await?;\n        let form = PostUpdateForm {\n          featured_community: Some(false),\n          ..Default::default()\n        };\n        Post::update(&mut context.pool(), post.id, &form).await?;\n      }\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/lock.rs",
    "content": "use crate::{\n  MOD_ACTION_DEFAULT_REASON,\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  generate_activity_id,\n  post_or_comment_community,\n  protocol::community::lock::{LockPageOrNote, LockType, UndoLockPageOrNote},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::UndoType,\n  traits::Activity,\n};\nuse lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action};\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, community::ApubCommunity},\n  utils::{\n    functions::{generate_to, verify_mod_action, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::source::{\n  activity::ActivitySendTargets,\n  comment::Comment,\n  modlog::{Modlog, ModlogInsertForm},\n  person::Person,\n  post::{Post, PostUpdateForm},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\n#[async_trait::async_trait]\nimpl Activity for LockPageOrNote {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    let community = self.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    check_community_deleted_or_removed(&community)?;\n    verify_mod_action(&self.actor, &community, context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    let reason = self\n      .summary\n      .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string());\n    let actor = self.actor.dereference(context).await?;\n    match self.object.dereference(context).await? {\n      PostOrComment::Left(post) => {\n        let form = PostUpdateForm {\n          locked: Some(true),\n          ..Default::default()\n        };\n        Post::update(&mut context.pool(), post.id, &form).await?;\n\n        let form = ModlogInsertForm::mod_lock_post(actor.id, &post, true, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context);\n      }\n      PostOrComment::Right(comment) => {\n        Comment::update_locked_for_comment_and_children(&mut context.pool(), &comment.path, true)\n          .await?;\n        let community_id = Post::read(&mut context.pool(), comment.post_id)\n          .await?\n          .community_id;\n\n        let form =\n          ModlogInsertForm::mod_lock_comment(actor.id, &comment, community_id, true, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context);\n      }\n    }\n\n    Ok(())\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for UndoLockPageOrNote {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    let community = self.object.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    check_community_deleted_or_removed(&community)?;\n    verify_mod_action(&self.actor, &community, context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    let reason = self\n      .summary\n      .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string());\n    let actor = self.actor.dereference(context).await?;\n\n    match self.object.object.dereference(context).await? {\n      PostOrComment::Left(post) => {\n        let form = PostUpdateForm {\n          locked: Some(false),\n          ..Default::default()\n        };\n\n        Post::update(&mut context.pool(), post.id, &form).await?;\n\n        let form = ModlogInsertForm::mod_lock_post(actor.id, &post, false, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context);\n      }\n      PostOrComment::Right(comment) => {\n        Comment::update_locked_for_comment_and_children(&mut context.pool(), &comment.path, false)\n          .await?;\n\n        let community_id = Post::read(&mut context.pool(), comment.post_id)\n          .await?\n          .community_id;\n\n        let form =\n          ModlogInsertForm::mod_lock_comment(actor.id, &comment, community_id, false, &reason);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context);\n      }\n    }\n\n    Ok(())\n  }\n}\n\npub(crate) async fn send_lock(\n  object: PostOrComment,\n  actor: Person,\n  locked: bool,\n  reason: String,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let community: ApubCommunity = post_or_comment_community(&object, &context).await?.into();\n  let id = generate_activity_id(LockType::Lock, &context)?;\n  let community_id = community.ap_id.inner().clone();\n  let ap_id = match object {\n    PostOrComment::Left(p) => p.ap_id.clone(),\n    PostOrComment::Right(c) => c.ap_id.clone(),\n  };\n\n  let lock = LockPageOrNote {\n    actor: actor.ap_id.clone().into(),\n    to: generate_to(&community)?,\n    object: ObjectId::from(ap_id),\n    cc: vec![community_id.clone()],\n    kind: LockType::Lock,\n    id,\n    summary: Some(reason.clone()),\n    audience: Some(community.ap_id.clone().into()),\n  };\n  let activity = if locked {\n    AnnouncableActivities::Lock(lock)\n  } else {\n    let id = generate_activity_id(UndoType::Undo, &context)?;\n    let undo = UndoLockPageOrNote {\n      actor: lock.actor.clone(),\n      to: generate_to(&community)?,\n      cc: lock.cc.clone(),\n      kind: UndoType::Undo,\n      id,\n      object: lock,\n      summary: Some(reason),\n      audience: Some(community.ap_id.clone().into()),\n    };\n    AnnouncableActivities::UndoLock(undo)\n  };\n  send_activity_in_community(\n    activity,\n    &actor.into(),\n    &community,\n    ActivitySendTargets::empty(),\n    true,\n    &context,\n  )\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/mod.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  protocol::community::announce::AnnounceActivity,\n  send_lemmy_activity,\n};\nuse activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Actor};\nuse either::Either;\nuse lemmy_api_utils::{context::LemmyContext, utils::is_admin};\nuse lemmy_apub_objects::{\n  objects::{\n    PostOrComment,\n    ReportableObjects,\n    community::ApubCommunity,\n    instance::ApubSite,\n    person::ApubPerson,\n  },\n  utils::functions::verify_mod_action,\n};\nuse lemmy_db_schema::source::{\n  activity::ActivitySendTargets,\n  person::{Person, PersonActions},\n  site::Site,\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub mod announce;\npub mod collection_add;\npub mod collection_remove;\npub mod lock;\npub mod report;\npub mod resolve_report;\npub mod update;\n\n/// This function sends all activities which are happening in a community to the right inboxes.\n/// For example Create/Page, Add/Mod etc, but not private messages.\n///\n/// Activities are sent to the community itself if it lives on another instance. If the community\n/// is local, the activity is directly wrapped into Announce and sent to community followers.\n/// Activities are also sent to those who follow the actor (with exception of moderation\n/// activities).\n///\n/// * `activity` - The activity which is being sent\n/// * `actor` - The user who is sending the activity\n/// * `community` - Community inside which the activity is sent\n/// * `inboxes` - Any additional inboxes the activity should be sent to (for example, to the user\n///   who is being promoted to moderator)\n/// * `is_mod_activity` - True for things like Add/Mod, these are not sent to user followers\npub(crate) async fn send_activity_in_community(\n  activity: AnnouncableActivities,\n  actor: &ApubPerson,\n  community: &ApubCommunity,\n  extra_inboxes: ActivitySendTargets,\n  is_mod_action: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  // If community is local only, don't send anything out\n  if !community.visibility.can_federate() {\n    return Ok(());\n  }\n\n  // send to any users which are mentioned or affected directly\n  let mut inboxes = extra_inboxes;\n\n  // send to user followers\n  if !is_mod_action {\n    inboxes.add_inboxes(PersonActions::follower_inboxes(&mut context.pool(), actor.id).await?);\n  }\n\n  if community.local {\n    // send directly to community followers\n    AnnounceActivity::send(activity.clone().try_into()?, community, context).await?;\n  } else {\n    // send to the community, which will then forward to followers\n    inboxes.add_inbox(community.shared_inbox_or_inbox());\n  }\n\n  send_lemmy_activity(context, activity.clone(), actor, inboxes, false).await?;\n  Ok(())\n}\n\nasync fn report_inboxes(\n  object_id: ObjectId<ReportableObjects>,\n  receiver: &Either<ApubSite, ApubCommunity>,\n  report_creator: &ApubPerson,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<ActivitySendTargets> {\n  // send report to the community where object was posted\n  let mut inboxes = ActivitySendTargets::to_inbox(receiver.shared_inbox_or_inbox());\n\n  // report is stored on the creator's instance, and sometimes listed there, so updates should be\n  // sent there\n  let report_creator_site =\n    Site::read_from_instance_id(&mut context.pool(), report_creator.0.instance_id).await?;\n  inboxes.add_inbox(report_creator_site.inbox_url.into());\n\n  if let Some(community) = local_community(receiver) {\n    // send to all moderators\n    let moderators =\n      CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;\n    for m in moderators {\n      inboxes.add_inbox(m.moderator.inbox_url.into());\n    }\n\n    // also send report to user's home instance if possible\n    let object_creator_id = match object_id.dereference_local(context).await? {\n      ReportableObjects::Left(PostOrComment::Left(p)) => p.creator_id,\n      ReportableObjects::Left(PostOrComment::Right(c)) => c.creator_id,\n      _ => return Ok(inboxes),\n    };\n    let object_creator = Person::read(&mut context.pool(), object_creator_id).await?;\n    let object_creator_site: Option<ApubSite> =\n      Site::read_from_instance_id(&mut context.pool(), object_creator.instance_id)\n        .await\n        .ok()\n        .map(Into::into);\n    if let Some(inbox) = object_creator_site.map(|s| s.shared_inbox_or_inbox()) {\n      inboxes.add_inbox(inbox);\n    }\n  }\n  Ok(inboxes)\n}\n\nfn local_community(site_or_community: &Either<ApubSite, ApubCommunity>) -> Option<&ApubCommunity> {\n  site_or_community.as_ref().right().filter(|c| c.local)\n}\n\nasync fn verify_mod_or_admin_action(\n  person_id: &ObjectId<ApubPerson>,\n  site_or_community: &Either<ApubSite, ApubCommunity>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  match site_or_community {\n    Either::Left(site) => {\n      // admin action comes from the correct instance, so it was presumably done\n      // by an instance admin.\n      // TODO: federate instance admin status and check it here\n      if person_id.inner().domain() == site.ap_id.domain() {\n        return Ok(());\n      }\n      let admin = person_id.dereference(context).await?;\n      let local_user_view = LocalUserView::read_person(&mut context.pool(), admin.id).await?;\n      is_admin(&local_user_view)\n    }\n    Either::Right(community) => verify_mod_action(person_id, community, context).await,\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/report.rs",
    "content": "use super::{local_community, report_inboxes};\nuse crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::community::{\n    announce::AnnounceActivity,\n    report::{Report, ReportObject},\n  },\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::FlagType,\n  traits::{Activity, Object},\n};\nuse either::Either;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{\n    check_comment_deleted_or_removed,\n    check_community_deleted_removed,\n    check_post_deleted_or_removed,\n  },\n};\nuse lemmy_apub_objects::{\n  objects::{\n    PostOrComment,\n    ReportableObjects,\n    community::ApubCommunity,\n    instance::ApubSite,\n    person::ApubPerson,\n  },\n  utils::functions::{verify_person_in_community, verify_person_in_site_or_community},\n};\nuse lemmy_db_schema::{\n  source::{\n    comment_report::{CommentReport, CommentReportForm},\n    community::Community,\n    community_report::{CommunityReport, CommunityReportForm},\n    post::Post,\n    post_report::{PostReport, PostReportForm},\n  },\n  traits::Reportable,\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl Report {\n  pub(crate) fn new(\n    object_id: &ObjectId<ReportableObjects>,\n    actor: &ApubPerson,\n    receiver: &Either<ApubSite, ApubCommunity>,\n    reason: Option<String>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Self> {\n    let kind = FlagType::Flag;\n    let id = generate_activity_id(kind.clone(), context)?;\n    Ok(Report {\n      actor: actor.id().clone().into(),\n      to: [receiver.id().clone().into()],\n      object: ReportObject::Lemmy(object_id.clone()),\n      summary: reason,\n      content: None,\n      kind,\n      id: id.clone(),\n      audience: receiver.as_ref().right().map(|c| c.ap_id.clone().into()),\n    })\n  }\n\n  pub(crate) async fn send(\n    object_id: ObjectId<ReportableObjects>,\n    actor: &ApubPerson,\n    receiver: &Either<ApubSite, ApubCommunity>,\n    reason: String,\n    context: Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let report = Self::new(&object_id, actor, receiver, Some(reason), &context)?;\n    let inboxes = report_inboxes(object_id, receiver, actor, &context).await?;\n\n    send_lemmy_activity(&context, report, actor, inboxes, false).await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for Report {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let receiver = self.to[0].dereference(context).await?;\n    verify_person_in_site_or_community(&self.actor, &receiver, context).await?;\n    match self.object.dereference(context).await? {\n      ReportableObjects::Left(PostOrComment::Left(post)) => {\n        let community: ApubCommunity = Community::read(&mut context.pool(), post.community_id)\n          .await?\n          .into();\n        check_community_deleted_or_removed(&community)?;\n        verify_person_in_community(&self.actor, &community, context).await?;\n        check_post_deleted_or_removed(&post)?;\n      }\n      ReportableObjects::Left(PostOrComment::Right(comment)) => {\n        let post = Post::read(&mut context.pool(), comment.post_id).await?;\n        let community: ApubCommunity = Community::read(&mut context.pool(), post.community_id)\n          .await?\n          .into();\n        verify_person_in_community(&self.actor, &community, context).await?;\n        check_community_deleted_or_removed(&community)?;\n        check_comment_deleted_or_removed(&comment)?;\n      }\n      ReportableObjects::Right(community) => {\n        check_community_deleted_removed(&community)?;\n      }\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let actor = self.actor.dereference(context).await?;\n    let reason = self.reason()?;\n    match self.object.dereference(context).await? {\n      ReportableObjects::Left(PostOrComment::Left(post)) => {\n        let report_form = PostReportForm {\n          creator_id: actor.id,\n          post_id: post.id,\n          original_post_name: post.name.clone(),\n          original_post_url: post.url.clone(),\n          reason,\n          original_post_body: post.body.clone(),\n          violates_instance_rules: false,\n        };\n        PostReport::report(&mut context.pool(), &report_form).await?;\n      }\n      ReportableObjects::Left(PostOrComment::Right(comment)) => {\n        let report_form = CommentReportForm {\n          creator_id: actor.id,\n          comment_id: comment.id,\n          original_comment_text: comment.content.clone(),\n          reason,\n          violates_instance_rules: false,\n        };\n        CommentReport::report(&mut context.pool(), &report_form).await?;\n      }\n      ReportableObjects::Right(community) => {\n        let report_form = CommunityReportForm {\n          creator_id: actor.id,\n          community_id: community.id,\n          reason,\n          original_community_name: community.name.clone(),\n          original_community_title: community.title.clone(),\n          original_community_banner: community.banner.clone(),\n          original_community_icon: community.icon.clone(),\n          original_community_summary: community.summary.clone(),\n          original_community_sidebar: community.sidebar.clone(),\n        };\n        CommunityReport::report(&mut context.pool(), &report_form).await?;\n      }\n    };\n\n    let receiver = self.to[0].dereference(context).await?;\n    if let Some(community) = local_community(&receiver) {\n      // forward to remote mods\n      let object_id = self.object.object_id(context).await?;\n      let announce = AnnouncableActivities::Report(self);\n      let announce = AnnounceActivity::new(announce.try_into()?, community, context)?;\n      let inboxes = report_inboxes(object_id, &receiver, &actor, context).await?;\n      send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?;\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/resolve_report.rs",
    "content": "use super::{local_community, report_inboxes, verify_mod_or_admin_action};\nuse crate::{\n  activity_lists::AnnouncableActivities,\n  generate_activity_id,\n  protocol::community::{\n    announce::AnnounceActivity,\n    report::Report,\n    resolve_report::{ResolveReport, ResolveType},\n  },\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Object},\n};\nuse either::Either;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{\n    PostOrComment,\n    ReportableObjects,\n    community::ApubCommunity,\n    instance::ApubSite,\n    person::ApubPerson,\n  },\n  utils::functions::verify_person_in_site_or_community,\n};\nuse lemmy_db_schema::{\n  source::{\n    comment_report::CommentReport,\n    community_report::CommunityReport,\n    post_report::PostReport,\n  },\n  traits::Reportable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl ResolveReport {\n  pub(crate) async fn send(\n    object_id: ObjectId<ReportableObjects>,\n    actor: &ApubPerson,\n    report_creator: &ApubPerson,\n    receiver: &Either<ApubSite, ApubCommunity>,\n    context: Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let kind = ResolveType::Resolve;\n    let id = generate_activity_id(kind.clone(), &context)?;\n    let object = Report::new(&object_id, report_creator, receiver, None, &context)?;\n    let resolve = ResolveReport {\n      actor: actor.id().clone().into(),\n      to: [receiver.id().clone().into()],\n      object,\n      kind,\n      id: id.clone(),\n      audience: receiver.as_ref().right().map(|c| c.ap_id.clone().into()),\n    };\n    let inboxes = report_inboxes(object_id, receiver, report_creator, &context).await?;\n\n    send_lemmy_activity(&context, resolve, actor, inboxes, false).await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for ResolveReport {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    self.object.verify(context).await?;\n    let receiver = self.object.to[0].dereference(context).await?;\n    verify_person_in_site_or_community(&self.actor, &receiver, context).await?;\n    verify_urls_match(self.to[0].inner(), self.object.to[0].inner())?;\n    verify_mod_or_admin_action(&self.actor, &receiver, context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let reporter = self.object.actor.dereference(context).await?;\n    let actor = self.actor.dereference(context).await?;\n    match self.object.object.dereference(context).await? {\n      ReportableObjects::Left(PostOrComment::Left(post)) => {\n        PostReport::resolve_apub(&mut context.pool(), post.id, reporter.id, actor.id).await?;\n      }\n      ReportableObjects::Left(PostOrComment::Right(comment)) => {\n        CommentReport::resolve_apub(&mut context.pool(), comment.id, reporter.id, actor.id).await?;\n      }\n      ReportableObjects::Right(community) => {\n        CommunityReport::resolve_apub(&mut context.pool(), community.id, reporter.id, actor.id)\n          .await?;\n      }\n    };\n\n    let receiver = self.object.to[0].dereference(context).await?;\n    if let Some(community) = local_community(&receiver) {\n      // forward to remote mods\n      let object_id = self.object.object.object_id(context).await?;\n      let announce = AnnouncableActivities::ResolveReport(self);\n      let announce = AnnounceActivity::new(announce.try_into()?, community, context)?;\n      let inboxes = report_inboxes(object_id, &receiver, &reporter, context).await?;\n      send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?;\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/community/update.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  community::{AnnouncableActivities, send_activity_in_community},\n  generate_activity_id,\n  protocol::community::update::Update,\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::{activity::UpdateType, public},\n  traits::{Activity, Object},\n};\nuse either::Either;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, multi_community::ApubMultiCommunity, person::ApubPerson},\n  utils::{\n    functions::{generate_to, verify_mod_action, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::source::{\n  activity::ActivitySendTargets,\n  community::Community,\n  modlog::{Modlog, ModlogInsertForm},\n  multi_community::MultiCommunity,\n  person::Person,\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\npub(crate) async fn send_update_community(\n  community: Community,\n  actor: Person,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let community: ApubCommunity = community.into();\n  let actor: ApubPerson = actor.into();\n  let id = generate_activity_id(UpdateType::Update, &context)?;\n  let update = Update {\n    actor: actor.id().clone().into(),\n    to: generate_to(&community)?,\n    object: Either::Left(community.clone().into_json(&context).await?),\n    cc: vec![community.id().clone()],\n    kind: UpdateType::Update,\n    id: id.clone(),\n    audience: Some(community.ap_id.clone().into()),\n  };\n\n  let activity = AnnouncableActivities::UpdateCommunity(Box::new(update));\n  send_activity_in_community(\n    activity,\n    &actor,\n    &community,\n    ActivitySendTargets::empty(),\n    true,\n    &context,\n  )\n  .await\n}\n\npub(crate) async fn send_update_multi_community(\n  multi: MultiCommunity,\n  actor: Person,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let multi: ApubMultiCommunity = multi.into();\n  let actor: ApubPerson = actor.into();\n  let id = generate_activity_id(UpdateType::Update, &context)?;\n  let update = Update {\n    actor: actor.id().clone().into(),\n    to: vec![multi.ap_id.clone().into(), public()],\n    object: Either::Right(multi.clone().into_json(&context).await?),\n    cc: vec![],\n    kind: UpdateType::Update,\n    id: id.clone(),\n    audience: Some(multi.ap_id.clone().into()),\n  };\n\n  let activity = AnnouncableActivities::UpdateCommunity(Box::new(update));\n  let mut inboxes = ActivitySendTargets::empty();\n  inboxes.add_inboxes(MultiCommunity::follower_inboxes(&mut context.pool(), multi.id).await?);\n  send_lemmy_activity(&context, activity, &actor, inboxes, false).await\n}\n\n#[async_trait::async_trait]\nimpl Activity for Update {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    match &self.object {\n      Either::Left(c) => {\n        let community = self.community(context).await?;\n        verify_visibility(&self.to, &self.cc, &community)?;\n        verify_mod_action(&self.actor, &community, context).await?;\n        check_community_deleted_or_removed(&community)?;\n        ApubCommunity::verify(c, &community.ap_id.clone().into(), context).await?;\n      }\n      Either::Right(m) => ApubMultiCommunity::verify(m, &self.id, context).await?,\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    match &self.object {\n      Either::Left(c) => {\n        let old_community = self.community(context).await?;\n\n        let community = ApubCommunity::from_json(c.clone(), context).await?;\n\n        if old_community.visibility != community.visibility {\n          let actor = self.actor.dereference(context).await?;\n          let form = ModlogInsertForm::mod_change_community_visibility(actor.id, old_community.id);\n          Modlog::create(&mut context.pool(), &[form]).await?;\n        }\n      }\n      Either::Right(m) => {\n        ApubMultiCommunity::from_json(m.clone(), context).await?;\n      }\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/create_or_update/comment.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  create_or_update::{parse_apub_mentions, tagged_user_inboxes},\n  generate_activity_id,\n  protocol::{CreateOrUpdateType, create_or_update::note::CreateOrUpdateNote},\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::verification::{verify_domains_match, verify_urls_match},\n  traits::{Activity, Object},\n};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::NotifyData,\n  utils::{check_is_mod_or_admin, check_post_deleted_or_removed},\n};\nuse lemmy_apub_objects::{\n  objects::{comment::ApubComment, community::ApubCommunity, person::ApubPerson},\n  utils::{\n    functions::{generate_to, verify_person_in_community, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    comment::{Comment, CommentActions, CommentLikeForm},\n    community::Community,\n    person::Person,\n    post::Post,\n  },\n  traits::Likeable,\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse serde_json::{from_value, to_value};\nuse url::Url;\n\nimpl CreateOrUpdateNote {\n  pub(crate) async fn send(\n    comment: Comment,\n    person_id: PersonId,\n    kind: CreateOrUpdateType,\n    context: Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    // TODO: might be helpful to add a comment method to retrieve community directly\n    let post_id = comment.post_id;\n    let post = Post::read(&mut context.pool(), post_id).await?;\n    let community_id = post.community_id;\n    let person: ApubPerson = Person::read(&mut context.pool(), person_id).await?.into();\n    let community: ApubCommunity = Community::read(&mut context.pool(), community_id)\n      .await?\n      .into();\n\n    let id = generate_activity_id(kind.clone(), &context)?;\n    let note = ApubComment(comment).into_json(&context).await?;\n\n    let create_or_update = CreateOrUpdateNote {\n      actor: person.id().clone().into(),\n      to: generate_to(&community)?,\n      cc: note.cc.clone(),\n      tag: note.tag.clone(),\n      object: note,\n      kind,\n      id: id.clone(),\n      audience: Some(community.ap_id.clone().into()),\n    };\n\n    let inboxes = tagged_user_inboxes(&create_or_update.tag, &context).await?;\n\n    // AnnouncableActivities doesnt contain Comment activity but only NoteWrapper,\n    // to be able to handle both comment and private message. So to send this out we need\n    // to convert this to NoteWrapper, by serializing and then deserializing again.\n    let converted = from_value(to_value(create_or_update)?)?;\n    let activity = AnnouncableActivities::CreateOrUpdateNoteWrapper(converted);\n    send_activity_in_community(activity, &person, &community, inboxes, false, &context).await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for CreateOrUpdateNote {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let post = self.object.get_parents(context).await?.0;\n    let community = self.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n\n    verify_person_in_community(&self.actor, &community, context).await?;\n    verify_domains_match(self.actor.inner(), self.object.id.inner())?;\n    check_community_deleted_or_removed(&community)?;\n    check_post_deleted_or_removed(&post)?;\n    verify_urls_match(self.actor.inner(), self.object.attributed_to.inner())?;\n\n    ApubComment::verify(&self.object, self.actor.inner(), context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n    // Need to do this check here instead of Note::from_json because we need the person who\n    // send the activity, not the comment author.\n    let existing_comment = self.object.id.dereference_local(context).await.ok();\n    let (post, _) = self.object.get_parents(context).await?;\n    if let (Some(distinguished), Some(existing_comment)) =\n      (self.object.distinguished, existing_comment)\n      && distinguished != existing_comment.distinguished\n    {\n      let creator = self.actor.dereference(context).await?;\n      check_is_mod_or_admin(&mut context.pool(), creator.id, post.community_id).await?;\n    }\n\n    let comment = ApubComment::from_json(self.object, context).await?;\n\n    // author likes their own comment by default\n    let like_form = CommentLikeForm::new(comment.id, comment.creator_id, Some(true));\n    CommentActions::like(&mut context.pool(), &like_form).await?;\n\n    // Calculate initial hot_rank\n    Comment::update_hot_rank(&mut context.pool(), comment.id).await?;\n\n    let do_send_email =\n      self.kind == CreateOrUpdateType::Create && !site_view.local_site.disable_email_notifications;\n    let actor = self.actor.dereference(context).await?;\n\n    let community = Community::read(&mut context.pool(), post.community_id).await?;\n    NotifyData {\n      comment: Some(comment.0),\n      do_send_email,\n      apub_mentions: Some(parse_apub_mentions(&self.tag, context).await?),\n      ..NotifyData::new(post.0, actor.0, community)\n    }\n    .send(context);\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/create_or_update/mod.rs",
    "content": "use activitypub_federation::{config::Data, traits::Actor};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::protocol::tags::ApubTag;\nuse lemmy_db_schema::source::{activity::ActivitySendTargets, person::Person};\nuse lemmy_utils::error::LemmyResult;\n\npub mod comment;\npub(crate) mod note_wrapper;\npub mod post;\npub mod private_message;\n\n/// From Activitypub `tag` field extract the mentions, and return the inboxes for these users.\n/// Used when sending out activity to ensure the mentioned users see it.\nasync fn tagged_user_inboxes(\n  tagged_users: &[ApubTag],\n  context: &Data<LemmyContext>,\n) -> LemmyResult<ActivitySendTargets> {\n  let tagged_users: Vec<_> = tagged_users.iter().flat_map(ApubTag::mention_id).collect();\n  let mut inboxes = ActivitySendTargets::empty();\n  for t in tagged_users {\n    let person = t.dereference(context).await?;\n    inboxes.add_inbox(person.shared_inbox_or_inbox());\n  }\n  Ok(inboxes)\n}\n\n/// Extracts the users who are mentioned in a received, federated post.\nasync fn parse_apub_mentions(\n  tags: &[ApubTag],\n  context: &Data<LemmyContext>,\n) -> LemmyResult<Vec<Person>> {\n  let mentions: Vec<_> = tags.iter().filter_map(ApubTag::mention_id).collect();\n  let mut res = vec![];\n  for m in mentions {\n    let person = m.dereference(context).await?.0;\n    if person.local {\n      res.push(person);\n    }\n  }\n  Ok(res)\n}\n"
  },
  {
    "path": "crates/apub/activities/src/create_or_update/note_wrapper.rs",
    "content": "use crate::protocol::create_or_update::{\n  note::CreateOrUpdateNote,\n  note_wrapper::CreateOrUpdateNoteWrapper,\n  private_message::CreateOrUpdatePrivateMessage,\n};\nuse activitypub_federation::{config::Data, traits::Activity};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{objects::community::ApubCommunity, utils::protocol::InCommunity};\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse serde_json::{from_value, to_value};\nuse url::Url;\n\n/// In Activitypub, both private messages and comments are represented by `type: Note` which\n/// makes it difficult to distinguish them. This wrapper handles receiving of both types, and\n/// routes them to the correct handler.\n#[async_trait::async_trait]\nimpl Activity for CreateOrUpdateNoteWrapper {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    &self.actor\n  }\n\n  async fn verify(&self, _context: &Data<Self::DataType>) -> LemmyResult<()> {\n    // Do everything in receive to avoid extra checks.\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    // Use serde to convert NoteWrapper either into Comment or PrivateMessage,\n    // depending on conditions below. This works because NoteWrapper keeps all\n    // additional data in field `other: Map<String, Value>`.\n    let val = to_value(self)?;\n\n    // Convert self to a comment and get the community. If the conversion is\n    // successful and a community is returned, this is a comment.\n    let comment = from_value::<CreateOrUpdateNote>(val.clone());\n    if let Ok(comment) = comment\n      && comment.community(context).await.is_ok()\n    {\n      CreateOrUpdateNote::verify(&comment, context).await?;\n      CreateOrUpdateNote::receive(comment, context).await?;\n      return Ok(());\n    }\n\n    // If any of the previous checks failed, we are dealing with a private message.\n    let private_message = from_value(val)?;\n    CreateOrUpdatePrivateMessage::verify(&private_message, context).await?;\n    CreateOrUpdatePrivateMessage::receive(private_message, context).await?;\n    Ok(())\n  }\n}\n\nimpl InCommunity for CreateOrUpdateNoteWrapper {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    // Same logic as in receive. In case this is a private message, an error is returned.\n    let val = to_value(self)?;\n    let comment: CreateOrUpdateNote = from_value(val.clone())?;\n    comment.community(context).await\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/create_or_update/post.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  create_or_update::{parse_apub_mentions, tagged_user_inboxes},\n  generate_activity_id,\n  protocol::{CreateOrUpdateType, create_or_update::page::CreateOrUpdatePage},\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::verification::{verify_domains_match, verify_urls_match},\n  traits::{Activity, Object},\n};\nuse chrono::Utc;\nuse lemmy_api_utils::{context::LemmyContext, notify::NotifyData};\nuse lemmy_apub_objects::{\n  objects::{\n    community::ApubCommunity,\n    person::ApubPerson,\n    post::{ApubPost, post_nsfw, update_apub_post_tags},\n  },\n  utils::{\n    functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    community::Community,\n    person::Person,\n    post::{Post, PostActions, PostLikeForm, PostUpdateForm},\n  },\n  traits::Likeable,\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};\nuse url::Url;\n\nimpl CreateOrUpdatePage {\n  pub async fn new(\n    post: ApubPost,\n    actor: &ApubPerson,\n    community: &ApubCommunity,\n    kind: CreateOrUpdateType,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<CreateOrUpdatePage> {\n    let id = generate_activity_id(kind.clone(), context)?;\n    Ok(CreateOrUpdatePage {\n      actor: actor.id().clone().into(),\n      to: generate_to(community)?,\n      object: post.into_json(context).await?,\n      cc: vec![community.id().clone()],\n      kind,\n      id: id.clone(),\n      audience: Some(community.ap_id.clone().into()),\n    })\n  }\n\n  pub(crate) async fn send(\n    post: Post,\n    person_id: PersonId,\n    kind: CreateOrUpdateType,\n    context: Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let community_id = post.community_id;\n    let person: ApubPerson = Person::read(&mut context.pool(), person_id).await?.into();\n    let community: ApubCommunity = Community::read(&mut context.pool(), community_id)\n      .await?\n      .into();\n\n    let create_or_update =\n      CreateOrUpdatePage::new(post.into(), &person, &community, kind, &context).await?;\n    let inboxes = tagged_user_inboxes(&create_or_update.object.tag, &context).await?;\n    let activity = AnnouncableActivities::CreateOrUpdatePost(create_or_update);\n    send_activity_in_community(activity, &person, &community, inboxes, false, &context).await?;\n    Ok(())\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for CreateOrUpdatePage {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.community(context).await?;\n    verify_visibility(&self.to, &self.cc, &community)?;\n    check_community_deleted_or_removed(&community)?;\n    verify_domains_match(self.actor.inner(), self.object.id.inner())?;\n    ApubPost::verify(&self.object, self.actor.inner(), context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.community(context).await?;\n    let is_same_actor =\n      verify_urls_match(self.actor.inner(), self.object.creator()?.inner()).is_ok();\n    let original_post =\n      Post::read_from_apub_id(&mut context.pool(), self.object.id.clone().into()).await;\n    let is_mod_action = verify_mod_action(&self.actor, &community, context)\n      .await\n      .is_ok();\n    // allow mods to edit the post\n    if !is_same_actor && let Ok(Some(post)) = original_post {\n      if is_mod_action {\n        let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n        let form = PostUpdateForm {\n          updated_at: Some(Some(Utc::now())),\n          nsfw: post_nsfw(&self.object, &community, Some(&local_site), context).await?,\n          ..Default::default()\n        };\n        Post::update(&mut context.pool(), post.id, &form).await?;\n        update_apub_post_tags(&self.object, &post, context).await?;\n        return Ok(());\n      } else {\n        return Err(LemmyErrorType::NotAModerator.into());\n      }\n    }\n\n    if !is_mod_action {\n      verify_person_in_community(&self.actor, &community, context).await?;\n    }\n\n    verify_urls_match(self.actor.inner(), self.object.creator()?.inner())?;\n    let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n    let post = ApubPost::from_json(self.object.clone(), context).await?;\n\n    // author likes their own post by default\n    let like_form = PostLikeForm::new(post.id, post.creator_id, Some(true));\n    PostActions::like(&mut context.pool(), &like_form).await?;\n\n    // Calculate initial hot_rank for post\n    Post::update_ranks(&mut context.pool(), post.id).await?;\n\n    let do_send_email =\n      self.kind == CreateOrUpdateType::Create && !site_view.local_site.disable_email_notifications;\n    let actor = self.actor.dereference(context).await?;\n\n    NotifyData {\n      apub_mentions: Some(parse_apub_mentions(&self.object.tag, context).await?),\n      do_send_email,\n      ..NotifyData::new(post.0, actor.0, community.0)\n    }\n    .send(context);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/create_or_update/private_message.rs",
    "content": "use crate::{\n  generate_activity_id,\n  protocol::{CreateOrUpdateType, create_or_update::private_message::CreateOrUpdatePrivateMessage},\n  send_lemmy_activity,\n  verify_person,\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::verification::{verify_domains_match, verify_urls_match},\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::{person::ApubPerson, private_message::ApubPrivateMessage};\nuse lemmy_db_schema::source::activity::ActivitySendTargets;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\npub(crate) async fn send_create_or_update_pm(\n  pm_view: PrivateMessageView,\n  kind: CreateOrUpdateType,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let actor: ApubPerson = pm_view.creator.into();\n  let recipient: ApubPerson = pm_view.recipient.into();\n\n  let id = generate_activity_id(kind.clone(), &context)?;\n  let create_or_update = CreateOrUpdatePrivateMessage {\n    id: id.clone(),\n    actor: actor.id().clone().into(),\n    to: [recipient.id().clone().into()],\n    object: ApubPrivateMessage(pm_view.private_message.clone())\n      .into_json(&context)\n      .await?,\n    kind,\n  };\n  let inbox = ActivitySendTargets::to_inbox(recipient.shared_inbox_or_inbox());\n  send_lemmy_activity(&context, create_or_update, &actor, inbox, true).await\n}\n\n#[async_trait::async_trait]\nimpl Activity for CreateOrUpdatePrivateMessage {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    verify_person(&self.actor, context).await?;\n    verify_domains_match(self.actor.inner(), self.object.id.inner())?;\n    verify_domains_match(self.to[0].inner(), self.object.to[0].inner())?;\n    verify_urls_match(self.actor.inner(), self.object.attributed_to.inner())?;\n    ApubPrivateMessage::verify(&self.object, self.actor.inner(), context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    ApubPrivateMessage::from_json(self.object, context).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/deletion/delete.rs",
    "content": "use crate::{\n  MOD_ACTION_DEFAULT_REASON,\n  deletion::{DeletableObjects, receive_delete_action, verify_delete_activity},\n  generate_activity_id,\n  protocol::{IdOrNestedObject, deletion::delete::Delete},\n};\nuse activitypub_federation::{config::Data, kinds::activity::DeleteType, traits::Activity};\nuse lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action};\nuse lemmy_apub_objects::objects::person::ApubPerson;\nuse lemmy_db_schema::{\n  source::{\n    comment::{Comment, CommentUpdateForm},\n    comment_report::CommentReport,\n    community::{Community, CommunityUpdateForm},\n    community_report::CommunityReport,\n    modlog::{Modlog, ModlogInsertForm},\n    post::{Post, PostUpdateForm},\n    post_report::PostReport,\n  },\n  traits::Reportable,\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse url::Url;\n\n#[async_trait::async_trait]\nimpl Activity for Delete {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    verify_delete_activity(self, self.summary.is_some(), context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    if let Some(reason) = self.summary {\n      // We set reason to empty string if it doesn't exist, to distinguish between delete and\n      // remove. Here we change it back to option, so we don't write it to db.\n      let reason = if reason.is_empty() {\n        None\n      } else {\n        Some(reason)\n      };\n      receive_remove_action(\n        &self.actor.dereference(context).await?,\n        self.object.id(),\n        reason,\n        self.with_replies,\n        context,\n      )\n      .await\n    } else {\n      receive_delete_action(\n        self.object.id(),\n        &self.actor,\n        true,\n        self.remove_data,\n        context,\n      )\n      .await\n    }\n  }\n}\n\nimpl Delete {\n  pub(in crate::deletion) fn new(\n    actor: &ApubPerson,\n    object: DeletableObjects,\n    to: Vec<Url>,\n    community: Option<&Community>,\n    summary: Option<String>,\n    with_replies: Option<bool>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Delete> {\n    let id = generate_activity_id(DeleteType::Delete, context)?;\n    let cc: Option<Url> = community.map(|c| c.ap_id.clone().into());\n    Ok(Delete {\n      actor: actor.ap_id.clone().into(),\n      to,\n      object: IdOrNestedObject::Id(object.id().clone()),\n      cc: cc.into_iter().collect(),\n      kind: DeleteType::Delete,\n      summary,\n      id,\n      audience: community.map(|c| c.ap_id.clone().into()),\n      remove_data: None,\n      with_replies,\n    })\n  }\n}\n\npub(crate) async fn receive_remove_action(\n  actor: &ApubPerson,\n  object: &Url,\n  reason: Option<String>,\n  with_replies: Option<bool>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let reason = reason.unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string());\n  match DeletableObjects::read_from_db(object, context).await? {\n    DeletableObjects::Community(community) => {\n      if community.local {\n        return Err(UntranslatedError::OnlyLocalAdminCanRemoveCommunity.into());\n      }\n      CommunityReport::resolve_all_for_object(&mut context.pool(), community.id, actor.id).await?;\n      let community_owner =\n        CommunityModeratorView::top_mod_for_community(&mut context.pool(), community.id).await?;\n      let form = ModlogInsertForm::admin_remove_community(\n        actor,\n        community.id,\n        community_owner,\n        true,\n        &reason,\n      );\n      let action = Modlog::create(&mut context.pool(), &[form]).await?;\n      notify_mod_action(action.clone(), context.app_data());\n\n      Community::update(\n        &mut context.pool(),\n        community.id,\n        &CommunityUpdateForm {\n          removed: Some(true),\n          ..Default::default()\n        },\n      )\n      .await?;\n    }\n    DeletableObjects::Post(post) => {\n      PostReport::resolve_all_for_object(&mut context.pool(), post.id, actor.id).await?;\n      let form = ModlogInsertForm::mod_remove_post(actor.id, &post, true, &reason, None);\n      let action = Modlog::create(&mut context.pool(), &[form]).await?;\n      notify_mod_action(action, context.app_data());\n      let post = Post::update(\n        &mut context.pool(),\n        post.id,\n        &PostUpdateForm {\n          removed: Some(true),\n          ..Default::default()\n        },\n      )\n      .await?;\n\n      let remove_children = with_replies.unwrap_or_default();\n      if remove_children {\n        CommentReport::resolve_all_for_post(&mut context.pool(), post.id, actor.id).await?;\n        let updated_comments: Vec<Comment> =\n          Comment::update_removed_for_post(&mut context.pool(), post.id, true).await?;\n\n        let forms: Vec<_> = updated_comments\n          .iter()\n          // Filter out deleted comments here so their content doesn't show up in the modlog.\n          .filter(|c| !c.deleted)\n          .map(|comment| {\n            ModlogInsertForm::mod_remove_comment(\n              actor.id,\n              comment,\n              post.community_id,\n              true,\n              &reason,\n              None,\n            )\n          })\n          .collect();\n\n        let actions = Modlog::create(&mut context.pool(), &forms).await?;\n        notify_mod_action(actions, context);\n      }\n    }\n    DeletableObjects::Comment(comment) => {\n      let remove_children = with_replies.unwrap_or_default();\n\n      // Read the post to get the community_id\n      let community_id = Post::read(&mut context.pool(), comment.post_id)\n        .await?\n        .community_id;\n\n      if remove_children {\n        CommentReport::resolve_all_for_thread(&mut context.pool(), &comment.path, actor.id).await?;\n        let updated_comments: Vec<Comment> = Comment::update_removed_for_comment_and_children(\n          &mut context.pool(),\n          &comment.path,\n          true,\n        )\n        .await?;\n\n        let forms: Vec<_> = updated_comments\n          .iter()\n          // Filter out deleted comments here so their content doesn't show up in the modlog.\n          .filter(|c| !c.deleted)\n          .map(|comment| {\n            ModlogInsertForm::mod_remove_comment(\n              actor.id,\n              comment,\n              community_id,\n              true,\n              &reason,\n              None,\n            )\n          })\n          .collect();\n\n        let actions = Modlog::create(&mut context.pool(), &forms).await?;\n        notify_mod_action(actions, context);\n      } else {\n        CommentReport::resolve_all_for_object(&mut context.pool(), comment.id, actor.id).await?;\n        let form = ModlogInsertForm::mod_remove_comment(\n          actor.id,\n          &comment,\n          community_id,\n          true,\n          &reason,\n          None,\n        );\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context.app_data());\n        Comment::update(\n          &mut context.pool(),\n          comment.id,\n          &CommentUpdateForm {\n            removed: Some(true),\n            ..Default::default()\n          },\n        )\n        .await?;\n      }\n    }\n    // TODO these need to be implemented yet, for now, return errors\n    DeletableObjects::PrivateMessage(_) => return Err(LemmyErrorType::NotFound.into()),\n    DeletableObjects::Person(_) => return Err(LemmyErrorType::NotFound.into()),\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/deletion/mod.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  check_community_deleted_or_removed,\n  community::send_activity_in_community,\n  protocol::deletion::{delete::Delete, undo_delete::UndoDelete},\n  send_lemmy_activity,\n  verify_person,\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::public,\n  protocol::verification::{verify_domains_match, verify_urls_match},\n  traits::{Actor, Object},\n};\nuse lemmy_api_utils::{context::LemmyContext, utils::purge_user_account};\nuse lemmy_apub_objects::{\n  objects::{\n    comment::ApubComment,\n    community::ApubCommunity,\n    person::ApubPerson,\n    post::ApubPost,\n    private_message::ApubPrivateMessage,\n  },\n  utils::{\n    functions::{\n      generate_to,\n      verify_is_public,\n      verify_mod_action,\n      verify_person_in_community,\n      verify_visibility,\n    },\n    protocol::InCommunity,\n  },\n};\nuse lemmy_db_schema::source::{\n  activity::ActivitySendTargets,\n  comment::{Comment, CommentUpdateForm},\n  community::{Community, CommunityUpdateForm},\n  person::Person,\n  post::{Post, PostUpdateForm},\n  private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageUpdateForm},\n};\nuse lemmy_db_schema_file::enums::CommunityVisibility;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\nuse std::ops::Deref;\nuse url::Url;\n\npub mod delete;\npub mod undo_delete;\n\n/// Parameter `reason` being set indicates that this is a removal by a mod. If its unset, this\n/// action was done by a normal user.\npub(crate) async fn send_apub_delete_in_community(\n  actor: Person,\n  mut community: Community,\n  object: DeletableObjects,\n  reason: Option<String>,\n  deleted: bool,\n  with_replies: Option<bool>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  // Bypass visibility check for sending this activity type\n  community.visibility = CommunityVisibility::Public;\n\n  let actor = ApubPerson::from(actor);\n  let is_mod_action = reason.is_some();\n  let to = generate_to(&community)?;\n  let activity = if deleted {\n    let delete = Delete::new(\n      &actor,\n      object,\n      to,\n      Some(&community),\n      reason,\n      with_replies,\n      context,\n    )?;\n    AnnouncableActivities::Delete(delete)\n  } else {\n    let undo = UndoDelete::new(\n      &actor,\n      object,\n      to,\n      Some(&community),\n      reason,\n      with_replies,\n      context,\n    )?;\n    AnnouncableActivities::UndoDelete(undo)\n  };\n\n  send_activity_in_community(\n    activity,\n    &actor,\n    &community.into(),\n    ActivitySendTargets::empty(),\n    is_mod_action,\n    context,\n  )\n  .await\n}\n\npub(crate) async fn send_apub_delete_private_message(\n  actor: &ApubPerson,\n  pm: DbPrivateMessage,\n  deleted: bool,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let recipient_id = pm.recipient_id;\n  let recipient: ApubPerson = Person::read(&mut context.pool(), recipient_id)\n    .await?\n    .into();\n\n  let deletable = DeletableObjects::PrivateMessage(pm.into());\n  let inbox = ActivitySendTargets::to_inbox(recipient.shared_inbox_or_inbox());\n  if deleted {\n    let delete: Delete = Delete::new(\n      actor,\n      deletable,\n      vec![recipient.id().clone()],\n      None,\n      None,\n      None,\n      &context,\n    )?;\n    send_lemmy_activity(&context, delete, actor, inbox, true).await?;\n  } else {\n    let undo = UndoDelete::new(\n      actor,\n      deletable,\n      vec![recipient.id().clone()],\n      None,\n      None,\n      None,\n      &context,\n    )?;\n    send_lemmy_activity(&context, undo, actor, inbox, true).await?;\n  };\n  Ok(())\n}\n\npub async fn send_apub_delete_user(\n  person: Person,\n  remove_data: bool,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let person: ApubPerson = person.into();\n\n  let deletable = DeletableObjects::Person(person.clone());\n  let mut delete: Delete = Delete::new(\n    &person,\n    deletable,\n    vec![public()],\n    None,\n    None,\n    None,\n    &context,\n  )?;\n  delete.remove_data = Some(remove_data);\n\n  let inboxes = ActivitySendTargets::to_all_instances();\n\n  send_lemmy_activity(&context, delete, &person, inboxes, true).await?;\n  Ok(())\n}\n\npub enum DeletableObjects {\n  Community(ApubCommunity),\n  Person(ApubPerson),\n  Comment(ApubComment),\n  Post(ApubPost),\n  PrivateMessage(ApubPrivateMessage),\n}\n\nimpl DeletableObjects {\n  pub(crate) async fn read_from_db(\n    ap_id: &Url,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<DeletableObjects> {\n    if let Some(c) = ApubCommunity::read_from_id(ap_id.clone(), context).await? {\n      return Ok(DeletableObjects::Community(c));\n    }\n    if let Some(p) = ApubPerson::read_from_id(ap_id.clone(), context).await? {\n      return Ok(DeletableObjects::Person(p));\n    }\n    if let Some(p) = ApubPost::read_from_id(ap_id.clone(), context).await? {\n      return Ok(DeletableObjects::Post(p));\n    }\n    if let Some(c) = ApubComment::read_from_id(ap_id.clone(), context).await? {\n      return Ok(DeletableObjects::Comment(c));\n    }\n    if let Some(p) = ApubPrivateMessage::read_from_id(ap_id.clone(), context).await? {\n      return Ok(DeletableObjects::PrivateMessage(p));\n    }\n    Err(diesel::NotFound.into())\n  }\n\n  pub(crate) fn id(&self) -> &Url {\n    match self {\n      DeletableObjects::Community(c) => c.id(),\n      DeletableObjects::Person(p) => p.id(),\n      DeletableObjects::Comment(c) => c.ap_id.inner(),\n      DeletableObjects::Post(p) => p.ap_id.inner(),\n      DeletableObjects::PrivateMessage(p) => p.ap_id.inner(),\n    }\n  }\n}\n\npub(crate) async fn verify_delete_activity(\n  activity: &Delete,\n  is_mod_action: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let object = DeletableObjects::read_from_db(activity.object.id(), context).await?;\n  match object {\n    DeletableObjects::Community(community) => {\n      verify_visibility(&activity.to, &[], &community)?;\n      if community.local {\n        // can only do this check for local community, in remote case it would try to fetch the\n        // deleted community (which fails)\n        verify_person_in_community(&activity.actor, &community, context).await?;\n      }\n      // community deletion is always a mod (or admin) action\n      verify_mod_action(&activity.actor, &community, context).await?;\n    }\n    DeletableObjects::Person(person) => {\n      verify_is_public(&activity.to, &[])?;\n      verify_person(&activity.actor, context).await?;\n      verify_urls_match(person.ap_id.inner(), activity.object.id())?;\n    }\n    DeletableObjects::Post(p) => {\n      let community = activity.community(context).await?;\n      verify_visibility(&activity.to, &[], &community)?;\n      verify_delete_post_or_comment(\n        &activity.actor,\n        &p.ap_id.clone().into(),\n        &community,\n        is_mod_action,\n        context,\n      )\n      .await?;\n    }\n    DeletableObjects::Comment(c) => {\n      let community = activity.community(context).await?;\n      verify_visibility(&activity.to, &[], &community)?;\n      verify_delete_post_or_comment(\n        &activity.actor,\n        &c.ap_id.clone().into(),\n        &community,\n        is_mod_action,\n        context,\n      )\n      .await?;\n    }\n    DeletableObjects::PrivateMessage(_) => {\n      verify_person(&activity.actor, context).await?;\n      verify_domains_match(activity.actor.inner(), activity.object.id())?;\n    }\n  }\n  Ok(())\n}\n\nasync fn verify_delete_post_or_comment(\n  actor: &ObjectId<ApubPerson>,\n  object_id: &Url,\n  community: &ApubCommunity,\n  is_mod_action: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  check_community_deleted_or_removed(community)?;\n  if is_mod_action {\n    verify_mod_action(actor, community, context).await?;\n  } else {\n    verify_person_in_community(actor, community, context).await?;\n    // domain of post ap_id and post.creator ap_id are identical, so we just check the former\n    verify_domains_match(actor.inner(), object_id)?;\n  }\n  Ok(())\n}\n\n/// Write deletion or restoring of an object to the database, and send websocket message.\nasync fn receive_delete_action(\n  object: &Url,\n  actor: &ObjectId<ApubPerson>,\n  deleted: bool,\n  do_purge_user_account: Option<bool>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  match DeletableObjects::read_from_db(object, context).await? {\n    DeletableObjects::Community(community) => {\n      if community.local {\n        let mod_: Person = actor.dereference(context).await?.deref().clone();\n        let object = DeletableObjects::Community(community.clone());\n        let c: Community = community.deref().clone();\n        send_apub_delete_in_community(mod_, c, object, None, true, None, context).await?;\n      }\n\n      Community::update(\n        &mut context.pool(),\n        community.id,\n        &CommunityUpdateForm {\n          deleted: Some(deleted),\n          ..Default::default()\n        },\n      )\n      .await?;\n    }\n    DeletableObjects::Person(person) => {\n      let site_view = SiteView::read_local(&mut context.pool()).await?;\n      let local_instance_id = site_view.site.instance_id;\n\n      if do_purge_user_account.unwrap_or(false) {\n        purge_user_account(person.id, local_instance_id, context).await?;\n      } else {\n        Person::delete_account(&mut context.pool(), person.id, local_instance_id).await?;\n      }\n    }\n    DeletableObjects::Post(post) => {\n      if deleted != post.deleted {\n        Post::update(\n          &mut context.pool(),\n          post.id,\n          &PostUpdateForm {\n            deleted: Some(deleted),\n            ..Default::default()\n          },\n        )\n        .await?;\n      }\n    }\n    DeletableObjects::Comment(comment) => {\n      if deleted != comment.deleted {\n        Comment::update(\n          &mut context.pool(),\n          comment.id,\n          &CommentUpdateForm {\n            deleted: Some(deleted),\n            ..Default::default()\n          },\n        )\n        .await?;\n      }\n    }\n    DeletableObjects::PrivateMessage(pm) => {\n      DbPrivateMessage::update(\n        &mut context.pool(),\n        pm.id,\n        &PrivateMessageUpdateForm {\n          deleted: Some(deleted),\n          ..Default::default()\n        },\n      )\n      .await?;\n    }\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/deletion/undo_delete.rs",
    "content": "use crate::{\n  deletion::{DeletableObjects, receive_delete_action, verify_delete_activity},\n  generate_activity_id,\n  protocol::deletion::{delete::Delete, undo_delete::UndoDelete},\n};\nuse activitypub_federation::{config::Data, kinds::activity::UndoType, traits::Activity};\nuse lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action};\nuse lemmy_apub_objects::objects::person::ApubPerson;\nuse lemmy_db_schema::source::{\n  comment::{Comment, CommentUpdateForm},\n  community::{Community, CommunityUpdateForm},\n  modlog::{Modlog, ModlogInsertForm},\n  post::{Post, PostUpdateForm},\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse url::Url;\n\n#[async_trait::async_trait]\nimpl Activity for UndoDelete {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {\n    self.object.verify(data).await?;\n    verify_delete_activity(&self.object, self.object.summary.is_some(), data).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    if let Some(reason) = self.object.summary {\n      UndoDelete::receive_undo_remove_action(\n        &self.actor.dereference(context).await?,\n        self.object.object.id(),\n        reason,\n        self.object.with_replies,\n        context,\n      )\n      .await\n    } else {\n      receive_delete_action(self.object.object.id(), &self.actor, false, None, context).await\n    }\n  }\n}\n\nimpl UndoDelete {\n  pub(in crate::deletion) fn new(\n    actor: &ApubPerson,\n    object: DeletableObjects,\n    to: Vec<Url>,\n    community: Option<&Community>,\n    summary: Option<String>,\n    with_replies: Option<bool>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<UndoDelete> {\n    let object = Delete::new(\n      actor,\n      object,\n      to.clone(),\n      community,\n      summary,\n      with_replies,\n      context,\n    )?;\n\n    let id = generate_activity_id(UndoType::Undo, context)?;\n    let cc: Option<Url> = community.map(|c| c.ap_id.clone().into());\n    Ok(UndoDelete {\n      actor: actor.ap_id.clone().into(),\n      to,\n      object,\n      cc: cc.into_iter().collect(),\n      kind: UndoType::Undo,\n      id,\n      audience: community.map(|c| c.ap_id.clone().into()),\n    })\n  }\n\n  pub(crate) async fn receive_undo_remove_action(\n    actor: &ApubPerson,\n    object: &Url,\n    reason: String,\n    with_replies: Option<bool>,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    match DeletableObjects::read_from_db(object, context).await? {\n      DeletableObjects::Community(community) => {\n        if community.local {\n          return Err(UntranslatedError::OnlyLocalAdminCanRestoreCommunity.into());\n        }\n        let community_owner =\n          CommunityModeratorView::top_mod_for_community(&mut context.pool(), community.id).await?;\n        let form = ModlogInsertForm::admin_remove_community(\n          actor,\n          community.id,\n          community_owner,\n          false,\n          &reason,\n        );\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action.clone(), context.app_data());\n\n        Community::update(\n          &mut context.pool(),\n          community.id,\n          &CommunityUpdateForm {\n            removed: Some(false),\n            ..Default::default()\n          },\n        )\n        .await?;\n      }\n      DeletableObjects::Post(post) => {\n        let form = ModlogInsertForm::mod_remove_post(actor.id, &post, false, &reason, None);\n        let action = Modlog::create(&mut context.pool(), &[form]).await?;\n        notify_mod_action(action, context.app_data());\n        Post::update(\n          &mut context.pool(),\n          post.id,\n          &PostUpdateForm {\n            removed: Some(false),\n            ..Default::default()\n          },\n        )\n        .await?;\n\n        let restore_children = with_replies.unwrap_or_default();\n        if restore_children {\n          let updated_comments: Vec<Comment> =\n            Comment::update_removed_for_post(&mut context.pool(), post.id, false).await?;\n\n          let forms: Vec<_> = updated_comments\n            .iter()\n            // Filter out deleted comments here so their content doesn't show up in the modlog.\n            .filter(|c| !c.deleted)\n            .map(|comment| {\n              ModlogInsertForm::mod_remove_comment(\n                actor.id,\n                comment,\n                post.community_id,\n                false,\n                &reason,\n                None,\n              )\n            })\n            .collect();\n\n          let actions = Modlog::create(&mut context.pool(), &forms).await?;\n          notify_mod_action(actions, context);\n        }\n      }\n      DeletableObjects::Comment(comment) => {\n        let restore_children = with_replies.unwrap_or_default();\n        if restore_children {\n          let updated_comments: Vec<Comment> = Comment::update_removed_for_comment_and_children(\n            &mut context.pool(),\n            &comment.path,\n            false,\n          )\n          .await?;\n\n          let mut forms: Vec<ModlogInsertForm> = Vec::new();\n          // Filter out deleted comments here so their content doesn't show up in the modlog.\n          // Unfortunate, but you need to loop over these to get the community_id for the modlog.\n          for comment in updated_comments.iter().filter(|c| !c.deleted) {\n            let community_id = Post::read(&mut context.pool(), comment.post_id)\n              .await?\n              .community_id;\n            let form = ModlogInsertForm::mod_remove_comment(\n              actor.id,\n              comment,\n              community_id,\n              false,\n              &reason,\n              None,\n            );\n            forms.push(form);\n          }\n          let actions = Modlog::create(&mut context.pool(), &forms).await?;\n          notify_mod_action(actions, context);\n        } else {\n          let community_id = Post::read(&mut context.pool(), comment.post_id)\n            .await?\n            .community_id;\n          let form = ModlogInsertForm::mod_remove_comment(\n            actor.id,\n            &comment,\n            community_id,\n            false,\n            &reason,\n            None,\n          );\n          let action = Modlog::create(&mut context.pool(), &[form]).await?;\n          notify_mod_action(action, context.app_data());\n          Comment::update(\n            &mut context.pool(),\n            comment.id,\n            &CommentUpdateForm {\n              removed: Some(false),\n              ..Default::default()\n            },\n          )\n          .await?;\n        }\n      }\n      // TODO these need to be implemented yet, for now, return errors\n      DeletableObjects::PrivateMessage(_) => return Err(LemmyErrorType::NotFound.into()),\n      DeletableObjects::Person(_) => return Err(LemmyErrorType::NotFound.into()),\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/following/accept.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::following::{accept::AcceptFollow, follow::Follow},\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::AcceptType,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  source::{activity::ActivitySendTargets, community::CommunityActions},\n  traits::Followable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError};\nuse url::Url;\n\nimpl AcceptFollow {\n  pub async fn send(follow: Follow, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let target = follow.object.dereference_local(context).await?;\n    let person = follow.actor.clone().dereference(context).await?;\n    let accept = AcceptFollow {\n      actor: target.id().clone().into(),\n      to: Some([person.id().clone().into()]),\n      object: follow,\n      kind: AcceptType::Accept,\n      id: generate_activity_id(AcceptType::Accept, context)?,\n    };\n    let inbox = ActivitySendTargets::to_inbox(person.shared_inbox_or_inbox());\n    send_lemmy_activity(context, accept, &target, inbox, true).await\n  }\n}\n\n/// Handle accepted follows\n#[async_trait::async_trait]\nimpl Activity for AcceptFollow {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    verify_urls_match(self.actor.inner(), self.object.object.inner())?;\n    self.object.verify(context).await?;\n    if let Some(to) = &self.to {\n      verify_urls_match(to[0].inner(), self.object.actor.inner())?;\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.actor.dereference(context).await?;\n    check_community_deleted_or_removed(&community)?;\n    let actor = self.object.actor.dereference(context).await?;\n    let person = actor.left().ok_or(UntranslatedError::Unreachable)?;\n    // This will throw an error if no follow was requested\n    let community_id = community.id;\n    let person_id = person.id;\n    CommunityActions::follow_accepted(&mut context.pool(), community_id, person_id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/following/follow.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::following::{accept::AcceptFollow, follow::Follow},\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::FollowType,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Actor, Object},\n};\nuse either::Either::*;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::{CommunityOrMulti, person::ApubPerson};\nuse lemmy_db_schema::{\n  source::{\n    activity::ActivitySendTargets,\n    community::{CommunityActions, CommunityFollowerForm},\n    community_community_follow::CommunityCommunityFollow,\n    instance::{Instance, InstanceActions},\n    multi_community::{MultiCommunity, MultiCommunityFollowForm},\n    person::{PersonActions, PersonFollowerForm},\n  },\n  traits::Followable,\n};\nuse lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};\nuse lemmy_db_views_community_moderator::CommunityPersonBanView;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse url::Url;\n\nimpl Follow {\n  pub(in crate::following) fn new(\n    actor: &ApubPerson,\n    target: &CommunityOrMulti,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Follow> {\n    Ok(Follow {\n      actor: actor.id().clone().into(),\n      object: target.id().clone().into(),\n      to: Some([target.id().clone().into()]),\n      kind: FollowType::Follow,\n      id: generate_activity_id(FollowType::Follow, context)?,\n    })\n  }\n\n  pub async fn send(\n    actor: &ApubPerson,\n    target: &CommunityOrMulti,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let follow = Follow::new(actor, target, context)?;\n    let inbox = ActivitySendTargets::to_inbox(target.shared_inbox_or_inbox());\n    send_lemmy_activity(context, follow, actor, inbox, true).await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for Follow {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, _context: &Data<LemmyContext>) -> LemmyResult<()> {\n    if let Some(to) = &self.to {\n      verify_urls_match(to[0].inner(), self.object.inner())?;\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    use CommunityVisibility::*;\n    let actor = self.actor.dereference(context).await?;\n    let object = self.object.dereference(context).await?;\n\n    let object_local = match &object {\n      Left(u) => u.local,\n      Right(Left(c)) => c.local,\n      Right(Right(m)) => m.local,\n    };\n    if !object_local {\n      return Err(UntranslatedError::InvalidFollow(\"Not a local object\".to_string()).into());\n    }\n\n    // Handle remote community following a local community\n    if let (Right(community), Right(Left(follower))) = (&actor, &object)\n      && (community.visibility == Public || community.visibility == Unlisted)\n    {\n      check_community_deleted_or_removed(community)?;\n      CommunityCommunityFollow::follow(&mut context.pool(), community.id, follower.id).await?;\n      AcceptFollow::send(self, context).await?;\n      return Ok(());\n    }\n\n    let person = actor.left().ok_or(UntranslatedError::InvalidFollow(\n      \"Groups can only follow public groups\".to_string(),\n    ))?;\n    InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n\n    match object {\n      Left(u) => {\n        let form = PersonFollowerForm::new(u.id, person.id, false);\n        PersonActions::follow(&mut context.pool(), &form).await?;\n        AcceptFollow::send(self, context).await?;\n      }\n      Right(Left(c)) => {\n        check_community_deleted_or_removed(&c)?;\n        CommunityPersonBanView::check(&mut context.pool(), person.id, c.id).await?;\n        if c.visibility == CommunityVisibility::Private {\n          let instance = Instance::read(&mut context.pool(), person.instance_id).await?;\n          if [Some(\"kbin\"), Some(\"mbin\")].contains(&instance.software.as_deref()) {\n            // TODO: change this to a minimum version check once private communities are supported\n            return Err(\n              UntranslatedError::InvalidFollow(\"No private community support\".to_string()).into(),\n            );\n          }\n        }\n        let follow_state = match c.visibility {\n          Public | Unlisted => CommunityFollowerState::Accepted,\n          Private => CommunityFollowerState::ApprovalRequired,\n          // Dont allow following local-only community via federation.\n          LocalOnlyPrivate | LocalOnlyPublic => return Err(LemmyErrorType::NotFound.into()),\n        };\n        let form = CommunityFollowerForm::new(c.id, person.id, follow_state);\n        CommunityActions::follow(&mut context.pool(), &form).await?;\n        if c.visibility == CommunityVisibility::Public {\n          AcceptFollow::send(self, context).await?;\n        }\n      }\n      Right(Right(m)) => {\n        let form = MultiCommunityFollowForm {\n          multi_community_id: m.id,\n          person_id: person.id,\n          follow_state: CommunityFollowerState::Accepted,\n        };\n\n        MultiCommunity::follow(&mut context.pool(), &form).await?;\n        AcceptFollow::send(self, context).await?;\n      }\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/following/mod.rs",
    "content": "use super::{generate_activity_id, send_lemmy_activity};\nuse crate::protocol::following::{\n  accept::AcceptFollow,\n  follow::Follow,\n  reject::RejectFollow,\n  undo_follow::UndoFollow,\n};\nuse activitypub_federation::{config::Data, kinds::activity::FollowType, traits::Activity};\nuse either::Either::*;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::{CommunityOrMulti, UserOrCommunityOrMulti, person::ApubPerson};\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{activity::ActivitySendTargets, community::Community, person::Person},\n};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse serde::Serialize;\n\npub(crate) mod accept;\npub(crate) mod follow;\npub(crate) mod reject;\npub(crate) mod undo_follow;\n\npub async fn send_follow(\n  target: CommunityOrMulti,\n  person: Person,\n  follow: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let actor: ApubPerson = person.into();\n  if follow {\n    Follow::send(&actor, &target, context).await\n  } else {\n    UndoFollow::send(&actor, &target, context).await\n  }\n}\n\npub async fn send_accept_or_reject_follow(\n  community_id: CommunityId,\n  person_id: PersonId,\n  accepted: bool,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let community = Community::read(&mut context.pool(), community_id).await?;\n  let person = Person::read(&mut context.pool(), person_id).await?;\n\n  let follow = Follow {\n    actor: person.ap_id.into(),\n    to: Some([community.ap_id.clone().into()]),\n    object: community.ap_id.into(),\n    kind: FollowType::Follow,\n    id: generate_activity_id(FollowType::Follow, context)?,\n  };\n  if accepted {\n    AcceptFollow::send(follow, context).await\n  } else {\n    RejectFollow::send(follow, context).await\n  }\n}\n\n/// Wrapper type which is needed because we cant implement ActorT for Either.\nasync fn send_activity_from_user_or_community_or_multi<A>(\n  context: &Data<LemmyContext>,\n  activity: A,\n  target: UserOrCommunityOrMulti,\n  send_targets: ActivitySendTargets,\n) -> LemmyResult<()>\nwhere\n  A: Activity + Serialize + Send + Sync + Clone + Activity<Error = LemmyError>,\n{\n  match target {\n    Left(user) => send_lemmy_activity(context, activity, &user, send_targets, true).await,\n    Right(Left(community)) => {\n      send_lemmy_activity(context, activity, &community, send_targets, true).await\n    }\n    Right(Right(multi)) => send_lemmy_activity(context, activity, &multi, send_targets, true).await,\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/following/reject.rs",
    "content": "use super::send_activity_from_user_or_community_or_multi;\nuse crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::following::{follow::Follow, reject::RejectFollow},\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::RejectType,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Actor, Object},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  source::{activity::ActivitySendTargets, community::CommunityActions},\n  traits::Followable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError};\nuse url::Url;\n\nimpl RejectFollow {\n  pub async fn send(follow: Follow, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let user_or_community = follow.object.dereference_local(context).await?;\n    let person = follow.actor.clone().dereference(context).await?;\n    let reject = RejectFollow {\n      actor: user_or_community.id().clone().into(),\n      to: Some([person.id().clone().into()]),\n      object: follow,\n      kind: RejectType::Reject,\n      id: generate_activity_id(RejectType::Reject, context)?,\n    };\n    let inbox = ActivitySendTargets::to_inbox(person.shared_inbox_or_inbox());\n    send_activity_from_user_or_community_or_multi(context, reject, user_or_community, inbox).await\n  }\n}\n\n/// Handle rejected follows\n#[async_trait::async_trait]\nimpl Activity for RejectFollow {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    verify_urls_match(self.actor.inner(), self.object.object.inner())?;\n    self.object.verify(context).await?;\n    if let Some(to) = &self.to {\n      verify_urls_match(to[0].inner(), self.object.actor.inner())?;\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.actor.dereference(context).await?;\n    check_community_deleted_or_removed(&community)?;\n    let actor = self.object.actor.dereference(context).await?;\n    let person = actor.left().ok_or(UntranslatedError::Unreachable)?;\n\n    // remove the follow\n    CommunityActions::unfollow(&mut context.pool(), person.id, community.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/following/undo_follow.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::following::{follow::Follow, undo_follow::UndoFollow},\n  send_lemmy_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::UndoType,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Actor, Object},\n};\nuse either::Either::*;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::{CommunityOrMulti, person::ApubPerson};\nuse lemmy_db_schema::{\n  source::{\n    activity::ActivitySendTargets,\n    community::CommunityActions,\n    community_community_follow::CommunityCommunityFollow,\n    instance::InstanceActions,\n    multi_community::MultiCommunity,\n    person::PersonActions,\n  },\n  traits::Followable,\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError};\nuse url::Url;\n\nimpl UndoFollow {\n  pub async fn send(\n    actor: &ApubPerson,\n    target: &CommunityOrMulti,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    let object = Follow::new(actor, target, context)?;\n    let undo = UndoFollow {\n      actor: actor.id().clone().into(),\n      to: Some([target.id().clone().into()]),\n      object,\n      kind: UndoType::Undo,\n      id: generate_activity_id(UndoType::Undo, context)?,\n    };\n    let inbox = ActivitySendTargets::to_inbox(target.shared_inbox_or_inbox());\n    send_lemmy_activity(context, undo, actor, inbox, true).await\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for UndoFollow {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    verify_urls_match(self.actor.inner(), self.object.actor.inner())?;\n    self.object.verify(context).await?;\n    if let Some(to) = &self.to {\n      verify_urls_match(to[0].inner(), self.object.object.inner())?;\n    }\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let actor = self.actor.dereference(context).await?;\n    let object = self.object.object.dereference(context).await?;\n\n    // Handle remote community unfollowing a local community\n    if let (Right(community), Right(Left(follower))) = (&actor, &object) {\n      check_community_deleted_or_removed(community)?;\n      CommunityCommunityFollow::unfollow(&mut context.pool(), community.id, follower.id).await?;\n      return Ok(());\n    }\n\n    let person = actor.left().ok_or(UntranslatedError::InvalidFollow(\n      \"Groups can only follow public groups\".to_string(),\n    ))?;\n    InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n\n    match object {\n      Left(u) => {\n        PersonActions::unfollow(&mut context.pool(), person.id, u.id).await?;\n      }\n      Right(Left(c)) => {\n        CommunityActions::unfollow(&mut context.pool(), person.id, c.id).await?;\n      }\n      Right(Right(m)) => MultiCommunity::unfollow(&mut context.pool(), person.id, m.id).await?,\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/lib.rs",
    "content": "use crate::{\n  block::{send_ban_from_community, send_ban_from_site},\n  community::{\n    collection_add::{send_add_mod_to_community, send_feature_post},\n    lock::send_lock,\n    update::{send_update_community, send_update_multi_community},\n  },\n  create_or_update::private_message::send_create_or_update_pm,\n  deletion::{\n    DeletableObjects,\n    send_apub_delete_in_community,\n    send_apub_delete_private_message,\n    send_apub_delete_user,\n  },\n  following::send_follow,\n  protocol::{\n    CreateOrUpdateType,\n    community::{report::Report, resolve_report::ResolveReport},\n    create_or_update::{note::CreateOrUpdateNote, page::CreateOrUpdatePage},\n  },\n  voting::send_like_activity,\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::AnnounceType,\n  traits::{Activity, Actor},\n};\nuse either::Either;\nuse following::send_accept_or_reject_follow;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n};\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, person::ApubPerson},\n  utils::functions::GetActorType,\n};\nuse lemmy_db_schema::source::{\n  activity::{ActivitySendTargets, SentActivity, SentActivityForm},\n  community::Community,\n  instance::InstanceActions,\n};\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError};\nuse serde::Serialize;\nuse tracing::info;\nuse url::{ParseError, Url};\nuse uuid::Uuid;\n\npub mod activity_lists;\npub mod block;\npub mod community;\npub mod create_or_update;\npub mod deletion;\npub mod following;\npub mod protocol;\npub mod voting;\n\nconst MOD_ACTION_DEFAULT_REASON: &str = \"No reason provided\";\n\n/// Checks that the specified Url actually identifies a Person (by fetching it), and that the person\n/// doesn't have a site ban.\nasync fn verify_person(\n  person_id: &ObjectId<ApubPerson>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let person = person_id.dereference(context).await?;\n  InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n  Ok(())\n}\n\npub(crate) fn check_community_deleted_or_removed(community: &Community) -> LemmyResult<()> {\n  if community.deleted || community.removed {\n    Err(UntranslatedError::CannotCreatePostOrCommentInDeletedOrRemovedCommunity.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Generate a unique ID for an activity, in the format:\n/// `http(s)://example.com/receive/create/202daf0a-1489-45df-8d2e-c8a3173fed36`\nfn generate_activity_id<T>(kind: T, context: &LemmyContext) -> Result<Url, ParseError>\nwhere\n  T: ToString,\n{\n  let id = format!(\n    \"{}/activities/{}/{}\",\n    &context.settings().get_protocol_and_hostname(),\n    kind.to_string().to_lowercase(),\n    Uuid::new_v4()\n  );\n  Url::parse(&id)\n}\n\n/// like generate_activity_id but also add the inner kind for easier debugging\nfn generate_announce_activity_id(\n  inner_kind: &str,\n  protocol_and_hostname: &str,\n) -> Result<Url, ParseError> {\n  let id = format!(\n    \"{}/activities/{}/{}/{}\",\n    protocol_and_hostname,\n    AnnounceType::Announce.to_string().to_lowercase(),\n    inner_kind.to_lowercase(),\n    Uuid::new_v4()\n  );\n  Url::parse(&id)\n}\n\nasync fn send_lemmy_activity<A, ActorT>(\n  data: &Data<LemmyContext>,\n  activity: A,\n  actor: &ActorT,\n  send_targets: ActivitySendTargets,\n  sensitive: bool,\n) -> LemmyResult<()>\nwhere\n  A: Activity + Serialize + Send + Sync + Clone + Activity<Error = LemmyError>,\n  ActorT: Actor + GetActorType,\n{\n  info!(\"Saving outgoing activity to queue {}\", activity.id());\n\n  let form = SentActivityForm {\n    ap_id: activity.id().clone().into(),\n    data: serde_json::to_value(activity)?,\n    sensitive,\n    send_inboxes: send_targets\n      .inboxes\n      .into_iter()\n      .map(|e| Some(e.into()))\n      .collect(),\n    send_all_instances: send_targets.all_instances,\n    send_community_followers_of: send_targets.community_followers_of.map(|e| e.0),\n    actor_type: actor.actor_type(),\n    actor_apub_id: actor.id().clone().into(),\n  };\n  SentActivity::create(&mut data.pool(), form).await?;\n\n  Ok(())\n}\n\npub async fn handle_outgoing_activities(context: Data<LemmyContext>) {\n  while let Some(data) = ActivityChannel::retrieve_activity().await {\n    if let Err(e) = match_outgoing_activities(data, &context).await {\n      tracing::warn!(\"error while saving outgoing activity to db: {e}\");\n    }\n  }\n}\n\npub async fn match_outgoing_activities(\n  data: SendActivityData,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let context = context.clone();\n  Box::pin(async {\n    use SendActivityData::*;\n    match data {\n      CreatePost(post) => {\n        let creator_id = post.creator_id;\n        CreateOrUpdatePage::send(post, creator_id, CreateOrUpdateType::Create, context).await\n      }\n      UpdatePost(post) => {\n        let creator_id = post.creator_id;\n        CreateOrUpdatePage::send(post, creator_id, CreateOrUpdateType::Update, context).await\n      }\n      DeletePost(post, person, community) => {\n        let is_deleted = post.deleted;\n        send_apub_delete_in_community(\n          person,\n          community,\n          DeletableObjects::Post(post.into()),\n          None,\n          is_deleted,\n          None,\n          &context,\n        )\n        .await\n      }\n      RemovePost {\n        post,\n        moderator,\n        reason,\n        removed,\n        with_replies,\n      } => {\n        let community = Community::read(&mut context.pool(), post.community_id).await?;\n        send_apub_delete_in_community(\n          moderator,\n          community,\n          DeletableObjects::Post(post.into()),\n          Some(reason),\n          removed,\n          Some(with_replies),\n          &context,\n        )\n        .await\n      }\n      LockPost(post, actor, locked, reason) => {\n        send_lock(\n          PostOrComment::Left(post.into()),\n          actor,\n          locked,\n          reason,\n          context,\n        )\n        .await\n      }\n      FeaturePost(post, actor, featured) => send_feature_post(post, actor, featured, context).await,\n      CreateComment(comment) => {\n        let creator_id = comment.creator_id;\n        CreateOrUpdateNote::send(comment, creator_id, CreateOrUpdateType::Create, context).await\n      }\n      UpdateComment(comment) => {\n        let creator_id = comment.creator_id;\n        CreateOrUpdateNote::send(comment, creator_id, CreateOrUpdateType::Update, context).await\n      }\n      DeleteComment(comment, actor, community) => {\n        let is_deleted = comment.deleted;\n        let deletable = DeletableObjects::Comment(comment.into());\n        send_apub_delete_in_community(\n          actor, community, deletable, None, is_deleted, None, &context,\n        )\n        .await\n      }\n      RemoveComment {\n        comment,\n        moderator,\n        community,\n        reason,\n        with_replies,\n      } => {\n        let is_removed = comment.removed;\n        let deletable = DeletableObjects::Comment(comment.into());\n        send_apub_delete_in_community(\n          moderator,\n          community,\n          deletable,\n          Some(reason),\n          is_removed,\n          Some(with_replies),\n          &context,\n        )\n        .await\n      }\n      LockComment(comment, actor, locked, reason) => {\n        send_lock(\n          PostOrComment::Right(comment.into()),\n          actor,\n          locked,\n          reason,\n          context,\n        )\n        .await\n      }\n      LikePostOrComment {\n        object_id,\n        actor,\n        community,\n        previous_is_upvote,\n        new_is_upvote,\n      } => {\n        send_like_activity(\n          object_id,\n          actor,\n          community,\n          previous_is_upvote,\n          new_is_upvote,\n          context,\n        )\n        .await\n      }\n      FollowCommunity(community, person, follow) => {\n        send_follow(Either::Left(community.into()), person, follow, &context).await\n      }\n      FollowMultiCommunity(multi, person, follow) => {\n        send_follow(Either::Right(multi.into()), person, follow, &context).await\n      }\n      UpdateCommunity(actor, community) => send_update_community(community, actor, context).await,\n      DeleteCommunity(actor, community, removed) => {\n        let deletable = DeletableObjects::Community(community.clone().into());\n        send_apub_delete_in_community(actor, community, deletable, None, removed, None, &context)\n          .await\n      }\n      RemoveCommunity {\n        moderator,\n        community,\n        reason,\n        removed,\n      } => {\n        let deletable = DeletableObjects::Community(community.clone().into());\n        send_apub_delete_in_community(\n          moderator,\n          community,\n          deletable,\n          Some(reason),\n          removed,\n          None,\n          &context,\n        )\n        .await\n      }\n      AddModToCommunity {\n        moderator,\n        community_id,\n        target,\n        added,\n      } => send_add_mod_to_community(moderator, community_id, target, added, context).await,\n      BanFromCommunity {\n        moderator,\n        community_id,\n        target,\n        data,\n      } => send_ban_from_community(moderator, community_id, target, data, context).await,\n      BanFromSite {\n        moderator,\n        banned_user,\n        reason,\n        remove_or_restore_data,\n        ban,\n        expires_at,\n      } => {\n        send_ban_from_site(\n          moderator,\n          banned_user,\n          reason,\n          remove_or_restore_data,\n          ban,\n          expires_at,\n          context,\n        )\n        .await\n      }\n      CreatePrivateMessage(pm) => {\n        send_create_or_update_pm(pm, CreateOrUpdateType::Create, context).await\n      }\n      UpdatePrivateMessage(pm) => {\n        send_create_or_update_pm(pm, CreateOrUpdateType::Update, context).await\n      }\n      DeletePrivateMessage(person, pm, deleted) => {\n        send_apub_delete_private_message(&person.into(), pm, deleted, context).await\n      }\n      DeleteUser(person, remove_data) => send_apub_delete_user(person, remove_data, context).await,\n      CreateReport {\n        object_id,\n        actor,\n        receiver,\n        reason,\n      } => {\n        Report::send(\n          ObjectId::from(object_id),\n          &actor.into(),\n          &receiver.map_either(Into::into, Into::into),\n          reason,\n          context,\n        )\n        .await\n      }\n      SendResolveReport {\n        object_id,\n        actor,\n        report_creator,\n        receiver,\n      } => {\n        ResolveReport::send(\n          ObjectId::from(object_id),\n          &actor.into(),\n          &report_creator.into(),\n          &receiver.map_either(Into::into, Into::into),\n          context,\n        )\n        .await\n      }\n      AcceptFollower(community_id, person_id) => {\n        send_accept_or_reject_follow(community_id, person_id, true, &context).await\n      }\n      RejectFollower(community_id, person_id) => {\n        send_accept_or_reject_follow(community_id, person_id, false, &context).await\n      }\n      UpdateMultiCommunity(multi, actor) => {\n        send_update_multi_community(multi, actor, context).await\n      }\n    }\n  })\n  .await?;\n  Ok(())\n}\n\npub(crate) async fn post_or_comment_community(\n  post_or_comment: &PostOrComment,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<Community> {\n  match post_or_comment {\n    PostOrComment::Left(p) => Community::read(&mut context.pool(), p.community_id).await,\n    PostOrComment::Right(c) => {\n      let site_view = SiteView::read_local(&mut context.pool()).await?;\n      Ok(\n        PostView::read(\n          &mut context.pool(),\n          c.post_id,\n          None,\n          site_view.instance.id,\n          false,\n        )\n        .await?\n        .community,\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/block/block_user.rs",
    "content": "use crate::block::SiteOrCommunity;\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::BlockType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse anyhow::anyhow;\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct BlockUser {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  pub(crate) target: ObjectId<SiteOrCommunity>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: BlockType,\n  pub(crate) id: Url,\n\n  /// Quick and dirty solution.\n  /// TODO: send a separate Delete activity instead\n  pub(crate) remove_data: Option<bool>,\n  /// block reason, written to mod log\n  pub(crate) summary: Option<String>,\n  pub(crate) end_time: Option<DateTime<Utc>>,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for BlockUser {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let target = self.target.dereference(context).await?;\n    let community = match target {\n      SiteOrCommunity::Right(c) => c,\n      SiteOrCommunity::Left(_) => return Err(anyhow!(\"activity is not in community\").into()),\n    };\n    Ok(community)\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/block/mod.rs",
    "content": "pub mod block_user;\npub mod undo_block_user;\n\n#[cfg(test)]\nmod tests {\n  use crate::protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser};\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_block() -> LemmyResult<()> {\n    test_parse_lemmy_item::<BlockUser>(\"../apub/assets/lemmy/activities/block/block_user.json\")?;\n    test_parse_lemmy_item::<UndoBlockUser>(\n      \"../apub/assets/lemmy/activities/block/undo_block_user.json\",\n    )?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/block/undo_block_user.rs",
    "content": "use super::block_user::BlockUser;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::UndoType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UndoBlockUser {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: BlockUser,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UndoType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n\n  /// Quick and dirty solution.\n  /// TODO: send a separate Delete activity instead\n  pub(crate) restore_data: Option<bool>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/announce.rs",
    "content": "use crate::protocol::IdOrNestedObject;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::AnnounceType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_apub_objects::objects::community::ApubCommunity;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AnnounceActivity {\n  pub(crate) actor: ObjectId<ApubCommunity>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub object: IdOrNestedObject<RawAnnouncableActivities>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: AnnounceType,\n  pub(crate) id: Url,\n}\n\n/// Use this to receive community inbox activities, and then announce them if valid. This\n/// ensures that all json fields are kept, even if Lemmy doesn't understand them.\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct RawAnnouncableActivities {\n  pub(crate) id: Url,\n  pub(crate) actor: Url,\n  #[serde(flatten)]\n  pub(crate) other: Map<String, Value>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/collection_add.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::AddType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_db_schema::source::community::Community;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CollectionAdd {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: Url,\n  pub(crate) target: Url,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: AddType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for CollectionAdd {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let (community, _) =\n      Community::get_by_collection_url(&mut context.pool(), &self.clone().target.into()).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/collection_remove.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::RemoveType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_db_schema::source::community::Community;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CollectionRemove {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: Url,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: RemoveType,\n  pub(crate) target: Url,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for CollectionRemove {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let (community, _) =\n      Community::get_by_collection_url(&mut context.pool(), &self.clone().target.into()).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/lock.rs",
    "content": "use crate::post_or_comment_community;\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::UndoType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse strum::Display;\nuse url::Url;\n\n#[derive(Clone, Debug, Display, Deserialize, Serialize)]\npub enum LockType {\n  Lock,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct LockPageOrNote {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: ObjectId<PostOrComment>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: LockType,\n  pub(crate) id: Url,\n  /// Summary is the reason for the lock.\n  pub(crate) summary: Option<String>,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UndoLockPageOrNote {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: LockPageOrNote,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UndoType,\n  pub(crate) id: Url,\n  /// Summary is the reason for the lock.\n  pub(crate) summary: Option<String>,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for LockPageOrNote {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let post_or_comment = self.object.dereference(context).await?;\n    let community = post_or_comment_community(&post_or_comment, context).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/mod.rs",
    "content": "pub mod announce;\npub mod collection_add;\npub mod collection_remove;\npub mod lock;\npub mod report;\npub mod resolve_report;\npub mod update;\n\n#[cfg(test)]\nmod tests {\n  use super::resolve_report::ResolveReport;\n  use crate::protocol::community::{\n    announce::AnnounceActivity,\n    collection_add::CollectionAdd,\n    collection_remove::CollectionRemove,\n    lock::{LockPageOrNote, UndoLockPageOrNote},\n    report::Report,\n    update::Update,\n  };\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_community_activities() -> LemmyResult<()> {\n    test_parse_lemmy_item::<AnnounceActivity>(\n      \"../apub/assets/lemmy/activities/community/announce_create_page.json\",\n    )?;\n\n    test_parse_lemmy_item::<CollectionAdd>(\n      \"../apub/assets/lemmy/activities/community/add_mod.json\",\n    )?;\n    test_parse_lemmy_item::<CollectionRemove>(\n      \"../apub/assets/lemmy/activities/community/remove_mod.json\",\n    )?;\n\n    test_parse_lemmy_item::<CollectionAdd>(\n      \"../apub/assets/lemmy/activities/community/add_featured_post.json\",\n    )?;\n    test_parse_lemmy_item::<CollectionRemove>(\n      \"../apub/assets/lemmy/activities/community/remove_featured_post.json\",\n    )?;\n\n    test_parse_lemmy_item::<LockPageOrNote>(\n      \"../apub/assets/lemmy/activities/community/lock_page.json\",\n    )?;\n    test_parse_lemmy_item::<UndoLockPageOrNote>(\n      \"../apub/assets/lemmy/activities/community/undo_lock_page.json\",\n    )?;\n\n    test_parse_lemmy_item::<LockPageOrNote>(\n      \"../apub/assets/lemmy/activities/community/lock_note.json\",\n    )?;\n    test_parse_lemmy_item::<UndoLockPageOrNote>(\n      \"../apub/assets/lemmy/activities/community/undo_lock_note.json\",\n    )?;\n\n    test_parse_lemmy_item::<Update>(\n      \"../apub/assets/lemmy/activities/community/update_community.json\",\n    )?;\n\n    test_parse_lemmy_item::<Report>(\"../apub/assets/lemmy/activities/community/report_page.json\")?;\n    test_parse_lemmy_item::<ResolveReport>(\n      \"../apub/assets/lemmy/activities/community/resolve_report_page.json\",\n    )?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/report.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::FlagType,\n  protocol::helpers::deserialize_one,\n};\nuse either::Either;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{ReportableObjects, community::ApubCommunity, instance::ApubSite, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Report {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one\")]\n  pub(crate) to: [ObjectId<Either<ApubSite, ApubCommunity>>; 1],\n  pub(crate) object: ReportObject,\n  /// Report reason as sent by Lemmy\n  pub(crate) summary: Option<String>,\n  /// Report reason as sent by Mastodon\n  pub(crate) content: Option<String>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: FlagType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl Report {\n  pub fn reason(&self) -> LemmyResult<String> {\n    self\n      .summary\n      .clone()\n      .or(self.content.clone())\n      .ok_or(LemmyErrorType::NotFound.into())\n  }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\npub(crate) enum ReportObject {\n  Lemmy(ObjectId<ReportableObjects>),\n  /// Mastodon sends an array containing user id and one or more post ids\n  Mastodon(Vec<Url>),\n}\n\nimpl ReportObject {\n  pub(crate) async fn dereference(\n    &self,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<ReportableObjects> {\n    match self {\n      ReportObject::Lemmy(l) => l.dereference(context).await,\n      ReportObject::Mastodon(objects) => {\n        for o in objects {\n          // Find the first reported item which can be dereferenced as post or comment (Lemmy can\n          // only handle one item per report).\n          let deref = ObjectId::from(o.clone()).dereference(context).await;\n          if deref.is_ok() {\n            return deref;\n          }\n        }\n        Err(LemmyErrorType::NotFound.into())\n      }\n    }\n  }\n\n  pub(crate) async fn object_id(\n    &self,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<ObjectId<ReportableObjects>> {\n    match self {\n      ReportObject::Lemmy(l) => Ok(l.clone()),\n      ReportObject::Mastodon(objects) => {\n        for o in objects {\n          // Same logic as above, but return the ID and not the object itself.\n          let deref = ObjectId::<ReportableObjects>::from(o.clone())\n            .dereference(context)\n            .await;\n          if deref.is_ok() {\n            return Ok(o.clone().into());\n          }\n        }\n        Err(LemmyErrorType::NotFound.into())\n      }\n    }\n  }\n}\n\nimpl InCommunity for Report {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    match self.to[0].dereference(context).await? {\n      Either::Left(_) => Err(LemmyErrorType::NotFound.into()),\n      Either::Right(c) => Ok(c),\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/resolve_report.rs",
    "content": "use super::report::Report;\nuse activitypub_federation::{fetch::object_id::ObjectId, protocol::helpers::deserialize_one};\nuse either::Either;\nuse lemmy_apub_objects::objects::{\n  community::ApubCommunity,\n  instance::ApubSite,\n  person::ApubPerson,\n};\nuse serde::{Deserialize, Serialize};\nuse strum::Display;\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Display)]\npub enum ResolveType {\n  Resolve,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ResolveReport {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one\")]\n  pub(crate) to: [ObjectId<Either<ApubSite, ApubCommunity>>; 1],\n  pub(crate) object: Report,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: ResolveType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/community/update.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::UpdateType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse either::Either;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  protocol::{group::Group, multi_community::Feed},\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n/// This activity is received from a remote community mod, and updates the description or other\n/// fields of a local community.\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Update {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  #[serde(with = \"either::serde_untagged\")]\n  pub(crate) object: Either<Group, Feed>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UpdateType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for Update {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    match &self.object {\n      Either::Left(c) => {\n        let community: ApubCommunity = c.id.clone().dereference(context).await?;\n        Ok(community)\n      }\n      Either::Right(_) => Err(LemmyErrorType::NotFound.into()),\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/create_or_update/mod.rs",
    "content": "pub mod note;\npub(crate) mod note_wrapper;\npub mod page;\npub mod private_message;\n\n#[cfg(test)]\nmod tests {\n  use super::note_wrapper::{CreateOrUpdateNoteWrapper, NoteWrapper};\n  use crate::protocol::create_or_update::{\n    note::CreateOrUpdateNote,\n    page::CreateOrUpdatePage,\n    private_message::CreateOrUpdatePrivateMessage,\n  };\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_create_or_update() -> LemmyResult<()> {\n    test_parse_lemmy_item::<CreateOrUpdatePage>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_page.json\",\n    )?;\n    test_parse_lemmy_item::<CreateOrUpdatePage>(\n      \"../apub/assets/lemmy/activities/create_or_update/update_page.json\",\n    )?;\n    test_parse_lemmy_item::<CreateOrUpdateNote>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_comment.json\",\n    )?;\n    test_parse_lemmy_item::<CreateOrUpdatePrivateMessage>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_private_message.json\",\n    )?;\n    test_parse_lemmy_item::<CreateOrUpdateNoteWrapper>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_comment.json\",\n    )?;\n    test_parse_lemmy_item::<CreateOrUpdateNoteWrapper>(\n      \"../apub/assets/lemmy/activities/create_or_update/create_private_message.json\",\n    )?;\n    test_parse_lemmy_item::<NoteWrapper>(\"../apub/assets/lemmy/objects/comment.json\")?;\n    test_parse_lemmy_item::<NoteWrapper>(\"../apub/assets/lemmy/objects/private_message.json\")?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/create_or_update/note.rs",
    "content": "use crate::protocol::CreateOrUpdateType;\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  protocol::{note::Note, tags::ApubTag},\n  utils::protocol::InCommunity,\n};\nuse lemmy_db_schema::source::community::Community;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateOrUpdateNote {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: Note,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(default)]\n  pub(crate) tag: Vec<ApubTag>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: CreateOrUpdateType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for CreateOrUpdateNote {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let post = self.object.get_parents(context).await?.0;\n    let community = Community::read(&mut context.pool(), post.community_id).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/create_or_update/note_wrapper.rs",
    "content": "use activitypub_federation::kinds::object::NoteType;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateOrUpdateNoteWrapper {\n  pub(crate) object: NoteWrapper,\n  pub(crate) id: Url,\n  #[serde(default)]\n  pub(crate) to: Vec<Url>,\n  #[serde(default)]\n  pub(crate) cc: Vec<Url>,\n  pub(crate) actor: Url,\n  #[serde(flatten)]\n  other: Map<String, Value>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct NoteWrapper {\n  pub(crate) r#type: NoteType,\n  #[serde(flatten)]\n  other: Map<String, Value>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/create_or_update/page.rs",
    "content": "use crate::protocol::CreateOrUpdateType;\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  protocol::page::Page,\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateOrUpdatePage {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: Page,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) cc: Vec<Url>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: CreateOrUpdateType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\nimpl InCommunity for CreateOrUpdatePage {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let community = self.object.community(context).await?;\n    Ok(community)\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/create_or_update/private_message.rs",
    "content": "use crate::protocol::CreateOrUpdateType;\nuse activitypub_federation::{fetch::object_id::ObjectId, protocol::helpers::deserialize_one};\nuse lemmy_apub_objects::{objects::person::ApubPerson, protocol::private_message::PrivateMessage};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateOrUpdatePrivateMessage {\n  pub(crate) id: Url,\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one\")]\n  pub(crate) to: [ObjectId<ApubPerson>; 1],\n  pub(crate) object: PrivateMessage,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: CreateOrUpdateType,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/deletion/delete.rs",
    "content": "use crate::{deletion::DeletableObjects, protocol::IdOrNestedObject};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::activity::DeleteType,\n  protocol::{helpers::deserialize_one_or_many, tombstone::Tombstone},\n};\nuse anyhow::anyhow;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_db_schema::source::{community::Community, post::Post};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Delete {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: IdOrNestedObject<Tombstone>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: DeleteType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Vec::is_empty\")]\n  pub(crate) cc: Vec<Url>,\n  /// If summary is present, this is a mod action (Remove in Lemmy terms). Otherwise, its a user\n  /// deleting their own content.\n  pub(crate) summary: Option<String>,\n  /// Nonstandard field, only valid if object refers to a Person. If present, all content from the\n  /// user should be deleted along with the account\n  pub(crate) remove_data: Option<bool>,\n  /// Nonstandard field denoting that the replies to an `Object` should be removed along with the\n  /// `Object`. Only valid for `Pages` and `Notes`.\n  // See here for discussion of this:\n  // https://activitypub.space/topic/78/deleting-a-post-vs-deleting-an-entire-comment-tree\n  pub(crate) with_replies: Option<bool>,\n}\n\nimpl InCommunity for Delete {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let community_id = match DeletableObjects::read_from_db(self.object.id(), context).await? {\n      DeletableObjects::Community(c) => c.id,\n      DeletableObjects::Comment(c) => {\n        let post = Post::read(&mut context.pool(), c.post_id).await?;\n        post.community_id\n      }\n      DeletableObjects::Post(p) => p.community_id,\n      DeletableObjects::Person(_) => return Err(anyhow!(\"Person is not part of community\").into()),\n      DeletableObjects::PrivateMessage(_) => {\n        return Err(anyhow!(\"Private message is not part of community\").into());\n      }\n    };\n    let community = Community::read(&mut context.pool(), community_id).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/deletion/delete_user.rs",
    "content": "use activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::DeleteType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_apub_objects::objects::person::ApubPerson;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DeleteUser {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: ObjectId<ApubPerson>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: DeleteType,\n  pub(crate) id: Url,\n\n  #[serde(deserialize_with = \"deserialize_one_or_many\", default)]\n  #[serde(skip_serializing_if = \"Vec::is_empty\")]\n  pub(crate) cc: Vec<Url>,\n  /// Nonstandard field. If present, all content from the user should be deleted along with the\n  /// account\n  pub(crate) remove_data: Option<bool>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/deletion/mod.rs",
    "content": "pub mod delete;\npub mod delete_user;\npub mod undo_delete;\n\n#[cfg(test)]\nmod tests {\n  use crate::protocol::deletion::{\n    delete::Delete,\n    delete_user::DeleteUser,\n    undo_delete::UndoDelete,\n  };\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_deletion() -> LemmyResult<()> {\n    test_parse_lemmy_item::<Delete>(\"../apub/assets/lemmy/activities/deletion/remove_note.json\")?;\n    test_parse_lemmy_item::<Delete>(\"../apub/assets/lemmy/activities/deletion/delete_page.json\")?;\n\n    test_parse_lemmy_item::<UndoDelete>(\n      \"../apub/assets/lemmy/activities/deletion/undo_remove_note.json\",\n    )?;\n    test_parse_lemmy_item::<UndoDelete>(\n      \"../apub/assets/lemmy/activities/deletion/undo_delete_page.json\",\n    )?;\n    test_parse_lemmy_item::<Delete>(\n      \"../apub/assets/lemmy/activities/deletion/delete_private_message.json\",\n    )?;\n    test_parse_lemmy_item::<UndoDelete>(\n      \"../apub/assets/lemmy/activities/deletion/undo_delete_private_message.json\",\n    )?;\n\n    test_parse_lemmy_item::<DeleteUser>(\n      \"../apub/assets/lemmy/activities/deletion/delete_user.json\",\n    )?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/deletion/undo_delete.rs",
    "content": "use super::delete::Delete;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::UndoType,\n  protocol::helpers::deserialize_one_or_many,\n};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UndoDelete {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  pub(crate) object: Delete,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UndoType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n\n  #[serde(deserialize_with = \"deserialize_one_or_many\", default)]\n  #[serde(skip_serializing_if = \"Vec::is_empty\")]\n  pub(crate) cc: Vec<Url>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/following/accept.rs",
    "content": "use crate::protocol::following::follow::Follow;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::AcceptType,\n  protocol::helpers::deserialize_skip_error,\n};\nuse lemmy_apub_objects::objects::{UserOrCommunity, community::ApubCommunity};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AcceptFollow {\n  pub(crate) actor: ObjectId<ApubCommunity>,\n  /// Optional, for compatibility with platforms that always expect recipient field\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) to: Option<[ObjectId<UserOrCommunity>; 1]>,\n  pub(crate) object: Follow,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: AcceptType,\n  pub(crate) id: Url,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/following/follow.rs",
    "content": "use activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::FollowType,\n  protocol::helpers::deserialize_skip_error,\n};\nuse lemmy_apub_objects::objects::{UserOrCommunity, UserOrCommunityOrMulti};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Follow {\n  pub(crate) actor: ObjectId<UserOrCommunity>,\n  /// Optional, for compatibility with platforms that always expect recipient field\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) to: Option<[ObjectId<UserOrCommunityOrMulti>; 1]>,\n  pub(crate) object: ObjectId<UserOrCommunityOrMulti>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: FollowType,\n  pub(crate) id: Url,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/following/mod.rs",
    "content": "pub(crate) mod accept;\npub mod follow;\npub(crate) mod reject;\npub mod undo_follow;\n\n#[cfg(test)]\nmod tests {\n  use crate::protocol::following::{accept::AcceptFollow, follow::Follow, undo_follow::UndoFollow};\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_accept_follow() -> LemmyResult<()> {\n    test_parse_lemmy_item::<Follow>(\"../apub/assets/lemmy/activities/following/follow.json\")?;\n    test_parse_lemmy_item::<AcceptFollow>(\"../apub/assets/lemmy/activities/following/accept.json\")?;\n    test_parse_lemmy_item::<UndoFollow>(\n      \"../apub/assets/lemmy/activities/following/undo_follow.json\",\n    )?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/following/reject.rs",
    "content": "use crate::protocol::following::follow::Follow;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::RejectType,\n  protocol::helpers::deserialize_skip_error,\n};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct RejectFollow {\n  pub(crate) actor: ObjectId<ApubCommunity>,\n  /// Optional, for compatibility with platforms that always expect recipient field\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) to: Option<[ObjectId<ApubPerson>; 1]>,\n  pub(crate) object: Follow,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: RejectType,\n  pub(crate) id: Url,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/following/undo_follow.rs",
    "content": "use crate::protocol::following::follow::Follow;\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::activity::UndoType,\n  protocol::helpers::deserialize_skip_error,\n};\nuse lemmy_apub_objects::objects::{UserOrCommunity, person::ApubPerson};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UndoFollow {\n  pub(crate) actor: ObjectId<UserOrCommunity>,\n  /// Optional, for compatibility with platforms that always expect recipient field\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) to: Option<[ObjectId<ApubPerson>; 1]>,\n  pub(crate) object: Follow,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UndoType,\n  pub(crate) id: Url,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/mod.rs",
    "content": "use activitypub_federation::{config::Data, fetch::fetch_object_http};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::utils::protocol::Id;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize, de::DeserializeOwned};\nuse strum::Display;\nuse url::Url;\n\npub mod block;\npub mod community;\npub mod create_or_update;\npub mod deletion;\npub mod following;\npub mod voting;\n\n#[derive(Clone, Debug, Display, Deserialize, Serialize, PartialEq, Eq)]\npub enum CreateOrUpdateType {\n  Create,\n  Update,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\npub enum IdOrNestedObject<Kind: Id> {\n  Id(Url),\n  NestedObject(Kind),\n}\n\nimpl<Kind: Id + DeserializeOwned + Send> IdOrNestedObject<Kind> {\n  pub(crate) fn id(&self) -> &Url {\n    match self {\n      IdOrNestedObject::Id(i) => i,\n      IdOrNestedObject::NestedObject(n) => n.id(),\n    }\n  }\n  pub async fn object(self, context: &Data<LemmyContext>) -> LemmyResult<Kind> {\n    match self {\n      // TODO: move IdOrNestedObject struct to library and make fetch_object_http private\n      IdOrNestedObject::Id(i) => Ok(fetch_object_http(&i, context).await?.object),\n      IdOrNestedObject::NestedObject(o) => Ok(o),\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::protocol::{\n    community::{announce::AnnounceActivity, report::Report},\n    create_or_update::{note::CreateOrUpdateNote, page::CreateOrUpdatePage},\n    deletion::delete::Delete,\n    following::{accept::AcceptFollow, follow::Follow, undo_follow::UndoFollow},\n    voting::{undo_vote::UndoVote, vote::Vote},\n  };\n  use lemmy_apub_objects::utils::test::test_json;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_smithereen_activities() -> LemmyResult<()> {\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/smithereen/activities/create_note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_pleroma_activities() -> LemmyResult<()> {\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/pleroma/activities/create_note.json\")?;\n    test_json::<Delete>(\"../apub/assets/pleroma/activities/delete.json\")?;\n    test_json::<Follow>(\"../apub/assets/pleroma/activities/follow.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_mastodon_activities() -> LemmyResult<()> {\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/mastodon/activities/create_note.json\")?;\n    test_json::<Delete>(\"../apub/assets/mastodon/activities/delete.json\")?;\n    test_json::<Follow>(\"../apub/assets/mastodon/activities/follow.json\")?;\n    test_json::<UndoFollow>(\"../apub/assets/mastodon/activities/undo_follow.json\")?;\n    test_json::<Vote>(\"../apub/assets/mastodon/activities/like_page.json\")?;\n    test_json::<UndoVote>(\"../apub/assets/mastodon/activities/undo_like_page.json\")?;\n    test_json::<Report>(\"../apub/assets/mastodon/activities/flag.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_lotide_activities() -> LemmyResult<()> {\n    test_json::<Follow>(\"../apub/assets/lotide/activities/follow.json\")?;\n    test_json::<CreateOrUpdatePage>(\"../apub/assets/lotide/activities/create_page.json\")?;\n    test_json::<CreateOrUpdatePage>(\"../apub/assets/lotide/activities/create_page_image.json\")?;\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/lotide/activities/create_note_reply.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_friendica_activities() -> LemmyResult<()> {\n    test_json::<CreateOrUpdatePage>(\"../apub/assets/friendica/activities/create_page_1.json\")?;\n    test_json::<CreateOrUpdatePage>(\"../apub/assets/friendica/activities/create_page_2.json\")?;\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/friendica/activities/create_note.json\")?;\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/friendica/activities/update_note.json\")?;\n    test_json::<Delete>(\"../apub/assets/friendica/activities/delete.json\")?;\n    test_json::<Vote>(\"../apub/assets/friendica/activities/like_page.json\")?;\n    test_json::<Vote>(\"../apub/assets/friendica/activities/dislike_page.json\")?;\n    test_json::<UndoVote>(\"../apub/assets/friendica/activities/undo_dislike_page.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_gnusocial_activities() -> LemmyResult<()> {\n    test_json::<CreateOrUpdatePage>(\"../apub/assets/gnusocial/activities/create_page.json\")?;\n    test_json::<CreateOrUpdateNote>(\"../apub/assets/gnusocial/activities/create_note.json\")?;\n    test_json::<Vote>(\"../apub/assets/gnusocial/activities/like_note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_peertube_activities() -> LemmyResult<()> {\n    test_json::<AnnounceActivity>(\"../apub/assets/peertube/activities/announce_video.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_mbin_activities() -> LemmyResult<()> {\n    test_json::<AcceptFollow>(\"../apub/assets/mbin/activities/accept.json\")?;\n    test_json::<Report>(\"../apub/assets/mbin/activities/flag.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_wordpress_activities() -> LemmyResult<()> {\n    test_json::<AnnounceActivity>(\"../apub/assets/wordpress/activities/announce.json\")?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/voting/mod.rs",
    "content": "pub mod undo_vote;\npub mod vote;\n\n#[cfg(test)]\nmod tests {\n  use crate::protocol::voting::{undo_vote::UndoVote, vote::Vote};\n  use lemmy_apub_objects::utils::test::test_parse_lemmy_item;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_lemmy_voting() -> LemmyResult<()> {\n    test_parse_lemmy_item::<Vote>(\"../apub/assets/lemmy/activities/voting/like_note.json\")?;\n    test_parse_lemmy_item::<Vote>(\"../apub/assets/lemmy/activities/voting/dislike_page.json\")?;\n\n    test_parse_lemmy_item::<UndoVote>(\n      \"../apub/assets/lemmy/activities/voting/undo_like_note.json\",\n    )?;\n    test_parse_lemmy_item::<UndoVote>(\n      \"../apub/assets/lemmy/activities/voting/undo_dislike_page.json\",\n    )?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/voting/undo_vote.rs",
    "content": "use super::vote::Vote;\nuse activitypub_federation::{fetch::object_id::ObjectId, kinds::activity::UndoType};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UndoVote {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  pub(crate) object: Vote,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UndoType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n"
  },
  {
    "path": "crates/apub/activities/src/protocol/voting/vote.rs",
    "content": "use crate::post_or_comment_community;\nuse activitypub_federation::{config::Data, fetch::object_id::ObjectId};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, community::ApubCommunity, person::ApubPerson},\n  utils::protocol::InCommunity,\n};\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse strum::Display;\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Vote {\n  pub(crate) actor: ObjectId<ApubPerson>,\n  pub(crate) object: ObjectId<PostOrComment>,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: VoteType,\n  pub(crate) id: Url,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n}\n\n#[derive(Clone, Debug, Display, Deserialize, Serialize, PartialEq, Eq)]\npub enum VoteType {\n  Like,\n  Dislike,\n}\n\nimpl From<bool> for VoteType {\n  fn from(value: bool) -> Self {\n    if value {\n      VoteType::Like\n    } else {\n      VoteType::Dislike\n    }\n  }\n}\n\nimpl From<&VoteType> for bool {\n  fn from(value: &VoteType) -> Self {\n    value == &VoteType::Like\n  }\n}\n\nimpl InCommunity for Vote {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let post_or_comment = self.object.dereference(context).await?;\n    let community = post_or_comment_community(&post_or_comment, context).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/voting/mod.rs",
    "content": "use crate::{\n  activity_lists::AnnouncableActivities,\n  community::send_activity_in_community,\n  protocol::voting::{\n    undo_vote::UndoVote,\n    vote::{Vote, VoteType},\n  },\n};\nuse activitypub_federation::{config::Data, fetch::object_id::ObjectId};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n};\nuse lemmy_apub_objects::objects::{\n  PostOrComment,\n  comment::ApubComment,\n  community::ApubCommunity,\n  person::ApubPerson,\n  post::ApubPost,\n};\nuse lemmy_db_schema::{\n  source::{\n    activity::ActivitySendTargets,\n    comment::{CommentActions, CommentLikeForm},\n    community::Community,\n    person::Person,\n    post::{PostActions, PostLikeForm},\n  },\n  traits::Likeable,\n};\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse lemmy_utils::error::LemmyResult;\n\npub mod undo_vote;\npub mod vote;\n\npub(crate) async fn send_like_activity(\n  object_id: DbUrl,\n  actor: Person,\n  community: Community,\n  previous_is_upvote: Option<bool>,\n  new_is_upvote: Option<bool>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let object_id: ObjectId<PostOrComment> = object_id.into();\n  let actor: ApubPerson = actor.into();\n  let community: ApubCommunity = community.into();\n\n  let empty = ActivitySendTargets::empty();\n  if let Some(s) = new_is_upvote {\n    let vote = Vote::new(object_id, &actor, &community, s.into(), &context)?;\n    let activity = AnnouncableActivities::Vote(vote);\n    send_activity_in_community(activity, &actor, &community, empty, false, &context).await\n  } else {\n    // undo a previous vote\n    let previous_vote_type = if previous_is_upvote == Some(true) {\n      VoteType::Like\n    } else {\n      VoteType::Dislike\n    };\n    let vote = Vote::new(object_id, &actor, &community, previous_vote_type, &context)?;\n    let undo_vote = UndoVote::new(vote, &actor, &community, &context)?;\n    let activity = AnnouncableActivities::UndoVote(undo_vote);\n    send_activity_in_community(activity, &actor, &community, empty, false, &context).await\n  }\n}\n\nasync fn vote_comment(\n  vote_type: &VoteType,\n  actor: ApubPerson,\n  comment: &ApubComment,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let mut like_form = CommentLikeForm::new(comment.id, actor.id, Some(vote_type.into()));\n  comment.set_not_pending(&mut context.pool()).await?;\n  like_form = plugin_hook_before(\"comment_before_vote\", like_form).await?;\n  let like = CommentActions::like(&mut context.pool(), &like_form).await?;\n  plugin_hook_after(\"comment_after_vote\", &like);\n  Ok(())\n}\n\nasync fn vote_post(\n  vote_type: &VoteType,\n  actor: ApubPerson,\n  post: &ApubPost,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let mut like_form = PostLikeForm::new(post.id, actor.id, Some(vote_type.into()));\n  post.set_not_pending(&mut context.pool()).await?;\n  like_form = plugin_hook_before(\"post_before_vote\", like_form).await?;\n  let like = PostActions::like(&mut context.pool(), &like_form).await?;\n  plugin_hook_after(\"post_after_vote\", &like);\n  Ok(())\n}\n\nasync fn undo_vote_comment(\n  actor: ApubPerson,\n  comment: &ApubComment,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let form = CommentLikeForm::new(comment.id, actor.id, None);\n  CommentActions::like(&mut context.pool(), &form).await?;\n  Ok(())\n}\n\nasync fn undo_vote_post(\n  actor: ApubPerson,\n  post: &ApubPost,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let form = PostLikeForm::new(post.id, actor.id, None);\n  PostActions::like(&mut context.pool(), &form).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/activities/src/voting/undo_vote.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::voting::{undo_vote::UndoVote, vote::Vote},\n  voting::{undo_vote_comment, undo_vote_post},\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::activity::UndoType,\n  protocol::verification::verify_urls_match,\n  traits::{Activity, Object},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, community::ApubCommunity, person::ApubPerson},\n  utils::{functions::verify_person_in_community, protocol::InCommunity},\n};\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl UndoVote {\n  pub(in crate::voting) fn new(\n    vote: Vote,\n    actor: &ApubPerson,\n    community: &ApubCommunity,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Self> {\n    Ok(UndoVote {\n      actor: actor.id().clone().into(),\n      object: vote,\n      kind: UndoType::Undo,\n      id: generate_activity_id(UndoType::Undo, context)?,\n      audience: Some(community.ap_id.clone().into()),\n    })\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for UndoVote {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.object.community(context).await?;\n    check_community_deleted_or_removed(&community)?;\n    verify_person_in_community(&self.actor, &community, context).await?;\n    verify_urls_match(self.actor.inner(), self.object.actor.inner())?;\n    self.object.verify(context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let actor = self.actor.dereference(context).await?;\n    let object = self.object.object.dereference(context).await?;\n    match object {\n      PostOrComment::Left(p) => undo_vote_post(actor, &p, context).await,\n      PostOrComment::Right(c) => undo_vote_comment(actor, &c, context).await,\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/activities/src/voting/vote.rs",
    "content": "use crate::{\n  check_community_deleted_or_removed,\n  generate_activity_id,\n  protocol::voting::vote::{Vote, VoteType},\n  voting::{undo_vote_comment, undo_vote_post, vote_comment, vote_post},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  traits::{Activity, Object},\n};\nuse lemmy_api_utils::{context::LemmyContext, utils::check_bot_account};\nuse lemmy_apub_objects::{\n  objects::{PostOrComment, community::ApubCommunity, person::ApubPerson},\n  utils::{functions::verify_person_in_community, protocol::InCommunity},\n};\nuse lemmy_db_schema_file::enums::FederationMode;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\nimpl Vote {\n  pub(in crate::voting) fn new(\n    object_id: ObjectId<PostOrComment>,\n    actor: &ApubPerson,\n    community: &ApubCommunity,\n    kind: VoteType,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Vote> {\n    Ok(Vote {\n      actor: actor.id().clone().into(),\n      object: object_id,\n      kind: kind.clone(),\n      id: generate_activity_id(kind, context)?,\n      audience: Some(community.ap_id.clone().into()),\n    })\n  }\n}\n\n#[async_trait::async_trait]\nimpl Activity for Vote {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    self.actor.inner()\n  }\n\n  async fn verify(&self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let community = self.community(context).await?;\n    check_community_deleted_or_removed(&community)?;\n    verify_person_in_community(&self.actor, &community, context).await?;\n    Ok(())\n  }\n\n  async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {\n    let actor = self.actor.dereference(context).await?;\n    let object = self.object.dereference(context).await?;\n\n    check_bot_account(&actor.0)?;\n\n    // Check for enabled federation votes\n    let local_site = SiteView::read_local(&mut context.pool())\n      .await\n      .map(|s| s.local_site)\n      .unwrap_or_default();\n\n    let (downvote_setting, upvote_setting) = match object {\n      PostOrComment::Left(_) => (local_site.post_downvotes, local_site.post_upvotes),\n      PostOrComment::Right(_) => (local_site.comment_downvotes, local_site.comment_upvotes),\n    };\n\n    // Don't allow dislikes for either disabled, or local only votes\n    let downvote_fail = self.kind == VoteType::Dislike && downvote_setting != FederationMode::All;\n    let upvote_fail = self.kind == VoteType::Like && upvote_setting != FederationMode::All;\n\n    if downvote_fail || upvote_fail {\n      // If this is a rejection, undo the vote\n      match object {\n        PostOrComment::Left(p) => undo_vote_post(actor, &p, context).await,\n        PostOrComment::Right(c) => undo_vote_comment(actor, &c, context).await,\n      }\n    } else {\n      // Otherwise apply the vote normally\n      match object {\n        PostOrComment::Left(p) => vote_post(&self.kind, actor, &p, context).await,\n        PostOrComment::Right(c) => vote_comment(&self.kind, actor, &c, context).await,\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/Cargo.toml",
    "content": "[package]\nname = \"lemmy_apub\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_apub\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[features]\nfull = []\n\n[lints]\nworkspace = true\n\n[dependencies]\nlemmy_db_views_community_moderator = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_follower = { workspace = true, features = [\"full\"] }\nlemmy_db_views_community_follower_approval = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_db_views_post = { workspace = true, features = [\"full\"] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true, features = [\"full\"] }\nlemmy_apub_activities = { workspace = true }\nlemmy_apub_objects = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nactivitypub_federation = { workspace = true }\nlemmy_db_schema_file = { workspace = true }\nserde_json = { workspace = true }\nserde = { workspace = true }\nactix-web = { workspace = true }\ntokio = { workspace = true }\ntracing = { workspace = true }\nurl = { workspace = true }\nfutures = { workspace = true }\nasync-trait = { workspace = true }\neither = { workspace = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/apub/apub/assets/discourse/objects/group.json",
    "content": "{\n  \"id\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146\",\n  \"type\": \"Group\",\n  \"updated\": \"2024-04-05T12:49:51Z\",\n  \"url\": \"https://socialhub.activitypub.rocks/c/meeting/threadiverse-wg/88\",\n  \"name\": \"Threadiverse Working Group (SocialHub)\",\n  \"inbox\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/inbox\",\n  \"outbox\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/outbox\",\n  \"followers\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/followers\",\n  \"preferredUsername\": \"threadiverse-wg\",\n  \"publicKey\": {\n    \"id\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146#main-key\",\n    \"owner\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApJi4iAcW6bPiHVCxT9p0\\n8DVnrDDO4QtLNy7bpRFdMFifmmmXprsuAi9D2MSwbhH49V54HtIkxBpKd2IR/UD8\\nmhMDY4CNI9FHpjqLw0wtkzxcqF9urSqhn0/vWX+9oxyhIgQS5KMiIkYDMJiAc691\\niEcZ8LCran23xIGl6Dk54Nr3TqTMLcjDhzQYUJbxMrLq5/knWqOKG3IF5OxK+9ZZ\\n1wxDF872eJTxJLkmpag+WYNtHzvB2SGTp8j5IF1/pZ9J1c3cpYfaeolTch/B/GQn\\najCB4l27U52rIIObxJqFXSY8wHyd0aAmNmxzPZ7cduRlBDhmI40cAmnCV1YQPvpk\\nDwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"mediaType\": \"image/png\",\n    \"url\": \"https://socialhub.activitypub.rocks/uploads/default/original/1X/8faac84234dc73d074dadaa2bcf24dc746b8647f.png\"\n  },\n  \"@context\": \"https://www.w3.org/ns/activitystreams\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/discourse/objects/page.json",
    "content": "{\n  \"id\": \"https://socialhub.activitypub.rocks/ap/object/1899f65c062200daec50a4c89ed76dc9\",\n  \"type\": \"Note\",\n  \"audience\": \"https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146\",\n  \"published\": \"2024-04-13T14:36:19Z\",\n  \"updated\": \"2024-04-13T14:36:19Z\",\n  \"url\": \"https://socialhub.activitypub.rocks/t/our-next-meeting/4079/1\",\n  \"attributedTo\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1\",\n  \"name\": \"Our next meeting\",\n  \"context\": \"https://socialhub.activitypub.rocks/ap/collection/8850f6e85b57c490da915a5dfbbd5045\",\n  \"content\": \"<h3>Last Meeting</h3>\\n<h4>Recording</h4>\\n<a href=\\\"https://us06web.zoom.us/rec/share/4hGBTvgXJPlu8UkjkkxVARypNg5DH0eeaKlIBv71D4G3lokYyrCrg7cqBCJmL109.FsHYTZDlVvZXrgcn?startTime=1712254114000\\\">https://us06web.zoom.us/rec/share/4hGBTvgXJPlu8UkjkkxVARypNg5DH0eeaKlIBv71D4G3lokYyrCrg7cqBCJmL109.FsHYTZDlVvZXrgcn?startTime=1712254114000</a>\\nPasscode: z+1*4pUB\\n<h4>Minutes</h4>\\nTo refresh your memory, you can read the minutes of last week's meeting <a href=\\\"https://community.nodebb.org/topic/17949/minutes&hellip;\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/discourse/objects/person.json",
    "content": "{\n  \"id\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1\",\n  \"type\": \"Person\",\n  \"updated\": \"2024-01-15T12:27:03Z\",\n  \"url\": \"https://socialhub.activitypub.rocks/u/angus\",\n  \"name\": \"Angus McLeod\",\n  \"inbox\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1/inbox\",\n  \"outbox\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1/outbox\",\n  \"sharedInbox\": \"https://socialhub.activitypub.rocks/ap/users/inbox\",\n  \"followers\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1/followers\",\n  \"preferredUsername\": \"angus\",\n  \"publicKey\": {\n    \"id\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1#main-key\",\n    \"owner\": \"https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3RpuFDuwXZzOeHO5fO2O\\nHmP7Flc5JDXJ8OOEJYq5T/dzUKqREOF1ZT0WMww8/E3P6w+gfFsjzThraJb8nHuW\\nP6798SUD35CWBclfhw9DapjVn99JyFcAWcH3b9fr6LYshc4y1BoeJagk1kcro2Dc\\n+pX0vVXgNjwWnGfyucAgGIUWrNUjcvIvXmyVCBSQfXG3nCALV1JbI4KSgf/5KyBn\\nza/QefaetxYiFV8wAisPKLsz3XQAaITsQmbSi+8gmwXt/9U808PK1KphCiClDOWg\\noi0HPzJn0rn+mwFCfgNWenvribfeG40AHLG33OkWKvslufjifdWDCOcBYYzyCEV6\\n+wIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"mediaType\": \"image/png\",\n    \"url\": \"https://socialhub.activitypub.rocks/user_avatar/socialhub.activitypub.rocks/angus/96/2295_2.png\"\n  },\n  \"@context\": \"https://www.w3.org/ns/activitystreams\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/create_note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://soc.schuerz.at/objects/4edd2508-4361-edb8-c4d8-b45181083984/Create\",\n  \"type\": \"Create\",\n  \"actor\": \"https://soc.schuerz.at/profile/jakob\",\n  \"published\": \"2022-01-23T20:21:24Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2021.12-rc-1448\",\n    \"url\": \"https://soc.schuerz.at\"\n  },\n  \"to\": [\n    \"https://lemmy.schuerz.at/u/jakob\",\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://lemmy.schuerz.at/c/test\"\n  ],\n  \"cc\": [\"https://soc.schuerz.at/followers/jakob\"],\n  \"object\": {\n    \"id\": \"https://soc.schuerz.at/objects/4edd2508-4361-edb8-c4d8-b45181083984\",\n    \"type\": \"Note\",\n    \"summary\": \"\",\n    \"inReplyTo\": \"https://lemmy.schuerz.at/post/25360\",\n    \"diaspora:guid\": \"4edd2508-4361-edb8-c4d8-b45181083984\",\n    \"published\": \"2022-01-23T20:21:24Z\",\n    \"url\": \"https://soc.schuerz.at/display/4edd2508-4361-edb8-c4d8-b45181083984\",\n    \"attributedTo\": \"https://soc.schuerz.at/profile/jakob\",\n    \"sensitive\": false,\n    \"context\": \"https://lemmy.schuerz.at/post/25360#context\",\n    \"content\": \"<span class=\\\"h-card\\\"><a href=\\\"https://lemmy.schuerz.at/u/jakob\\\" class=\\\"u-url mention\\\">@<span>jakob</span></a></span> test\",\n    \"contentMap\": {\n      \"de\": \"<bdi>@<a href=\\\"https://lemmy.schuerz.at/u/jakob\\\" class=\\\"userinfo mention\\\" title=\\\"jakob\\\">jakob</a></bdi> test\"\n    },\n    \"source\": {\n      \"content\": \"@[url=https://lemmy.schuerz.at/u/jakob]Jakob[/url] test\",\n      \"mediaType\": \"text/bbcode\"\n    },\n    \"diaspora:comment\": \"{\\\"author\\\":\\\"jakob@soc.schuerz.at\\\",\\\"guid\\\":\\\"4edd2508-4361-edb8-c4d8-b45181083984\\\",\\\"created_at\\\":\\\"2022-01-23T20:21:24Z\\\",\\\"edited_at\\\":\\\"2022-01-23T20:21:24Z\\\",\\\"parent_guid\\\":\\\"ea620d1e-742c8b4d15249a9b-18b5fca3\\\",\\\"text\\\":\\\"@{Jakob; jakob@lemmy.schuerz.at} test\\\",\\\"author_signature\\\":\\\"JNCqOui5Cg8\\\\/Uxw+f0NtGCRjRnhPOrqE6kGJnMkZvOOKhlCdZbCqvyPlNJzEYDa3Z30mOWQKTTNo5BVI+VVZtGrVEqFOdzNog7jOLQoY1dKU9iEQ9vc8USwUCkyJyv48w1iXpfea87KPwv+03DMlftmD6kC7jdUVwhc7+jm0g4fh06tpOcCMQJOZqTTV\\\\/80EjxIJQ+8eEk5evSw\\\\/S98ohD1ahcwSomJ9hJUV1H48ucDvMod1FCLcN5h4ALHqubCu4TZIYhGhw9zoCl52GeHhrD3\\\\/vL6OW4ftZ7UG4rEKQ4HowuXqmNwydrQldtprRtu2UrZBjLqVusPXEs\\\\/xERQqZnalNXHijyd1TwwCmfTV4YjKwH4BhX\\\\/p4hdWMqEP4yYXlfA4apalVeAaYZLrNR58kPJjBHad\\\\/yqH30ziBFheqZ5odFh\\\\/jnKB4OCFVST3u9b1OKE0jyTrbTepPTaONwc8giQH1sM8koj1gFdulwuJuOTRUKR\\\\/8ishgHi5SWwbp5YG5Z3YSINkF10IcLiFZAF300AvwgOCdf7ferim4i\\\\/7TR1D2CBpoNUZnKCKZRymZbE0GuKEE+A6Pk3lk\\\\/DCsDtmMXpnxlPZ8Nq8OZS\\\\/olXevAu1y57MNnxBDXtojr4F54MP2fO7E2JwBr7AlwoeSEvtZSAO\\\\/elzrKfW0eVWOUM2OnI=\\\"}\",\n    \"attachment\": [],\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://lemmy.schuerz.at/u/jakob\",\n        \"name\": \"@jakob@lemmy.schuerz.at\"\n      }\n    ],\n    \"to\": [\n      \"https://lemmy.schuerz.at/u/jakob\",\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://lemmy.schuerz.at/c/test\"\n    ],\n    \"cc\": [\"https://soc.schuerz.at/followers/jakob\"]\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"fe42f1478453c9c5e92efdc8a1b00c7e2dd2ce89501f2437c4438b8add1c8ff7\",\n    \"creator\": \"https://soc.schuerz.at/profile/jakob#main-key\",\n    \"created\": \"2022-01-23T20:21:25Z\",\n    \"signatureValue\": \"iWeNKyfH/d5+f6FDmZIadF4hW7XBliL8w3PQ2QkeKQG7fheqx1MB6825JX+Eaq8C0aNESesTTiDJgy3Xdcw8tgKwAVdji2DNZh7rNbSy57AzXlXOPRDnGJUbXp8gAuW2PJNZx3TTsJ5yM7tKLmHk0PpwsnKbvjFabL5O+htyfRZNVjFAsB9bVym/dBvf4jiTZiLufGDprgsaDVygUi3QrzmwsE41NZtL/MIEtbiC5pROWQvdQBEzeLfMDsnjI4CR+3tnaSlvepipuFxeSFpwl5Ae5+YM6IYRvSDsssjr8kAg1t3XnHUyeBdBdys0A6ryR5t5QuY0ygAHFs+X633JsgHDuCxxHiqNYxFuTs1xO0gmHydFy1iKlEt2rbr9pcX05hSvEFg0bI8HEC5M9GuafpY7sOyLX0jobBUH9CxdHUu0qri4ntORlvvAYsGFNHj+folFlMRBNMkcZ+MbrAxdoXBdjhsAp+tD6nje+PeZy63yJJQmPLQi9E+fHGGe0DAobGrBE/XF8X1ABH+ywyKwVu0t6lkSxu+zdr9+JXKgnf7HaFSsknapumw9aQwC7N/Q0M5KO41fF0R4VL2GtoppyB9Ck9Dg1zwMWjL2KZN3ckbWABb+frWtmKIVQACzupRWzHiHSZjRRNJalK3uugVisHF2PFGkjYoUjHDCNegKHO0=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/create_page_1.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\"\n    }\n  ],\n  \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/activity\",\n  \"type\": \"Create\",\n  \"actor\": \"https://masto.qa.urbanwildlife.biz/users/mastodon\",\n  \"published\": \"2023-05-26T16:45:48Z\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\n    \"https://masto.qa.urbanwildlife.biz/users/mastodon/followers\",\n    \"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community\",\n    \"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community/followers\"\n  ],\n  \"object\": {\n    \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161\",\n    \"type\": \"Note\",\n    \"summary\": null,\n    \"inReplyTo\": null,\n    \"published\": \"2023-05-26T16:45:48Z\",\n    \"url\": \"https://masto.qa.urbanwildlife.biz/@mastodon/110435994705014161\",\n    \"attributedTo\": \"https://masto.qa.urbanwildlife.biz/users/mastodon\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\n      \"https://masto.qa.urbanwildlife.biz/users/mastodon/followers\",\n      \"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community\",\n      \"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community/followers\"\n    ],\n    \"sensitive\": false,\n    \"atomUri\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161\",\n    \"inReplyToAtomUri\": null,\n    \"conversation\": \"tag:masto.qa.urbanwildlife.biz,2023-05-26:objectId=61:objectType=Conversation\",\n    \"content\": \"<p>Test post to community</p><p><span class=\\\"h-card\\\"><a href=\\\"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community\\\" class=\\\"u-url mention\\\">@<span>lemmy_community</span></a></span></p>\",\n    \"contentMap\": {\n      \"fr\": \"<p>Test post to community</p><p><span class=\\\"h-card\\\"><a href=\\\"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community\\\" class=\\\"u-url mention\\\">@<span>lemmy_community</span></a></span></p>\"\n    },\n    \"attachment\": [],\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://lemmy.qa.urbanwildlife.biz/c/lemmy_community\",\n        \"name\": \"@lemmy_community@lemmy.qa.urbanwildlife.biz\"\n      }\n    ],\n    \"replies\": {\n      \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies\",\n      \"type\": \"Collection\",\n      \"first\": {\n        \"type\": \"CollectionPage\",\n        \"next\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies?only_other_accounts=true&page=true\",\n        \"partOf\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies\",\n        \"items\": []\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/create_page_2.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850/Create\",\n  \"type\": \"Create\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T04:23:44Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1452\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"object\": {\n    \"id\": \"https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850\",\n    \"type\": \"Article\",\n    \"summary\": \"\",\n    \"inReplyTo\": null,\n    \"diaspora:guid\": \"ec054ce7-5162-3bf2-504c-16d024994850\",\n    \"published\": \"2022-03-24T04:23:44Z\",\n    \"url\": \"https://pirati.ca/display/ec054ce7-5162-3bf2-504c-16d024994850\",\n    \"attributedTo\": \"https://pirati.ca/profile/heluecht\",\n    \"sensitive\": false,\n    \"context\": \"https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850#context\",\n    \"name\": \"From Friendica to Lemmy\",\n    \"content\": \"Hello Lemmy!\",\n    \"contentMap\": {\n      \"de\": \"<bdi>!<a href=\\\"https://ds9.lemmy.ml/c/testcom\\\" class=\\\"userinfo mention\\\" title=\\\"testcom\\\">testcom</a></bdi> Hello Lemmy!\"\n    },\n    \"source\": {\n      \"content\": \"![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Hello Lemmy!\",\n      \"mediaType\": \"text/bbcode\"\n    },\n    \"attachment\": [],\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://ds9.lemmy.ml/c/testcom\",\n        \"name\": \"@testcom@ds9.lemmy.ml\"\n      }\n    ],\n    \"to\": [\"https://ds9.lemmy.ml/c/testcom\"],\n    \"cc\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://ds9.lemmy.ml/c/testcom/followers\"\n    ]\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"d1b75df08009e59510606604758732d499c3c385b4ce6ee374e6d8c2ee86230b\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T04:26:55Z\",\n    \"signatureValue\": \"nmAyEh/6Zq5Ki0AYtTFDxGom67HOTuWDzToGuEorm0cOzsNv7OIUgGjkOtKOVJA91J7NaS1hCCSMrhM7HCurIQyE3wDa3NAzhDQORVbbRF6+NxpB70nlJaOaInAS4bmVsed0rBg2aYQfrai0QB4F8zhN8JIa6zu0EAtLMh0vkzDFOCeGbvahLkSJO+sZKEqAWsr3VfMmJ8TCd+JWUKyy2/Hd87czj58oMk8yKzKKHlL0z+rbP2LbpaZspHCT+kfkZy0IOjrdcvgENCwCsPA9Y5kJmH08NXkKG6D6CGIti2zhnkNMxBuZ5sPU6PcE535J/UjfrGN3ikGoxjO3bRqFI42TbhTxBRk/yMv0BVIPLzAbPAJdJ6VAwq5UAjI6G4ejjQ9LKRqjxlG96PNo+YVKFhKTmmSMWLmdWckC8PRL2nZvq4UtIf4cd+p1pQ+TnfjD8ZHadb10tHJEXrUIz9Q/pWmfLvTSodbdYRSurWFNYaJ3gCEjSoCoHxA7GeEoivSA0IuuDRKo0tFEv09/BA9m8m04kjQ8q/ZxAb2P4TICcPZrhGKoDkRTc1P5vSNeuw8GFV/5Dy7NWKarJUlzCA1OoHo/cKWhY3fVc/lANssTyxsMakp4ofN0zRoToQAx6hDLgESJcn04tUO4JvluYkGggBEhPfhLNSyc6yUWahjh9Gc=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/delete.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210/Delete\",\n  \"type\": \"Delete\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T07:22:36Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"object\": {\n    \"id\": \"https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210\",\n    \"type\": \"Tombstone\"\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"eecfe411c2f5b4a21354f2580593c5d6cbbafef1dfc2266c3a29d3136270e489\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T21:43:46Z\",\n    \"signatureValue\": \"SCu1Qj4V4JJl+GbJB/Wy//L6DFSKc0T4lTSxI2FWD+2lyeumtDu3raqg2Kfg2uR1abWgf2T0cDLJn31wpjzAQ6QpR3tGgM7o3yHV9KIZZa0QJ7Oa/cGW0ZJijiNAETKw67cthb+hy4z6dx1M+s7wCSEQZoEZqmgn/5BMY8o0NMw/BSV797uF1tJRq29AsdIgJpjX4eX2kVmVTtYMqHc8T5/l1z3FsZFyL0UkW5BypT0T3lhGlKflov47oNSPsadHgL2A8RPdiY59OLbHCJZnQgcHA3BgeMBnwlmtpGqcfsJKUo+43zXkfKikPOO03WQ+w+LzS9UOLWhhP5yfVBLmwhM5oPfps9VUjJ/gOyWtw8pPAK/LL65sUiooxdR3fqskctVRlTDGJ8WTZPsJwsH9zygBiHOmVnkIdHkNdsA80GD9iGmnPTHixEIY124QWu+o53kydEOAbOiZo7vowjSN4ViPzkhh9xI5xkkPLeIQSEgmSsxBN98xpRVBg17qjkhBWVMCwTIqFsmzlp3oWFdp0m0xwhKJLKwlcKReoaWUCUINBgBUIfAQuWHpv7Clv7xw4pcZ1w/hiZU+1ZOnEjwGQILaGpJvK2C80z5RkhCDSamT5vetS2TrkxbjkJIY6ffUzxJQ/Ox/zN4KWyaYV6DfYELfGkybBXKo3EFhLQDARos=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/dislike_page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367\",\n  \"type\": \"Dislike\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T21:29:23Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"diaspora:guid\": \"ec054ce7-1862-3ce2-b3e4-870035437794\",\n  \"diaspora:like\": \"{\\\"author\\\":\\\"heluecht@pirati.ca\\\",\\\"guid\\\":\\\"ec054ce7-1862-3ce2-b3e4-870035437794\\\",\\\"parent_guid\\\":\\\"ec054ce7-2062-3bfa-8687-ca8313624820\\\",\\\"parent_type\\\":\\\"Post\\\",\\\"positive\\\":\\\"false\\\",\\\"author_signature\\\":\\\"KWp5AQ71Tn4kFgGxzgLDLQUvULKMtsb4DYwP\\\\/Ap9QNGStMQuKvYE2VBthRBaIvX9LmknZ3cBvuqKvNaL2Nj7B2R2Goa7\\\\/eWYDCogwafbp6Pj93vWvdy2+fGTkHGSxobnvgLvFIqv9IOy2Lk4QjWj7o64dUCiopR0OKjL8+vPM+l8iF+7bYeG+xSqy8SX8Fai5XOoNhy9anaJzK9ASLah8VeXKdfjGrvYsx2X\\\\/PaP+B8xFySP2XM95kGPKxyExi7Hk0j2igvjHqC2s3Cdg9+nwuUijnUycqGHUq3djMTLoPRjMHOJquZ1t2BNY575iRbYJTlteIgQkPHf50WALkzxn8zY5MkudBzffxm8B1Q6bnwoQHK8TR7KU2gMPwnQm6\\\\/ncygHuq1flVm3dqrF9xG6Cp2wC2SgTcErhrS\\\\/6in7FzrgBIOl570cxY0ovFICrE6rinuBdJkjfWYE3CZGCo6fVTAXUmje48c0611JmGD3PM6XQigFXGE32fjjaQoIkXf8TWI01kIqJDmZn80S6NXaYSrf9maWN1CB5gQ2E6B6Zk586sTZ2nnnJNol2KTkM1BPCTSMkrdiLtpjkUEGeo1tTe87oUzFHx++rgSO\\\\/lM94Dw5oN2jifGQquDBgIHY5ovxXVN3xrTgfrLEx+HWxdsiuIYpPx4lu4Qe0CVgZwPMqR4=\\\"}\",\n  \"object\": \"https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820\",\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"02cae41f51765647ceeac26de13e29757cb47b7da1b54703116a7ae185fe967e\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T21:32:43Z\",\n    \"signatureValue\": \"C27zo2Ks9twwhMs3WndG3c8l6y6PVM/4fUZKIxHuh50eEx2g9hIRUD0GFgxR5Mbx76E0PDePbJtrEaad+H97USmylFYw+Opp12zAkpFMMsKQgZfSG6ARyKfn/AK6qn/+5gmxAvX5qTMw29Qsbhh9w7mlyYfqZb+A5xmdxi/kV7FAa5AEK9UR8wsCb6FeHhpRPBmSGFqluFyPY0m8uYHwLILZac+KkvQz2+dVQkpt5S+C2N4POxFYQwd1mEoone4HQQtSEfnrlbxLbyCgLKM4Vf6SJnzzyufTzurSySp4kmsnTxfUKsR200TUAozwWBde9UyEjdcv/j1m/ZX18YHzwE4OwAGF6G1LCDAXcwzmH3RvjJwGsFuTz68lZJ5qcAbHvsGxymPJf2RTfLS0I7E2JWZ/yStcwtm9siHNBhusUq7lwJWWkwYw4iuoZBHNT24gj0uTbj82jB5UAIlFyIXbIbR8Di70Jg2ucvQK36XKm6/6TGntkoJ3VGa1qIhB/r0KiVTBPwPSrf8v0sHB6zc3osirpyG8Gzd+FVMs11UaGDQOIur4Bu1/StmuWdh3VFwFE9t4XaR1OEIRexYh4717B8ImfPbSTtyqsc8mE0Hvp+3wkLaD4l889Zu0BGNK9KXvT2ifKGhCg/Q8mM89oNnb4Fsl3Uu5fp8GtE3RPBt4a/g=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/like_page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-2062-3ce2-7f10-2b4451595945\",\n  \"type\": \"Like\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T21:28:31Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"diaspora:guid\": \"ec054ce7-1062-3ce2-7f10-2a6640956978\",\n  \"diaspora:like\": \"{\\\"author\\\":\\\"heluecht@pirati.ca\\\",\\\"guid\\\":\\\"ec054ce7-1062-3ce2-7f10-2a6640956978\\\",\\\"parent_guid\\\":\\\"ec054ce7-2062-3bfa-8687-ca8313624820\\\",\\\"parent_type\\\":\\\"Post\\\",\\\"positive\\\":\\\"true\\\",\\\"author_signature\\\":\\\"F7e4x++hte0pSoUwbB6BcK0gl1c4+FjxlhwjTMvmxnB4HL58Kxk8UJ\\\\/SiTS5g4IoDoRcvQdIgntuHZJfKx3SsIDyWQUP1U9+RrBJh1gskcVmTT15gb2E5qq30PNM8DFV0opewp33KCVWvqqkZ2DIhjiRqqF8eUASUNkdwQci732krkMul\\\\/B211qBbSndxLPdrqv5Wkl0F2mZJZLIiWRoIjaFNV60lUkOwdqz8p7OuD\\\\/DdR\\\\/T8g4s7ofuvKaZ\\\\/scpxqixYHvv7+0cWXBlsKllW97dH1VHo7SdvJKhApk2IQz9BT8JWcBhBYjkb71olF2gajo6EQxaXt2svEOtRi+mDVQ4Lb6gk\\\\/Fp5aC5BVAe5Fe4Wr2GUOLmo5P6Fc6IweaoTgcqH2os2OYc\\\\/isJtQ2jtUtw8smY7kPSa2Qo\\\\/FMLWyCI8XRrWI9XGo8uSA84E+eiSVuGfqYNQNDkhzr5qCZfiF457SsxFpGa3XI53IA24Iatkj91VGSMQ8OWppK7SERwax2mVc5tn7mSq+2va0k9DiLyLoHVdEdzQcjEGCWEW7rPz7ndKBT3cW\\\\/jjsww3znAOko7jloXWremEtiqBIZkmHhA+Ec9UnOcShedqz\\\\/oIB\\\\/IYVUhwD3STG7EpRjh6J6G0FoU1MieCKjtpSadHN+2GN4COzNTE9iIi9Uqonf4Xio=\\\"}\",\n  \"object\": \"https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820\",\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"3913cfa1f27e2e83ef770a414e477d4aac9878d1270e9056fc793fdf0a4e07d5\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T21:33:58Z\",\n    \"signatureValue\": \"uJBDZ4NIKTmcoh2ONypglOZ0El3RtgGeF5X2XSdy8V6QmLD6meUWVMQlcbs0LosSzXX1wxMJsj6QtnFPWEAneRW/gBa5N+F/+vkrnnodbO9CvDfwRIfmWqCNC5Hg4uGVukHbhCwsQkgKoRj9YjQVt0FSSrC5X/NcwiS7ZJkwsHuzv+ZZZE+GArkSQLcf4eOrK7wT1NY0fGGbjeO7+KpJH75kAGI+Yi1BuiGy7tl3dTIdGYb66j+e5RpeZR1AlueLKBB2lN2eT8DS0PwaGVjrgKQQ+riIUV8pWhrlWsoWj1u9ZELXOYUuFsjqzmNPeSU3cnxGcLSvAKmc7j2dm6ErCLFiadaodamYDR0Fqb9yd/IM3ojyxmAQRAMABCnhIppQWkOw5l1koJ2gHmnljh162sFsnifo7ccNuRHYUJjJxwsdQ9aLaAWHySqXDfRk+Hvf5G67+edlETr3XYmVtW96J0ZyQALp/I0QMbPR2Xa/b8RClms0QxNOBwVn8YswLWRZ2XgjQfxoCkRHSbY5nU6e65I4QBiAlSzHNOffNO4LYxj0GPDbEFZy9dFiv9EC9eN/FtIhkykV46PJTuM+hKneh1PLCmkkICWrj7dvPXT/00eQmvZcQTq/tNKlrbzBWQ8DnG6/3GZMyBPgsWk/E0aUwvwGlcQEwYWmzI1NWBe/XMc=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/undo_dislike_page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367/Undo\",\n  \"type\": \"Undo\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T21:29:23Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"object\": {\n    \"id\": \"https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367\",\n    \"type\": \"Dislike\",\n    \"actor\": \"https://pirati.ca/profile/heluecht\",\n    \"published\": \"2022-03-24T21:29:23Z\",\n    \"instrument\": {\n      \"type\": \"Service\",\n      \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n      \"url\": \"https://pirati.ca\"\n    },\n    \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n    \"cc\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://ds9.lemmy.ml/c/testcom/followers\"\n    ],\n    \"diaspora:guid\": \"ec054ce7-1862-3ce2-b3e4-870035437794\",\n    \"diaspora:like\": \"{\\\"author\\\":\\\"heluecht@pirati.ca\\\",\\\"guid\\\":\\\"ec054ce7-1862-3ce2-b3e4-870035437794\\\",\\\"parent_guid\\\":\\\"ec054ce7-2062-3bfa-8687-ca8313624820\\\",\\\"parent_type\\\":\\\"Post\\\",\\\"positive\\\":\\\"false\\\",\\\"author_signature\\\":\\\"KWp5AQ71Tn4kFgGxzgLDLQUvULKMtsb4DYwP\\\\/Ap9QNGStMQuKvYE2VBthRBaIvX9LmknZ3cBvuqKvNaL2Nj7B2R2Goa7\\\\/eWYDCogwafbp6Pj93vWvdy2+fGTkHGSxobnvgLvFIqv9IOy2Lk4QjWj7o64dUCiopR0OKjL8+vPM+l8iF+7bYeG+xSqy8SX8Fai5XOoNhy9anaJzK9ASLah8VeXKdfjGrvYsx2X\\\\/PaP+B8xFySP2XM95kGPKxyExi7Hk0j2igvjHqC2s3Cdg9+nwuUijnUycqGHUq3djMTLoPRjMHOJquZ1t2BNY575iRbYJTlteIgQkPHf50WALkzxn8zY5MkudBzffxm8B1Q6bnwoQHK8TR7KU2gMPwnQm6\\\\/ncygHuq1flVm3dqrF9xG6Cp2wC2SgTcErhrS\\\\/6in7FzrgBIOl570cxY0ovFICrE6rinuBdJkjfWYE3CZGCo6fVTAXUmje48c0611JmGD3PM6XQigFXGE32fjjaQoIkXf8TWI01kIqJDmZn80S6NXaYSrf9maWN1CB5gQ2E6B6Zk586sTZ2nnnJNol2KTkM1BPCTSMkrdiLtpjkUEGeo1tTe87oUzFHx++rgSO\\\\/lM94Dw5oN2jifGQquDBgIHY5ovxXVN3xrTgfrLEx+HWxdsiuIYpPx4lu4Qe0CVgZwPMqR4=\\\"}\",\n    \"object\": \"https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820\"\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"1e0f4ed490473423292524d96f3d13f7fb1425599dfadd614b174ad77eb77019\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T21:41:48Z\",\n    \"signatureValue\": \"PAA2RTSvWKABq84fxlbvR1UZbIvDhoECyP5rSr5mP2nGFMo+BBhOq4Enq7SO/fiNOctILaLP4cdExyHZdYs7J64jCdfScuz6h2WZIlnKGEsR7mfDeUANboXqRbTKoyisA8vS3+BSSi4T2gjiyF3GGJLxUEcpOpD7T6G2BHQCQGbDSfue71Pygs6Z2RjCdLG1NiT6basjCKamrwxC+UYqzN3mCYLqpzBB3YD8/ql+1uqPPo3TI8CyQqq8ThEzYvXOI1eJcn0H9itD3WForGs9EQ/P39YGqrT40kx+mzhMBl16BnSO9sFHwJGd+Udi0DPrwlbCdJTuTEXJyvt6VRsyXYXe50aci+msm7MS2F+WaZZpbkxCbQNkJYfF2+yV/bDmhbvexT3avObytKGZURR+jo1UCRTwD5x4LZU/8Bvg6epsYmIXqLuuifbsrELpk+zhoHZD2drbRLWJM9KGHIK2EYtQlfvk7bDCQ/ukRns9G74JZYykqLxGhLFqd51JW2yUohmv4YoEFStXVCpInQGVQigxxO7qoCTbNiFhO7mTpd0gdx2kR4g83QJpPq6ZPaqag7z+zf3IPxA9WsZgfS66CAl6lqOK5jYkLr7JOejOU7oguHUfF6P89F2MDRoBTp6wVFL1z+rTGozyPr5mpgAAN1ambv/3ouUJdJ0Q9c6vvcE=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/activities/update_note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210/Update\",\n  \"type\": \"Update\",\n  \"actor\": \"https://pirati.ca/profile/heluecht\",\n  \"published\": \"2022-03-24T07:22:36Z\",\n  \"instrument\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.05-dev-1453\",\n    \"url\": \"https://pirati.ca\"\n  },\n  \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ],\n  \"object\": {\n    \"id\": \"https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210\",\n    \"type\": \"Note\",\n    \"summary\": \"\",\n    \"inReplyTo\": \"https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820\",\n    \"diaspora:guid\": \"ec054ce7-4762-3c1c-3c25-0cc665717210\",\n    \"published\": \"2022-03-24T07:22:36Z\",\n    \"updated\": \"2022-03-24T21:37:34Z\",\n    \"url\": \"https://pirati.ca/display/ec054ce7-4762-3c1c-3c25-0cc665717210\",\n    \"attributedTo\": \"https://pirati.ca/profile/heluecht\",\n    \"sensitive\": false,\n    \"context\": \"https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820#context\",\n    \"content\": \"<span class=\\\"h-card\\\"><a href=\\\"https://pirati.ca/profile/test8\\\" class=\\\"u-url mention\\\">@<span>test8</span></a></span> This is an edited comment.\",\n    \"contentMap\": {\n      \"de\": \"This is an edited comment.\"\n    },\n    \"source\": {\n      \"content\": \"This is an edited comment.\",\n      \"mediaType\": \"text/bbcode\"\n    },\n    \"diaspora:comment\": \"{\\\"author\\\":\\\"heluecht@pirati.ca\\\",\\\"guid\\\":\\\"ec054ce7-4762-3c1c-3c25-0cc665717210\\\",\\\"created_at\\\":\\\"2022-03-24T07:22:36Z\\\",\\\"edited_at\\\":\\\"2022-03-24T07:22:36Z\\\",\\\"parent_guid\\\":\\\"ec054ce7-2062-3bfa-8687-ca8313624820\\\",\\\"text\\\":\\\"This is a comment.\\\",\\\"author_signature\\\":\\\"oqthcfSIjETYRshGeN0Zq9yGJ9+bbghdzMH4Vfl\\\\/kxDyNQe7tsvK6M5cQlM46h2+jmpK2Okb4mK7K6Yenh+6aH2sJKIyMUdKIINzhp9Gav31sUtHf4\\\\/A0x1aqqTp1oLvnc5uKdKdIGaSdODUZY\\\\/ABmDjin5sE1gjIBlAkAlhvdhy\\\\/k+4c3UCFtazjawb1oXbh94uSgu4DxseBec4Kn5laWNwLhZLdx9PMSN1mhNqz2rnF6gWAlrlaLLeRDawh2AS5t2TUPH92QY818DW9b0rF9Gz4w1PtEIkzXDd6u\\\\/VEMMrwmRtd8SSDgnDPFzH4HqZDf1Y4TnQixZIqgUyv9zsiNT0pg0vOXTkuQ7hJ7hj6BI92SISTtQnEVhZBmW+i22roFs87EbSb5e6Yy4+2YphjCUd2NWlyrtG1UTR1hzCN+kzKIQU34zgXTtnwvYhi6wz71Lh3w1VoQbLthxpG1t1WRsXQ\\\\/QZNUNInyHyIgzWTcWAS6MdzVnmXSV+1080PQ5zFWbR6Tft3YySyk8iIyhdhTAjfEDGTRGHciiPtLBPQFlHMPTiMZjEWFnBZuhDhOrA6OazONXHRO09Xr6S\\\\/+ZudMvEOAG1FvcBec6gWRZdcma6UBi+2M3ay3dYFJw4+fG4XZh3H\\\\/sCA\\\\/q8MUgreP3t9q\\\\/wzxCW\\\\/BXZAv4u2FvwKvw=\\\"}\",\n    \"attachment\": [],\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://pirati.ca/profile/test8\",\n        \"name\": \"@test8@pirati.ca\"\n      },\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://ds9.lemmy.ml/c/testcom\",\n        \"name\": \"@testcom@ds9.lemmy.ml\"\n      }\n    ],\n    \"to\": [\"https://pirati.ca/profile/test8\", \"https://ds9.lemmy.ml/c/testcom\"],\n    \"cc\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://ds9.lemmy.ml/c/testcom/followers\"\n    ]\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"nonce\": \"534f3e33435fa56911a12094d9918002b2d734794609019793603116b6509a54\",\n    \"creator\": \"https://pirati.ca/profile/heluecht#main-key\",\n    \"created\": \"2022-03-24T21:37:50Z\",\n    \"signatureValue\": \"lhLotVmAv22CmiYCSmQgQA/X38ype7o89iJIC0I2FzIWGQkvZz58YAxpmW047Z2hT2qm15sV02bPgyoOBXAdXd+M8WNwz+cNwU1cE7QNZ7102Y+tQRgpTfHz+e57QwUXESo46xAG0qSVu0UQMm+3uCUFYWKgHEmAXy89sp//3J/vJtI2+3jbaC9YWdsBe8XwcoHeelnX7f8LNniRnZIkKTLfoOhcEIHAJkEV4otSCOfzwGHN0SqbGlK9xWBPQhgtN3GvnOZU9zhNQMsQiX+9Wb2X4NLXs0tTRkDubF78stH+0xbep7ZfyvRNLoebPtecN8dMnfHQs8y0a9iG9tuNjcwht2ezIwf1h140+iB8znav35sA6LzgcfEyzU8O6JYF9p9x3tCw2rcMMiy/f7mvOLCP/05d0GEoUNZXrfuXf/osysMYp1Y05Lkze5WqTMu7sEW6jDx8r1NTE6s88wqZAJa56G5NVxG5vU3Cj7yscI6LQiqaUDilVHLa+DzR0pQEUSSh2J1PgBFT2KKPZIY22UinDgI1QNl+Dhfj2nPzf/xXssuDTvWyU8vJzXc2MZNqFz7ds7tNdea6laLiMl7nOnMo1wz1f+w1bn4Y1YR/iwFgaqo2WEt+cIzAaN4dUw00WlXPCNNhrgaXHxlXI6SeFtviUfwz/dehcfbxMTxX7e0=\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/note_1.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://soc.schuerz.at/objects/4edd2508-4361-edb8-c4d8-b45181083984\",\n  \"type\": \"Note\",\n  \"summary\": \"\",\n  \"inReplyTo\": \"https://lemmy.schuerz.at/post/25360\",\n  \"diaspora:guid\": \"4edd2508-4361-edb8-c4d8-b45181083984\",\n  \"published\": \"2022-01-23T20:21:24Z\",\n  \"url\": \"https://soc.schuerz.at/display/4edd2508-4361-edb8-c4d8-b45181083984\",\n  \"attributedTo\": \"https://soc.schuerz.at/profile/jakob\",\n  \"sensitive\": false,\n  \"context\": \"https://lemmy.schuerz.at/post/25360#context\",\n  \"content\": \"<span class=\\\"h-card\\\"><a href=\\\"https://lemmy.schuerz.at/u/jakob\\\" class=\\\"u-url mention\\\">@<span>jakob</span></a></span> test\",\n  \"contentMap\": {\n    \"de\": \"<bdi>@<a href=\\\"https://lemmy.schuerz.at/u/jakob\\\" class=\\\"userinfo mention\\\" title=\\\"jakob\\\">jakob</a></bdi> test\"\n  },\n  \"source\": {\n    \"content\": \"@[url=https://lemmy.schuerz.at/u/jakob]Jakob[/url] test\",\n    \"mediaType\": \"text/bbcode\"\n  },\n  \"diaspora:comment\": \"{\\\"author\\\":\\\"jakob@soc.schuerz.at\\\",\\\"guid\\\":\\\"4edd2508-4361-edb8-c4d8-b45181083984\\\",\\\"created_at\\\":\\\"2022-01-23T20:21:24Z\\\",\\\"edited_at\\\":\\\"2022-01-23T20:21:24Z\\\",\\\"parent_guid\\\":\\\"ea620d1e-742c8b4d15249a9b-18b5fca3\\\",\\\"text\\\":\\\"@{Jakob; jakob@lemmy.schuerz.at} test\\\",\\\"author_signature\\\":\\\"JNCqOui5Cg8\\\\/Uxw+f0NtGCRjRnhPOrqE6kGJnMkZvOOKhlCdZbCqvyPlNJzEYDa3Z30mOWQKTTNo5BVI+VVZtGrVEqFOdzNog7jOLQoY1dKU9iEQ9vc8USwUCkyJyv48w1iXpfea87KPwv+03DMlftmD6kC7jdUVwhc7+jm0g4fh06tpOcCMQJOZqTTV\\\\/80EjxIJQ+8eEk5evSw\\\\/S98ohD1ahcwSomJ9hJUV1H48ucDvMod1FCLcN5h4ALHqubCu4TZIYhGhw9zoCl52GeHhrD3\\\\/vL6OW4ftZ7UG4rEKQ4HowuXqmNwydrQldtprRtu2UrZBjLqVusPXEs\\\\/xERQqZnalNXHijyd1TwwCmfTV4YjKwH4BhX\\\\/p4hdWMqEP4yYXlfA4apalVeAaYZLrNR58kPJjBHad\\\\/yqH30ziBFheqZ5odFh\\\\/jnKB4OCFVST3u9b1OKE0jyTrbTepPTaONwc8giQH1sM8koj1gFdulwuJuOTRUKR\\\\/8ishgHi5SWwbp5YG5Z3YSINkF10IcLiFZAF300AvwgOCdf7ferim4i\\\\/7TR1D2CBpoNUZnKCKZRymZbE0GuKEE+A6Pk3lk\\\\/DCsDtmMXpnxlPZ8Nq8OZS\\\\/olXevAu1y57MNnxBDXtojr4F54MP2fO7E2JwBr7AlwoeSEvtZSAO\\\\/elzrKfW0eVWOUM2OnI=\\\"}\",\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://lemmy.schuerz.at/u/jakob\",\n      \"name\": \"@jakob@lemmy.schuerz.at\"\n    }\n  ],\n  \"to\": [\n    \"https://lemmy.schuerz.at/u/jakob\",\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://lemmy.schuerz.at/c/test\"\n  ],\n  \"cc\": [\"https://soc.schuerz.at/followers/jakob\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/note_2.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://nerdica.net/objects/a85d7459-7262-66e9-f901-f05552414769\",\n  \"type\": \"Note\",\n  \"summary\": \"\",\n  \"inReplyTo\": \"https://lemmy.ml/comment/167904\",\n  \"diaspora:guid\": \"a85d7459-7262-66e9-f901-f05552414769\",\n  \"published\": \"2022-04-25T18:35:37Z\",\n  \"url\": \"https://nerdica.net/display/a85d7459-7262-66e9-f901-f05552414769\",\n  \"attributedTo\": \"https://nerdica.net/profile/liwott\",\n  \"sensitive\": false,\n  \"context\": \"https://lemmy.ml/post/243881#context\",\n  \"content\": \"Note that on <a href=\\\"https://nerdica.net/search?tag=Friendica\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>Friendica</span></a> we canquote-share, and we can also do it in comments. As I discovered recently by playing in the below post<br><div><a href=\\\"https://lemmy.ml/post/241819\\\">♲</a> @<span class=\\\"vcard\\\"><a href=\\\"https://lemmy.ml/u/Liwott\\\" class=\\\"url u-url mention\\\" title=\\\"Liwott@lemmy.ml\\\"><span class=\\\"fn nickname mention\\\">Liwott@lemmy.ml</span></a>:</span><blockquote><h3>Do your commenting tests here.</h3><br>While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.</blockquote></div>\\nthese make it through to <a href=\\\"https://nerdica.net/search?tag=Lemmy\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>Lemmy</span></a> when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missing is the backlink !<blockquote>Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.</blockquote>This seems interesting, but I must say I don't directly see an application of this in the context of microblogging/commenting. Maybe you can inspire us here? 😀\",\n  \"contentMap\": {\n    \"en\": \"Note that on #<a href=\\\"https://nerdica.net/search?tag=Friendica\\\" class=\\\"tag\\\" rel=\\\"tag\\\" title=\\\"Friendica\\\">Friendica</a> we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post<br>\\n<div class=\\\"shared-wrapper well well-sm\\\">\\n\\t<div class=\\\"shared_header\\\">\\n\\t\\t\\t\\t\\t<a href=\\\"https://lemmy.ml/u/Liwott\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"avatar shared-userinfo\\\">\\n\\t\\t\\t\\t<img src=\\\"https://nerdica.net/photo/contact/80/44b525e5e979775f3ab2722747f7f07704134945?ts=1644399345\\\" alt=\\\"\\\">\\n\\t\\t\\t</a>\\n\\t\\t\\t\\t<div class=\\\"metadata\\\">\\n\\t\\t\\t<p class=\\\"shared-author\\\">\\n\\t\\t\\t\\t<a href=\\\"https://lemmy.ml/u/Liwott\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"shared-wall-item-name\\\">\\n\\t\\t\\t\\t\\tLiwott\\n\\t\\t\\t\\t</a>\\n\\t\\t\\t</p>\\n\\t\\t\\t<p class=\\\"shared-wall-item-ago\\\">\\n\\t\\t\\t\\t\\t\\t\\t\\t<a href=\\\"/display/44b525e5-4101b003e005e70a-d472d963\\\">\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t<span class=\\\"shared-time\\\">2022-04-23 14:34:19</span>\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t</a>\\n\\t\\t\\t\\t\\t\\t\\t</p>\\n\\t\\t</div>\\n\\t\\t<div class=\\\"preferences\\\">\\n\\t\\t\\t\\t\\t\\t\\t<span class=\\\"wall-item-network\\\"><i class=\\\"fa fa-activitypub\\\" title=\\\"lemmy (AP)\\\"></i></span>\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t<a href=\\\"https://lemmy.ml/post/241819\\\" class=\\\"plink u-url\\\" title=\\\"Link to source\\\">\\n\\t\\t\\t\\t\\t<i class=\\\"fa fa-external-link\\\"></i>\\n\\t\\t\\t\\t</a>\\n\\t\\t\\t\\t\\t</div>\\n\\t</div>\\n\\t<blockquote class=\\\"shared_content\\\" dir=\\\"auto\\\"><br class=\\\"top-anchor\\\"><h3>Do your commenting tests here.</h3><br>While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.<br class=\\\"button-anchor\\\"></blockquote>\\n</div>\\nthese make it through to #<a href=\\\"https://nerdica.net/search?tag=Lemmy\\\" class=\\\"tag\\\" rel=\\\"tag\\\" title=\\\"Lemmy\\\">Lemmy</a> when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missing is the backlink !<blockquote>Also the Linked Data nature of the underlying data would make it possible to createall different kinds of associations, not just a plain cross-ref link.</blockquote>This seems interesting, but I must say I don't directly see an application of this in the context of microblogging/commenting. Maybe you can inspire us here? 😀\"\n  },\n  \"source\": {\n    \"content\": \"Note that on #[url=https://nerdica.net/search?tag=Friendica]Friendica[/url] we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post\\n[share author='Liwott' profile='https://lemmy.ml/u/Liwott' avatar='' link='https://lemmy.ml/post/241819' posted='2022-04-23 14:34:19' guid='44b525e5-4101b003e005e70a-d472d963'][h3]Do your commenting tests here.[/h3]\\nWhile posting tests can obsviously bemade on this community without further precision, commenting requires a post to comment on. This is what this post is for.[/share]\\nthese make it through to #[url=https://nerdica.net/search?tag=Lemmy]Lemmy[/url] when we do it in a comment, but not in a top-level post (which is already a great start !).So, in Friendica, all that's missing is the backlink !\\n[quote]Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.[/quote]\\nThis seems interesting, but I must say I don't directly see an application ofthis in the context of microblogging/commenting. Maybe you can inspire us here? :)\",\n    \"mediaType\": \"text/bbcode\"\n  },\n  \"diaspora:comment\": \"{\\\"author\\\":\\\"liwott@nerdica.net\\\",\\\"guid\\\":\\\"a85d7459-7262-66e9-f901-f05552414769\\\",\\\"created_at\\\":\\\"2022-04-25T18:35:37Z\\\",\\\"edited_at\\\":\\\"2022-04-25T18:35:37Z\\\",\\\"parent_guid\\\":\\\"44b525e5-532e8d03ea8f5dff-c45ad734\\\",\\\"text\\\":\\\"Note that on #Friendica we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post\\\\n\\\\n- - - - - -\\\\n\\\\n**\\\\u2672 [Liwott](https:\\\\/\\\\/lemmy.ml\\\\/u\\\\/Liwott)** - [2022-04-23 14:34:19 GMT](https:\\\\/\\\\/lemmy.ml\\\\/post\\\\/241819)\\\\n\\\\n> ### Do your commenting tests here.\\\\n> \\\\n> \\\\n> While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.\\\\n\\\\nthese make it through to #Lemmy when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missingis the backlink !> Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.\\\\n\\\\nThis seems interesting, but I must say I don't directly see an application of this in the context of microblogging\\\\/commenting. Maybe you can inspire us here? \\\\ud83d\\\\ude00\\\",\\\"author_signature\\\":\\\"Ho9NYtWzEkREWyvyjUnUOuYvPBI35I4SGAb+cXBMp\\\\/n2Tu5gJipmKuIcMpyrxYNtIqXRwr\\\\/BUOGkd99s5\\\\/uBWCcL8jCbx3i4wTVYzdgPAZaykd7EqdwULNRTtf8eKL2Wvdo7tYtYm\\\\/Yo5dajM5HI2NuOgQR8CgLInmEmBlKLZ8EkzAC+z2EwMhx7JBmKzeEabAmclJgR8IfYWX34KPYqBFcZ9w8V\\\\/D3lcPGs3olJcvwqHSnY7vgL1X9f2XVAQ38pmGg2ggaKhKa5QligOhkPC57NYPh\\\\/1SR9Plpyf0QPQRCuCs5vkEloe47rxaWZ62gfKqul0dXmGchIcIYhms4DN7DaGapOXaQPuIfh4FIvEb9qh9mJ7haHa9+0uD9TUToG+wilifdtGwZoZnF9zMfGSLiaDlD\\\\/UZHA1jXMa3uhfGE+MUT1dnJcZqfE+jwJUb4BPuYxTm7UClvERg8sfDFWqlMaNpPtJlay2PL\\\\/nwCxuQ54M5v6lgyb8NylIrjFyUttiBnNC6HYsy4YoPnN7r\\\\/0EV3Av1KtnJt84xrJbDo4fvR1TPs4Hmx5BoH1cvHCH2Tld2OgKUCHd5g9Pr3RVPEGGillZSqDWCP6317BQ0EDftTwABjPXoitQX2ZaHXXqXLWCRoLk6MsEWM0jsoGzv+GP4coZWreCD1XRI5an1W4998=\\\",\\\"thread_parent_guid\\\":\\\"44b525e5-cb6ed8649810a557-1ccd7106\\\"}\",\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://nerdica.net/search?tag=Friendica\",\n      \"name\": \"#Friendica\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://nerdica.net/search?tag=Lemmy\",\n      \"name\": \"#Lemmy\"\n    },\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://lemmy.ml/u/Liwott\",\n      \"name\": \"@Liwott@lemmy.ml\"\n    }\n  ],\n  \"to\": [\n    \"https://lemmy.ml/u/humanetech\",\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://lemmy.ml/c/fediversefutures\"\n  ],\n  \"cc\": [\n    \"https://lemmy.ml/u/Liwott\",\n    \"https://nerdica.net/followers/liwott\",\n    \"https://lemmy.ml/u/poVoq\",\n    \"https://mastodon.social/users/humanetech\",\n    \"https://lemmy.ml/u/KelsonV\"\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/page_1.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-8062-3c1b-016c-910426317080\",\n  \"type\": \"Page\",\n  \"summary\": \"\",\n  \"inReplyTo\": null,\n  \"diaspora:guid\": \"ec054ce7-8062-3c1b-016c-910426317080\",\n  \"published\": \"2022-03-24T07:17:21Z\",\n  \"url\": \"https://www.nasaspaceflight.com/2022/03/us-eva-80/\",\n  \"attributedTo\": \"https://pirati.ca/profile/heluecht\",\n  \"sensitive\": false,\n  \"context\": \"https://pirati.ca/objects/ec054ce7-8062-3c1b-016c-910426317080#context\",\n  \"name\": \"ISS astronauts perform final spacewalk of Expedition 66\",\n  \"content\": \"Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.\",\n  \"contentMap\": {\n    \"de\": \"<bdi>!<a href=\\\"https://ds9.lemmy.ml/c/testcom\\\" class=\\\"userinfo mention\\\" title=\\\"testcom\\\">testcom</a></bdi> Expedition 66 astronauts RajaChari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.<br><a href=\\\"https://www.nasaspaceflight.com/2022/03/us-eva-80/\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">ISS astronauts perform final spacewalk of Expedition 66</a>\"\n  },\n  \"source\": {\n    \"content\": \"![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.\\n[attachment type='link' url='https://www.nasaspaceflight.com/2022/03/us-eva-80/' title='ISS astronauts perform final spacewalk of Expedition 66' publisher_name='NASASpaceFlight.com' publisher_url='https://www.nasaspaceflight.com/' publisher_img='https://www.nasaspaceflight.com/wp-content/uploads/2017/12/logo.svg' author_name='Justin Davenport' author_url='https://www.nasaspaceflight.com/author/justin/' author_img='https://secure.gravatar.com/avatar/5dc0dc04b38dbb016bf6f15552555883?s=96&amp;d=mm&amp;r=g' image='https://www.nasaspaceflight.com/wp-content/uploads/2022/03/51941297402_fa7a00c1ee_o-scaled.jpg']Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on…[/attachment]\",\n    \"mediaType\": \"text/bbcode\"\n  },\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://ds9.lemmy.ml/c/testcom\",\n      \"name\": \"@testcom@ds9.lemmy.ml\"\n    }\n  ],\n  \"to\": [\"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/page_2.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850\",\n  \"type\": \"Article\",\n  \"summary\": \"\",\n  \"inReplyTo\": null,\n  \"diaspora:guid\": \"ec054ce7-5162-3bf2-504c-16d024994850\",\n  \"published\": \"2022-03-24T04:23:44Z\",\n  \"url\": \"https://pirati.ca/display/ec054ce7-5162-3bf2-504c-16d024994850\",\n  \"attributedTo\": \"https://pirati.ca/profile/heluecht\",\n  \"sensitive\": false,\n  \"context\": \"https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850#context\",\n  \"name\": \"From Friendica to Lemmy\",\n  \"content\": \"Hello Lemmy!\",\n  \"contentMap\": {\n    \"de\": \"<bdi>!<a href=\\\"https://ds9.lemmy.ml/c/testcom\\\" class=\\\"userinfo mention\\\" title=\\\"testcom\\\">testcom</a></bdi> Hello Lemmy!\"\n  },\n  \"source\": {\n    \"content\": \"![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Hello Lemmy!\",\n    \"mediaType\": \"text/bbcode\"\n  },\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://ds9.lemmy.ml/c/testcom\",\n      \"name\": \"@testcom@ds9.lemmy.ml\"\n    }\n  ],\n  \"to\": [\"https://ds9.lemmy.ml/c/testcom\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://ds9.lemmy.ml/c/testcom/followers\"\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/person_1.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://soc.schuerz.at/profile/jakob\",\n  \"diaspora:guid\": \"4edd2508-1661-30f6-ebcc-2da966353356\",\n  \"type\": \"Person\",\n  \"following\": \"https://soc.schuerz.at/following/jakob\",\n  \"followers\": \"https://soc.schuerz.at/followers/jakob\",\n  \"inbox\": \"https://soc.schuerz.at/inbox/jakob\",\n  \"outbox\": \"https://soc.schuerz.at/outbox/jakob\",\n  \"preferredUsername\": \"jakob\",\n  \"name\": \"Jakob :friendica:\",\n  \"vcard:hasAddress\": {\n    \"@type\": \"vcard:Home\",\n    \"vcard:country-name\": \"Austria\",\n    \"vcard:region\": \"Niederoesterreich\",\n    \"vcard:locality\": \"\"\n  },\n  \"summary\": \"Linux, FOSS, Öffentlicher Verkehr, Eisenbahn, Radfahren, Fußgehen, Verkehrsplanung, Städtebau, Will das Schöne wieder in die Welt bringen,Nachhaltigkeit, Modellbahn, Java Entwickler (jun), Bash,<br><br>#FediverseOnlyAccount\",\n  \"vcard:hasInstantMessage\": [\n    \"xmpp:jakob@schuerz.at\",\n    \"matrix:@jakob:schuerz.at\"\n  ],\n  \"url\": \"https://soc.schuerz.at/profile/jakob\",\n  \"manuallyApprovesFollowers\": true,\n  \"discoverable\": true,\n  \"publicKey\": {\n    \"id\": \"https://soc.schuerz.at/profile/jakob#main-key\",\n    \"owner\": \"https://soc.schuerz.at/profile/jakob\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1RRoj3DpUmTiRBshv+kz\\njO5tgfHs99aBJjvaoW8nbPcOs+HZm9Nj4ncJh99kwd+yONwac6ObMMIisYpVU4C1\\neKpnlRrRu/8vQFwhHQT4RxpkibB+l+LvG1HJoMNIuYxvVCIaQZugdJclAdMJjDTF\\nbDQNwG6xlcazKd4IbMbmgfoxTxSnQSomJQew1NUbdD3vDiCdJEtjCmeWm6eTCfyZ\\njT0mjrAm8ccJ7+opN5SWJ0je0Rav5dohyaVFEtv1Dlv1UlqU4hKefvv71eoROHCA\\nWQ3+kYGFGY4ApnbWxwLZyke7khzxr2BjDrfwUAeEsLJT4YOxa5fKJJ59+q5Iddaq\\nPNT3QqP0Qzum5w6qDOWm3cNNw7ByqoqxKckZS5U2vm0sx83UEmBqysAkAS/8M9Qr\\nBKkb9DQ9jgUa7GPpL+Oknr8hV+Vpk49Jjx+A1WJ/MlNja7fi4w4rBM+v3B8nRayM\\nzX8XaKbbOib21mCawJiJIOAm0EP2rNqNM1GpUWPstHKG00o3Czz3P5Hm/q6RcNJE\\nKRlSIPQZnUVsoC0bFsqWzipsgb3uDHnz3Ni2OjLNLWBVYkWD7RNfB3WV/XKl2QL3\\nnnhmUDahGN7UCOrcBuLfWsTa+GZDFeHot1HXa9tNcxq+QxAUg3qv7oiAH1H+hoJg\\nn/Ydg1IR5sLovKi3g7DRS7MCAwEAAQ==\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"endpoints\": {\n    \"sharedInbox\": \"https://soc.schuerz.at/inbox\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://soc.schuerz.at/photo/profile/jakob.png?ts=1630598950\",\n    \"mediaType\": \"image/png\"\n  },\n  \"attachment\": [\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Mobilizon\",\n      \"value\": \"@jakob@events.schuerz.at<br>@jakob@events.tulln.social\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Lemmy\",\n      \"value\": \"<a href=\\\"https://lemmy.schuerz.at/u/jakob\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://lemmy.schuerz.at/u/jakob</a>\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Funkwhale\",\n      \"value\": \"<a href=\\\"https://radio.schuerz.at/@jakob/\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://radio.schuerz.at/@jakob/</a>\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Peertube\",\n      \"value\": \"<a href=\\\"https://kino.schuerz.at/a/jakob\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://kino.schuerz.at/a/jakob</a>\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Pixelfed\",\n      \"value\": \"<a href=\\\"https://japix.schuerz.at/jakob\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://japix.schuerz.at/jakob</a>\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"about:\",\n      \"value\": \"This is an OpenPGP proof that connects my OpenPGP key to this Peertube account. For details check out <a href=\\\"https://keyoxide.org/guides/openpgp-proofs\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://keyoxide.org/guides/openpgp-proofs</a><br><br>[Verifying my OpenPGP key: openpgp4fpr:FED82F1C73FF53FB1EE9926336615E0FD12833CF]\"\n    }\n  ],\n  \"generator\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2021.12-rc-1448\",\n    \"url\": \"https://soc.schuerz.at\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/friendica/objects/person_2.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\",\n      \"dfrn\": \"http://purl.org/macgirvin/dfrn/1.0/\",\n      \"diaspora\": \"https://diasporafoundation.org/ns/\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"schema\": \"http://schema.org#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"sensitive\": \"as:sensitive\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"directMessage\": \"litepub:directMessage\",\n      \"discoverable\": \"toot:discoverable\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\"\n    }\n  ],\n  \"id\": \"https://poliverso.org/profile/informapirata\",\n  \"diaspora:guid\": \"0477a01e-8161-2935-9a73-393807834700\",\n  \"type\": \"Organization\",\n  \"following\": \"https://poliverso.org/following/informapirata\",\n  \"followers\": \"https://poliverso.org/followers/informapirata\",\n  \"inbox\": \"https://poliverso.org/inbox/informapirata\",\n  \"outbox\": \"https://poliverso.org/outbox/informapirata\",\n  \"preferredUsername\": \"informapirata\",\n  \"name\": \"Informa Pirata\",\n  \"vcard:hasAddress\": {\n    \"@type\": \"vcard:Home\",\n    \"vcard:country-name\": \"Italy\",\n    \"vcard:region\": \"Lazio\",\n    \"vcard:locality\": \"\"\n  },\n  \"summary\": \"Politica Pirata: informazione su #whistleblowing #dirittidigitali #sovranitàdigitale #copyright #privacy #cyberwarfare #pirati #Europa #opensource #opendata <br>➡️➡️ http://T.ME/PPINFORMA\",\n  \"url\": \"https://poliverso.org/profile/informapirata\",\n  \"manuallyApprovesFollowers\": false,\n  \"discoverable\": true,\n  \"publicKey\": {\n    \"id\": \"https://poliverso.org/profile/informapirata#main-key\",\n    \"owner\": \"https://poliverso.org/profile/informapirata\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA09bPyftct7KNf4hm+rmG\\n1aX4HmJbAdiXkmxo3g7iJG21Vvd3+OQeKnjpLc0n9s9rKMrpy8FQj+E7FaVYIGcP\\n/f8McmSb4ezFeNkVNKGm+Y7swpeAAmh0MBWfDD+j3WHznD+OLABhWZlnfhIxW1aD\\nD6mN9mkITvLAut8vJVTaciGzjfv6AndHVerVPV8lw5gXCmvX/+NZUOjjLQVND3fL\\n8fZiJjJ3NSQ1tAx0m38PVMHZGw2492gkbKxzkW6c/QyMRAOrKP2+kJQ/6O2sn/ZK\\n7MtHzMQ4eUjGc0ZLWQlCqQ4oVbVTcPgwHW1+no3928fzhU95zi5oAI08wfJ5wo86\\nAnPv4fnUL/gyGff/ytZ/kGhNv+jVlSbMYxiDslRoD2Zp+L1P5Ypw6iemR1rMivL4\\nJMxx2FoYGD1xzKBqNcJ2cDRQ5VQGwhBs/U6XyRMrRTzhDoe5dHr49MjHGuYkUzhq\\naYPgku+zA7hfjvZA982kK2jAMXPoTLoUrY7T6beanYwfFIxd++fNHxTSexrhwx7P\\nqn7v+pi0WTA8Cxor4N+ICCXxVvpO7s5VERVugiJofKZhFXiE2S02S2jVoGCRtEKw\\n9/iignMld/IQSojz8N+77KMYGuVT9eG9Io/mF4MjCLluNNRXklt55dz55vOHPBxg\\nll83LwyA3eELfylUNV75DcsCAwEAAQ==\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"endpoints\": {\n    \"sharedInbox\": \"https://poliverso.org/inbox\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://poliverso.org/photo/profile/informapirata.png?ts=1630486460\",\n    \"mediaType\": \"image/png\"\n  },\n  \"attachment\": [\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Telegram\",\n      \"value\": \"http://T.ME/PPINFORMA\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Mastodon\",\n      \"value\": \"<a href=\\\"https://mastodon.uno/@informapirata\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">https://mastodon.uno/@informapirata</a>\"\n    }\n  ],\n  \"generator\": {\n    \"type\": \"Service\",\n    \"name\": \"Friendica 'Siberian Iris' 2022.03-1452\",\n    \"url\": \"https://poliverso.org\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/activities/create_note.json",
    "content": "{\n  \"type\": \"Create\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/activity/1339\",\n  \"published\": \"2022-03-01T20:58:48+00:00\",\n  \"actor\": \"https://instance.gnusocial.test/actor/42\",\n  \"object\": {\n    \"type\": \"Note\",\n    \"id\": \"https://instance.gnusocial.test/object/note/1339\",\n    \"published\": \"2022-03-01T21:00:16+00:00\",\n    \"attributedTo\": \"https://instance.gnusocial.test/actor/42\",\n    \"content\": \"<p>yay ^^</p>\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"yay ^^\",\n      \"mediaType\": \"text/plain\"\n    },\n    \"attachment\": [],\n    \"tag\": [],\n    \"inReplyTo\": \"https://instance.gnusocial.test/object/note/1338\",\n    \"inConversation\": \"https://instance.gnusocial.test/conversation/1338\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\"https://instance.gnusocial.test/actor/42/subscribers\"]\n  },\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://instance.gnusocial.test/actor/42/subscribers\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/activities/create_page.json",
    "content": "{\n  \"type\": \"Create\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/activity/1338\",\n  \"published\": \"2022-03-17T23:30:26+00:00\",\n  \"actor\": \"https://instance.gnusocial.test/actor/42\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://instance.gnusocial.test/actor/21\"],\n  \"object\": {\n    \"type\": \"Page\",\n    \"id\": \"https://instance.gnusocial.test/object/note/1338\",\n    \"published\": \"2022-03-17T23:30:26+00:00\",\n    \"attributedTo\": \"https://instance.gnusocial.test/actor/42\",\n    \"name\": \"hello, world.\",\n    \"content\": \"<p>This is an interesting page.</p>\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"This is an interesting page.\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"attachment\": [],\n    \"tag\": [],\n    \"inConversation\": \"https://instance.gnusocial.test/conversation/1338\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\"https://instance.gnusocial.test/actor/21\"]\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/activities/like_note.json",
    "content": "{\n  \"type\": \"Like\",\n  \"@context\": [\"https://www.w3.org/ns/activitystreams\"],\n  \"id\": \"https://another_instance.gnusocial.test/activity/41362\",\n  \"published\": \"2022-03-20T17:54:15+00:00\",\n  \"actor\": \"https://another_instance.gnusocial.test/actor/43\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://instance.gnusocial.test/actor/42\"],\n  \"object\": \"https://instance.gnusocial.test/object/note/1337\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/objects/group.json",
    "content": "{\n  \"type\": \"Group\",\n  \"streams\": [],\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/actor/21\",\n  \"inbox\": \"https://instance.gnusocial.test/actor/21/inbox.json\",\n  \"outbox\": \"https://instance.gnusocial.test/actor/21/outbox.json\",\n  \"following\": \"https://instance.gnusocial.test/actor/21/subscriptions\",\n  \"followers\": \"https://instance.gnusocial.test/actor/21/subscribers\",\n  \"liked\": \"https://instance.gnusocial.test/actor/21/favourites\",\n  \"preferredUsername\": \"hackers\",\n  \"publicKey\": {\n    \"id\": \"https://instance.gnusocial.test/actor/2#public-key\",\n    \"owner\": \"https://instance.gnusocial.test/actor/2\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoZyKL+GyJbTV/ilVBlzz\\n8OL/UwNi3KpfV5kQwXU0pPcBbw6y2JOfWnKUT1CfiHG3ntiOFnc+wQfHZk4hRSE8\\n9Xe/G5Y215xW+gqx/kjt2GOENqzSzYXdEZ5Qsx6yumZD/yb6VZK9Og0HjX2mpRs9\\nbactY76w4BQVntjZ17gSkMhYcyPFZTAIe7QDkeSPk5lkXfTwtaB3YcJSbQ3+s7La\\npeEgukQDkrLUIP6cxayKrgUl4fhHdpx1Yk4Bzd/1XkZCjeBca94lP1p2M12amI+Z\\nOLSTuLyEiCcku8aN+Ms9plwATmIDaGvKFVk0YVtBHdIJlYXV0yIscab3bqyhsLBK\\njwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"name\": \"Hackers!\",\n  \"published\": \"2022-02-23T21:54:52+00:00\",\n  \"updated\": \"2022-02-23T21:55:16+00:00\",\n  \"url\": \"https://instance.gnusocial.test/!hackers\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://instance.gnusocial.test/inbox.json\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/objects/note.json",
    "content": "{\n  \"type\": \"Note\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    },\n    {\n      \"@language\": \"en\"\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/object/note/1339\",\n  \"published\": \"2022-03-01T21:00:16+00:00\",\n  \"attributedTo\": \"https://instance.gnusocial.test/actor/42\",\n  \"content\": \"<p>yay ^^</p>\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"yay ^^\",\n    \"mediaType\": \"text/plain\"\n  },\n  \"attachment\": [],\n  \"tag\": [],\n  \"inReplyTo\": \"https://instance.gnusocial.test/object/note/1338\",\n  \"inConversation\": \"https://instance.gnusocial.test/conversation/1338\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://instance.gnusocial.test/actor/42/subscribers\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/objects/page.json",
    "content": "{\n  \"type\": \"Page\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/object/note/1338\",\n  \"published\": \"2022-03-17T23:30:26+00:00\",\n  \"attributedTo\": \"https://instance.gnusocial.test/actor/42\",\n  \"name\": \"hello, world.\",\n  \"content\": \"<p>This is an interesting page.</p>\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"This is an interesting page.\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"attachment\": [],\n  \"tag\": [],\n  \"inConversation\": \"https://instance.gnusocial.test/conversation/1338\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://instance.gnusocial.test/actor/21\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/gnusocial/objects/person.json",
    "content": "{\n  \"type\": \"Person\",\n  \"streams\": [],\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"gs\": \"https://www.gnu.org/software/social/ns#\"\n    },\n    {\n      \"litepub\": \"http://litepub.social/ns#\"\n    },\n    {\n      \"chatMessage\": \"litepub:chatMessage\"\n    },\n    {\n      \"inConversation\": {\n        \"@id\": \"gs:inConversation\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"id\": \"https://instance.gnusocial.test/actor/42\",\n  \"inbox\": \"https://instance.gnusocial.test/actor/42/inbox.json\",\n  \"outbox\": \"https://instance.gnusocial.test/actor/42/outbox.json\",\n  \"following\": \"https://instance.gnusocial.test/actor/42/subscriptions\",\n  \"followers\": \"https://instance.gnusocial.test/actor/42/subscribers\",\n  \"liked\": \"https://instance.gnusocial.test/actor/42/favourites\",\n  \"preferredUsername\": \"diogo\",\n  \"publicKey\": {\n    \"id\": \"https://instance.gnusocial.test/actor/42#public-key\",\n    \"owner\": \"https://instance.gnusocial.test/actor/42\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArBB+3ldwA2qC1hQTtIho\\n9KYhvvMlPdydn8dA6OlyIQ3Jy57ADt2e144jDSY5RQ3esmzWm2QqsI8rAsZsAraO\\nl2+855y7Fw35WH4GBc7PJ6MLAEvMk1YWeS/rttXaDzh2i4n/AXkMuxDjS1IBqw2w\\nn0qTz2sdGcBJ+mop6AB9Qt2lseBc5IW040jSnfLEDDIaYgoc5m2yRsjGKItOh3BG\\njGHDb6JB9FySToSMGIt0/tE5k06wfvAxtkxX5dfGeKtciBpC2MGT169iyMIOM8DN\\nFhSl8mowtV1NJQ7nN692USrmNvSJjqe9ugPCDPPvwQ5A6A61Qrgpz5pav/o5Sz69\\nzQIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"name\": \"Diogo Peralta Cordeiro\",\n  \"published\": \"2022-02-23T17:20:30+00:00\",\n  \"updated\": \"2022-02-25T02:12:48+00:00\",\n  \"url\": \"https://instance.gnusocial.test/@diogo\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://instance.gnusocial.test/inbox.json\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/block/block_user.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n  \"target\": \"http://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Block\",\n  \"removeData\": true,\n  \"summary\": \"spam post\",\n  \"endTime\": \"2021-11-01T12:23:50.151874Z\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/block/5d42fffb-0903-4625-86d4-0b39bb344fc2\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/block/undo_block_user.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"object\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n    \"target\": \"http://enterprise.lemmy.ml/c/main\",\n    \"type\": \"Block\",\n    \"removeData\": true,\n    \"summary\": \"spam post\",\n    \"endTime\": \"2021-11-01T12:23:50.151874Z\",\n    \"id\": \"http://enterprise.lemmy.ml/activities/block/726f43ab-bd0e-4ab3-89c8-627e976f553c\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n  \"type\": \"Undo\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/undo/06a20ffb-3e32-42fb-8f4c-674b36d7c557\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/add_featured_post.json",
    "content": "{\n  \"cc\": [\"https://ds9.lemmy.ml/c/main\"],\n  \"id\": \"https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"type\": \"Add\",\n  \"actor\": \"https://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"object\": \"https://ds9.lemmy.ml/post/2\",\n  \"target\": \"https://ds9.lemmy.ml/c/main/featured\",\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/add_mod.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"target\": \"http://enterprise.lemmy.ml/c/main/moderators\",\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n  \"type\": \"Add\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/add/ec069147-77c3-447f-88c8-0ef1df10403f\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/announce_create_page.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/c/main\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"object\": {\n      \"type\": \"Page\",\n      \"id\": \"http://enterprise.lemmy.ml/post/7\",\n      \"attributedTo\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n      \"to\": [\n        \"http://enterprise.lemmy.ml/c/main\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n      ],\n      \"name\": \"post 4\",\n      \"mediaType\": \"text/html\",\n      \"commentsEnabled\": true,\n      \"sensitive\": false,\n      \"stickied\": false,\n      \"published\": \"2021-11-01T12:11:22.871846Z\",\n      \"audience\": \"http://enterprise.lemmy.ml/u/main\"\n    },\n    \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"type\": \"Create\",\n    \"id\": \"http://enterprise.lemmy.ml/activities/create/2807c9ec-3ad8-4859-a9e0-28b59b6e499f\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main/followers\"],\n  \"type\": \"Announce\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/announce/8030b171-803a-4108-94b1-342688f375cf\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/lock_note.json",
    "content": "{\n  \"actor\": \"https://lemmy-alpha/u/lemmy_aplha\",\n  \"to\": [\n    \"https://lemmy-alpha/c/test\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"object\": \"https://lemmy-alpha/comment/1\",\n  \"cc\": [\"https://lemmy-alpha/c/test\"],\n  \"type\": \"Lock\",\n  \"id\": \"https://lemmy-alpha/activities/lock/ae02478a-c7fa-4cc9-9838-eae131d3e9fa\",\n  \"summary\": \"A reason for a lock\",\n  \"audience\": \"http://lemmy-alpha/c/main\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/lock_page.json",
    "content": "{\n  \"id\": \"http://lemmy-alpha:8541/activities/lock/cb48761d-9e8c-42ce-aacb-b4bbe6408db2\",\n  \"actor\": \"http://lemmy-alpha:8541/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://lemmy-alpha:8541/post/2\",\n  \"cc\": [\"http://lemmy-alpha:8541/c/main\"],\n  \"type\": \"Lock\",\n  \"summary\": \"A reason for the lock\",\n  \"audience\": \"http://lemmy-alpha:8541/c/main\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/remove_featured_post.json",
    "content": "{\n  \"cc\": [\"https://ds9.lemmy.ml/c/main\"],\n  \"id\": \"https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"type\": \"Remove\",\n  \"actor\": \"https://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"object\": \"https://ds9.lemmy.ml/post/2\",\n  \"target\": \"https://ds9.lemmy.ml/c/main/featured\",\n  \"audience\": \"https://ds9.lemmy.ml/c/main\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/remove_mod.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"type\": \"Remove\",\n  \"target\": \"http://enterprise.lemmy.ml/c/main/moderators\",\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/remove/aab114f8-cfbd-4935-a5b7-e1a64603650d\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/report_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"http://enterprise.lemmy.ml/u/main\",\n  \"object\": \"http://enterprise.lemmy.ml/post/7\",\n  \"summary\": \"report this post\",\n  \"type\": \"Flag\",\n  \"id\": \"http://ds9.lemmy.ml/activities/flag/98b0933f-5e45-4a95-a15f-e0dc86361ba4\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/resolve_report_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_user\",\n  \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"type\": \"Resolve\",\n  \"id\": \"http://ds9.lemmy.ml/activities/flag/4323412-5e45-4a95-a15f-e0dc86361ba4\",\n  \"audience\": \"http://ds9.lemmy.ml/u/main\",\n  \"object\": {\n    \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"object\": \"http://enterprise.lemmy.ml/post/7\",\n    \"summary\": \"report this post\",\n    \"type\": \"Flag\",\n    \"audience\": \"http://ds9.lemmy.ml/u/main\",\n    \"id\": \"http://ds9.lemmy.ml/activities/flag/98b0933f-5e45-4a95-a15f-e0dc86361ba4\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/undo_lock_note.json",
    "content": "{\n  \"id\": \"https://lemmy-alpha/activities/undo/8c0a65ff-eea6-47cf-9025-6b94a86252ff\",\n  \"actor\": \"https://lemmy-alpha/u/lemmy_aplha\",\n  \"to\": [\n    \"https://lemmy-alpha/c/test\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"object\": {\n    \"actor\": \"https://lemmy-alpha/u/lemmy_aplha\",\n    \"to\": [\n      \"https://lemmy-alpha/c/test\",\n      \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"object\": \"https://lemmy-alpha/comment/1\",\n    \"cc\": [\"https://lemmy-alpha/c/test\"],\n    \"audience\": \"http://lemmy-alpha/c/main\",\n    \"type\": \"Lock\",\n    \"id\": \"https://lemmy-alpha/activities/lock/574b9805-19f5-4349-8c6e-c38c82898df9\"\n  },\n  \"cc\": [\"https://lemmy-alpha/c/test\"],\n  \"audience\": \"http://lemmy-alpha/c/main\",\n  \"type\": \"Undo\",\n  \"summary\": \"A reason for an unlock.\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/undo_lock_page.json",
    "content": "{\n  \"id\": \"http://lemmy-alpha:8541/activities/undo/d6066719-d277-4964-9190-4d6faffac286\",\n  \"actor\": \"http://lemmy-alpha:8541/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"actor\": \"http://lemmy-alpha:8541/u/lemmy_alpha\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"object\": \"http://lemmy-alpha:8541/post/2\",\n    \"cc\": [\"http://lemmy-alpha:8541/c/main\"],\n    \"type\": \"Lock\",\n    \"id\": \"http://lemmy-alpha:8541/activities/lock/08b6fd3e-9ef3-4358-a987-8bb641f3e2c3\",\n    \"audience\": \"http://lemmy-alpha:8541/c/main\"\n  },\n  \"cc\": [\"http://lemmy-alpha:8541/c/main\"],\n  \"type\": \"Undo\",\n  \"audience\": \"http://lemmy-alpha:8541/c/main\",\n  \"summary\": \"A reason for the unlock\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/community/update_community.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"type\": \"Group\",\n    \"id\": \"http://enterprise.lemmy.ml/c/main\",\n    \"preferredUsername\": \"main\",\n    \"name\": \"The Updated Community\",\n    \"summary\": \"<p>updated 2</p>\\n\",\n    \"source\": {\n      \"content\": \"updated 2\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"sensitive\": false,\n    \"postingRestrictedToMods\": false,\n    \"inbox\": \"http://enterprise.lemmy.ml/c/main/inbox\",\n    \"outbox\": \"http://enterprise.lemmy.ml/c/main/outbox\",\n    \"followers\": \"http://enterprise.lemmy.ml/c/main/followers\",\n    \"attributedTo\": \"https://enterprise.lemmy.ml/c/main/moderators\",\n    \"endpoints\": {\n      \"sharedInbox\": \"http://enterprise.lemmy.ml/inbox\"\n    },\n    \"publicKey\": {\n      \"id\": \"http://enterprise.lemmy.ml/c/main#main-key\",\n      \"owner\": \"http://enterprise.lemmy.ml/c/main\",\n      \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA16Xh06V1l2yy0WAIMUTV\\nnvZIuAuKDxzDQUNT+n8gmcVuvBu7tkpbPTQ3DjGB3bQfGC2ekew/yldwOXyZ7ry1\\npbJSYSrCBJrAlPLs/ao3OPTqmcl3vnSWti/hqopEV+Um2t7fwpkCjVrnzVKRSlys\\nihnrth64ZiwAqq2llpaXzWc1SR2URZYSdnry/4d9UNrZVkumIeg1gk9KbCAo4j/O\\njsv/aBjpZcTeLmtMZf6fcrvGre9duJdx6e2Tg/YNcnSnARosqev/UwVTzzGNVWXg\\n9rItaa0a0aea4se4Bn6QXvOBbcq3+OYZMR6a34hh5BTeNG8WbpwmVahS0WFUsv9G\\nswIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n    },\n    \"language\": [\n      {\n        \"identifier\": \"fr\",\n        \"name\": \"Français\"\n      },\n      {\n        \"identifier\": \"de\",\n        \"name\": \"Deutsch\"\n      }\n    ],\n    \"published\": \"2021-10-29T15:05:51.476984Z\",\n    \"updated\": \"2021-11-01T12:23:50.151874Z\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Update\",\n  \"id\": \"http://ds9.lemmy.ml/activities/update/d3717cf5-096d-473f-9530-5d52f9d51f5f\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/create_or_update/create_comment.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"type\": \"Note\",\n    \"id\": \"http://ds9.lemmy.ml/comment/1\",\n    \"attributedTo\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\n      \"http://enterprise.lemmy.ml/c/main\",\n      \"http://ds9.lemmy.ml/u/lemmy_alpha\"\n    ],\n    \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n    \"content\": \"hello\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"hello\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"inReplyTo\": \"http://ds9.lemmy.ml/post/1\",\n    \"published\": \"2021-11-01T11:45:49.794920Z\"\n  },\n  \"cc\": [\n    \"http://enterprise.lemmy.ml/c/main\",\n    \"http://ds9.lemmy.ml/u/lemmy_alpha\"\n  ],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"tag\": [\n    {\n      \"href\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n      \"type\": \"Mention\",\n      \"name\": \"@lemmy_alpha@ds9.lemmy.ml\"\n    }\n  ],\n  \"type\": \"Create\",\n  \"id\": \"http://ds9.lemmy.ml/activities/create/1e77d67c-44ac-45ed-bf2a-460e21f60236\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/create_or_update/create_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"type\": \"Page\",\n    \"id\": \"http://ds9.lemmy.ml/post/1\",\n    \"attributedTo\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\n      \"http://enterprise.lemmy.ml/c/main\",\n      \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n    \"name\": \"test post\",\n    \"content\": \"<p>test body</p>\\n\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"test body\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"attachment\": [\n      {\n        \"type\": \"Link\",\n        \"href\": \"https://lemmy.ml/pictrs/image/xl8W7FZfk9.jpg\"\n      }\n    ],\n    \"sensitive\": false,\n    \"language\": {\n      \"identifier\": \"ko\",\n      \"name\": \"한국어\"\n    },\n    \"published\": \"2021-10-29T15:10:51.557399Z\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Create\",\n  \"id\": \"http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/create_or_update/create_private_message.json",
    "content": "{\n  \"id\": \"http://enterprise.lemmy.ml/activities/create/987d05fa-f637-46d7-85be-13d112bc269f\",\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"http://ds9.lemmy.ml/u/lemmy_alpha\"],\n  \"object\": {\n    \"type\": \"Note\",\n    \"id\": \"http://enterprise.lemmy.ml/private_message/1\",\n    \"attributedTo\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"to\": [\"http://ds9.lemmy.ml/u/lemmy_alpha\"],\n    \"content\": \"hello\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"hello\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"published\": \"2021-10-29T15:31:56.058289Z\"\n  },\n  \"type\": \"Create\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/create_or_update/update_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"type\": \"Page\",\n    \"id\": \"http://ds9.lemmy.ml/post/1\",\n    \"attributedTo\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\n      \"http://enterprise.lemmy.ml/c/main\",\n      \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n    \"name\": \"test post 1\",\n    \"content\": \"<p>test body</p>\\n\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"test body\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"attachment\": [\n      {\n        \"type\": \"Link\",\n        \"href\": \"https://lemmy.ml/pictrs/image/xl8W7FZfk9.jpg\"\n      }\n    ],\n    \"sensitive\": false,\n    \"published\": \"2021-10-29T15:10:51.557399Z\",\n    \"updated\": \"2021-10-29T15:11:35.976374Z\",\n    \"context\": \"http://ds9.lemmy.ml/post/1/context\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Update\",\n  \"id\": \"http://ds9.lemmy.ml/activities/update/ab360117-e165-4de4-b7fc-906b62c98631\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/delete_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/post/1\",\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Delete\",\n  \"id\": \"http://ds9.lemmy.ml/activities/delete/f2abee48-c7bb-41d5-9e27-8775ff32db12\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/delete_private_message.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"http://enterprise.lemmy.ml/u/lemmy_beta\"],\n  \"object\": \"http://enterprise.lemmy.ml/private_message/1\",\n  \"type\": \"Delete\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/delete/041d9858-5eef-4ad9-84ae-7455b4d87ed9\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/delete_user.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"type\": \"Delete\",\n  \"id\": \"http://ds9.lemmy.ml/activities/delete/f2abee48-c7bb-41d5-9e27-8775ff32db12\",\n  \"removeData\": true\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/remove_note.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": \"http://ds9.lemmy.ml/comment/1\",\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Delete\",\n  \"summary\": \"bad comment\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/delete/42ca1a79-f99e-4518-a2ca-ba2df221eb5e\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/undo_delete_page.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"object\": \"http://ds9.lemmy.ml/post/1\",\n    \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n    \"type\": \"Delete\",\n    \"id\": \"http://ds9.lemmy.ml/activities/delete/b13cca96-7737-41e1-9769-8fbf972b3509\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Undo\",\n  \"id\": \"http://ds9.lemmy.ml/activities/undo/5e939cfb-b8a1-4de8-950f-9d684e9162b9\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/undo_delete_private_message.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"http://ds9.lemmy.ml/u/lemmy_alpha\"],\n  \"object\": {\n    \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"to\": [\"http://enterprise.lemmy.ml/u/lemmy_beta\"],\n    \"object\": \"http://enterprise.lemmy.ml/private_message/1\",\n    \"type\": \"Delete\",\n    \"id\": \"http://enterprise.lemmy.ml/activities/delete/616c41be-04ed-4bd4-b865-30712186b122\"\n  },\n  \"type\": \"Undo\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/undo/35e5b337-014c-4bbe-8d63-6fac96f51409\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/deletion/undo_remove_note.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"object\": \"http://ds9.lemmy.ml/comment/1\",\n    \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n    \"type\": \"Delete\",\n    \"summary\": \"bad comment\",\n    \"id\": \"http://enterprise.lemmy.ml/activities/delete/2598435c-87a3-49cd-81f3-a44b03b7af9d\"\n  },\n  \"cc\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"audience\": \"https://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Undo\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/undo/a850cf21-3866-4b3a-b80b-56aa00997fee\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/following/accept.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/c/main\",\n  \"to\": [\"http://ds9.lemmy.ml/u/lemmy_alpha\"],\n  \"object\": {\n    \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"object\": \"http://enterprise.lemmy.ml/c/main\",\n    \"type\": \"Follow\",\n    \"id\": \"http://ds9.lemmy.ml/activities/follow/6abcd50b-b8ca-4952-86b0-a6dd8cc12866\"\n  },\n  \"type\": \"Accept\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/accept/75f080cc-3d45-4654-8186-8f3bb853fa27\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/following/follow.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"object\": \"http://enterprise.lemmy.ml/c/main\",\n  \"type\": \"Follow\",\n  \"id\": \"http://ds9.lemmy.ml/activities/follow/6abcd50b-b8ca-4952-86b0-a6dd8cc12866\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/following/undo_follow.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n  \"object\": {\n    \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"to\": [\"http://enterprise.lemmy.ml/c/main\"],\n    \"object\": \"http://enterprise.lemmy.ml/c/main\",\n    \"type\": \"Follow\",\n    \"id\": \"http://ds9.lemmy.ml/activities/follow/dc2f1bc5-f3a0-4daa-a46b-428cbfbd023c\"\n  },\n  \"type\": \"Undo\",\n  \"id\": \"http://ds9.lemmy.ml/activities/undo/dd83c482-8ebd-4b6c-9008-c8373bd1a86a\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/voting/dislike_page.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"object\": \"http://ds9.lemmy.ml/post/1\",\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"type\": \"Dislike\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/dislike/64d40d40-a829-43a5-8247-1fb595b3ca1c\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/voting/like_note.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"object\": \"http://ds9.lemmy.ml/comment/1\",\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"type\": \"Like\",\n  \"id\": \"http://ds9.lemmy.ml/activities/like/fd61d070-7382-46a9-b2b7-6bb253732877\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/voting/undo_dislike_page.json",
    "content": "{\n  \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n  \"object\": {\n    \"actor\": \"http://enterprise.lemmy.ml/u/lemmy_beta\",\n    \"object\": \"http://ds9.lemmy.ml/post/1\",\n    \"type\": \"Like\",\n    \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"id\": \"http://enterprise.lemmy.ml/activities/like/2227ab2c-79e2-4fca-a1d2-1d67dacf2457\"\n  },\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"type\": \"Undo\",\n  \"id\": \"http://enterprise.lemmy.ml/activities/undo/6cc6fb71-39fe-49ea-9506-f0423b101e98\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/activities/voting/undo_like_note.json",
    "content": "{\n  \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n  \"object\": {\n    \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n    \"object\": \"http://ds9.lemmy.ml/comment/1\",\n    \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"type\": \"Like\",\n    \"id\": \"http://ds9.lemmy.ml/activities/like/efcf7ae2-dfcc-4ff4-9ce4-6adf251ff004\"\n  },\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"type\": \"Undo\",\n  \"id\": \"http://ds9.lemmy.ml/activities/undo/3518565c-24a7-4d9e-8e0a-f7a2f45ac618\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/collections/group_featured_posts.json",
    "content": "{\n  \"type\": \"OrderedCollection\",\n  \"id\": \"https://ds9.lemmy.ml/c/main/featured\",\n  \"totalItems\": 2,\n  \"orderedItems\": [\n    {\n      \"type\": \"Page\",\n      \"id\": \"https://ds9.lemmy.ml/post/2\",\n      \"attributedTo\": \"https://ds9.lemmy.ml/u/lemmy_alpha\",\n      \"to\": [\n        \"https://ds9.lemmy.ml/c/main\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n      ],\n      \"name\": \"test 2\",\n      \"cc\": [],\n      \"mediaType\": \"text/html\",\n      \"attachment\": [],\n      \"sensitive\": false,\n      \"published\": \"2023-02-06T06:42:41.939437Z\",\n      \"language\": {\n        \"identifier\": \"de\",\n        \"name\": \"Deutsch\"\n      },\n      \"audience\": \"https://ds9.lemmy.ml/c/main\"\n    },\n    {\n      \"type\": \"Page\",\n      \"id\": \"https://ds9.lemmy.ml/post/1\",\n      \"attributedTo\": \"https://ds9.lemmy.ml/u/lemmy_alpha\",\n      \"to\": [\n        \"https://ds9.lemmy.ml/c/main\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n      ],\n      \"name\": \"test 1\",\n      \"cc\": [],\n      \"mediaType\": \"text/html\",\n      \"attachment\": [],\n      \"sensitive\": false,\n      \"published\": \"2023-02-06T06:42:37.119567Z\",\n      \"language\": {\n        \"identifier\": \"de\",\n        \"name\": \"Deutsch\"\n      },\n      \"audience\": \"https://ds9.lemmy.ml/c/main\"\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/collections/group_followers.json",
    "content": "{\n  \"id\": \"http://enterprise.lemmy.ml/c/main/followers\",\n  \"type\": \"Collection\",\n  \"totalItems\": 3,\n  \"items\": []\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/collections/group_moderators.json",
    "content": "{\n  \"type\": \"OrderedCollection\",\n  \"id\": \"https://enterprise.lemmy.ml/c/tenforward/moderators\",\n  \"orderedItems\": [\"https://enterprise.lemmy.ml/u/picard\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/collections/group_outbox.json",
    "content": "{\n  \"type\": \"OrderedCollection\",\n  \"id\": \"https://ds9.lemmy.ml/c/testcom/outbox\",\n  \"totalItems\": 2,\n  \"orderedItems\": [\n    {\n      \"actor\": \"https://ds9.lemmy.ml/c/testcom\",\n      \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n      \"object\": {\n        \"actor\": \"https://ds9.lemmy.ml/u/nutomic\",\n        \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n        \"cc\": [\"https://ds9.lemmy.ml/c/testcom\"],\n        \"type\": \"Create\",\n        \"id\": \"http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf\",\n        \"object\": {\n          \"type\": \"Page\",\n          \"id\": \"https://ds9.lemmy.ml/post/2328\",\n          \"attributedTo\": \"https://ds9.lemmy.ml/u/nutomic\",\n          \"to\": [\n            \"https://ds9.lemmy.ml/c/testcom\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n          ],\n          \"name\": \"another outbox test\",\n          \"mediaType\": \"text/html\",\n          \"sensitive\": false,\n          \"stickied\": false,\n          \"published\": \"2021-11-18T17:19:45.895163Z\"\n        }\n      },\n      \"cc\": [\"https://ds9.lemmy.ml/c/testcom/followers\"],\n      \"type\": \"Announce\",\n      \"id\": \"https://ds9.lemmy.ml/activities/announce/b204fe9f-b13d-4af2-9d22-239ac2d892e6\"\n    },\n    {\n      \"actor\": \"https://ds9.lemmy.ml/c/testcom\",\n      \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n      \"object\": {\n        \"actor\": \"https://ds9.lemmy.ml/u/nutomic\",\n        \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n        \"cc\": [\"https://ds9.lemmy.ml/c/testcom\"],\n        \"type\": \"Create\",\n        \"id\": \"http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf\",\n        \"object\": {\n          \"type\": \"Page\",\n          \"id\": \"https://ds9.lemmy.ml/post/2327\",\n          \"attributedTo\": \"https://ds9.lemmy.ml/u/nutomic\",\n          \"to\": [\n            \"https://ds9.lemmy.ml/c/testcom\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n          ],\n          \"name\": \"outbox test\",\n          \"mediaType\": \"text/html\",\n          \"sensitive\": false,\n          \"stickied\": false,\n          \"published\": \"2021-11-18T17:19:05.763109Z\"\n        }\n      },\n      \"cc\": [\"https://ds9.lemmy.ml/c/testcom/followers\"],\n      \"type\": \"Announce\",\n      \"id\": \"https://ds9.lemmy.ml/activities/announce/c6c960ce-c8d8-4231-925e-3ba367468f18\"\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/collections/person_outbox.json",
    "content": "{\n  \"type\": \"OrderedCollection\",\n  \"id\": \"http://ds9.lemmy.ml/u/lemmy_alpha/outbox\",\n  \"orderedItems\": [],\n  \"totalItems\": 0\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/comment.json",
    "content": "{\n  \"id\": \"https://enterprise.lemmy.ml/comment/38741\",\n  \"type\": \"Note\",\n  \"attributedTo\": \"https://enterprise.lemmy.ml/u/picard\",\n  \"to\": [\n    \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"cc\": [\"https://enterprise.lemmy.ml/u/picard\"],\n  \"inReplyTo\": \"https://enterprise.lemmy.ml/post/55143\",\n  \"content\": \"<p>first comment!</p>\\n\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"first comment!\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"tag\": [\n    {\n      \"href\": \"https://enterprise.lemmy.ml/u/picard\",\n      \"type\": \"Mention\",\n      \"name\": \"@picard@enterprise.lemmy.ml\"\n    }\n  ],\n  \"distinguished\": false,\n  \"language\": {\n    \"identifier\": \"fr\",\n    \"name\": \"Français\"\n  },\n  \"published\": \"2021-03-01T13:42:43.966208Z\",\n  \"updated\": \"2021-03-01T13:43:03.955787Z\",\n  \"context\": \"https://enterprise.lemmy.ml/comment/38741/context\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/group.json",
    "content": "{\n  \"id\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"type\": \"Group\",\n  \"preferredUsername\": \"tenforward\",\n  \"name\": \"Ten Forward\",\n  \"description\": \"A description of ten forward.\",\n  \"summary\": \"<p>Lounge and recreation facility</p>\\n<hr />\\n<p>Welcome to the Enterprise!.</p>\\n\",\n  \"source\": {\n    \"content\": \"Lounge and recreation facility\\n\\n---\\n\\nWelcome to the Enterprise!\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"mediaType\": \"text/html\",\n  \"sensitive\": false,\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://enterprise.lemmy.ml/pictrs/image/waqyZwLAy4.webp\"\n  },\n  \"image\": {\n    \"type\": \"Image\",\n    \"url\": \"https://enterprise.lemmy.ml/pictrs/image/Wt8zoMcCmE.jpg\"\n  },\n  \"inbox\": \"https://enterprise.lemmy.ml/c/tenforward/inbox\",\n  \"followers\": \"https://enterprise.lemmy.ml/c/tenforward/followers\",\n  \"attributedTo\": \"https://enterprise.lemmy.ml/c/tenforward/moderators\",\n  \"featured\": \"https://enterprise.lemmy.ml/c/tenforward//featured\",\n  \"postingRestrictedToMods\": false,\n  \"endpoints\": {\n    \"sharedInbox\": \"https://enterprise.lemmy.ml/inbox\"\n  },\n  \"outbox\": \"https://enterprise.lemmy.ml/c/tenforward/outbox\",\n  \"publicKey\": {\n    \"id\": \"https://enterprise.lemmy.ml/c/tenforward#main-key\",\n    \"owner\": \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzRjKTNtvDCmugplwEh+g\\nx1bhKm6BHUZfXfpscgMMm7tXFswSDzUQirMgfkxa9ubfr1PDFKffA2vQ9x6CyuO/\\n70xTafdOHyV1tSqzgKz0ZvFZ/VCOo6qy1mYWVkrtBm/fKzM+87MdkKYB/zI4VyEJ\\nLfLQgjwxBAEYUH3CBG71U0gO0TwbimWNN0vqlfp0QfThNe1WYObF88ZVzMLgFbr7\\nRHBItZjlZ/d8foPDidlIR3l2dJjy0EsD8F9JM340jtX7LXqFmU4j1AQKNHTDLnUF\\nwYVhzuQGNJ504l5LZkFG54XfIFT7dx2QwuuM9bSnfPv/98RYrq1Si6tCkxEt1cVe\\n4wIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"language\": [\n    {\n      \"identifier\": \"fr\",\n      \"name\": \"Français\"\n    },\n    {\n      \"identifier\": \"de\",\n      \"name\": \"Deutsch\"\n    }\n  ],\n  \"tag\": [\n    {\n      \"type\": \"CommunityPostTag\",\n      \"id\": \"https://enterprise.lemmy.ml/c/tenforward/tag/news\",\n      \"preferredUsername\": \"news\"\n    }\n  ],\n  \"published\": \"2019-06-02T16:43:50.799554Z\",\n  \"updated\": \"2021-03-10T17:18:10.498868Z\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/instance.json",
    "content": "{\n  \"type\": \"Application\",\n  \"id\": \"https://enterprise.lemmy.ml/\",\n  \"name\": \"Enterprise\",\n  \"preferredUsername\": \"enterprise.lemmy.ml\",\n  \"description\": \"A test instance\",\n  \"content\": \"<p>Enterprise sidebar</p>\\\\n\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"Enterprise sidebar\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"inbox\": \"https://enterprise.lemmy.ml/inbox\",\n  \"outbox\": \"https://enterprise.lemmy.ml/outbox\",\n  \"publicKey\": {\n    \"id\": \"https://enterprise.lemmy.ml/#main-key\",\n    \"owner\": \"https://enterprise.lemmy.ml/\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAupcK0xTw5yQb/fnztAmb\\n9LfPbhJJP1+1GwUaOXGYiDJD6uYJhl9CLmgztLl3RyV9ltOYoN8/NLNDfOMmgOjd\\nrsNWEjDI9IcVPmiZnhU7hsi6KgQvJzzv8O5/xYjAGhDfrGmtdpL+lyG0B5fQod8J\\n/V5VWvTQ0B0qFrLSBBuhOrp8/fTtDskdtElDPtnNfH2jn6FgtLOijidWwf9ekFo4\\n0I1JeuEw6LuD/CzKVJTPoztzabUV1DQF/DnFJm+8y7SCJa9jEO56Uf9eVfa1jF6f\\ndH6ZvNJMiafstVuLMAw7C/eNJy3ufXgtZ4403oOKA0aRSYf1cc9pHSZ9gDE/mevH\\nLwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"language\": [\n    {\n      \"identifier\": \"fr\",\n      \"name\": \"Français\"\n    },\n    {\n      \"identifier\": \"es\",\n      \"name\": \"Español\"\n    }\n  ],\n  \"published\": \"2022-01-19T21:52:11.110741Z\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/page.json",
    "content": "{\n  \"id\": \"https://enterprise.lemmy.ml/post/55143\",\n  \"type\": \"Page\",\n  \"attributedTo\": \"https://enterprise.lemmy.ml/u/picard\",\n  \"to\": [\n    \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"audience\": \"https://enterprise.lemmy.ml/c/tenforward\",\n  \"name\": \"Post title\",\n  \"content\": \"<p>This is a post in the /c/tenforward community</p>\\n\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"This is a post in the /c/tenforward community\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"attachment\": [\n    {\n      \"type\": \"Link\",\n      \"href\": \"https://enterprise.lemmy.ml/pictrs/image/eOtYb9iEiB.png\"\n    }\n  ],\n  \"image\": {\n    \"type\": \"Image\",\n    \"url\": \"https://enterprise.lemmy.ml/pictrs/image/eOtYb9iEiB.png\"\n  },\n  \"sensitive\": false,\n  \"language\": {\n    \"identifier\": \"fr\",\n    \"name\": \"Français\"\n  },\n  \"tag\": [\n    {\n      \"type\": \"CommunityPostTag\",\n      \"id\": \"https://enterprise.lemmy.ml/c/tenforward/tag/news\",\n      \"preferredUsername\": \"news\"\n    }\n  ],\n  \"context\": \"https://enterprise.lemmy.ml/post/55143/context\",\n  \"published\": \"2021-02-26T12:35:34.292626Z\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/person.json",
    "content": "{\n  \"id\": \"https://enterprise.lemmy.ml/u/picard\",\n  \"type\": \"Person\",\n  \"preferredUsername\": \"picard\",\n  \"name\": \"Jean-Luc Picard\",\n  \"summary\": \"<p>Captain of the starship <strong>Enterprise</strong>.</p>\\n\",\n  \"source\": {\n    \"content\": \"Captain of the starship **Enterprise**.\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://enterprise.lemmy.ml/pictrs/image/ed9ej7.jpg\"\n  },\n  \"image\": {\n    \"type\": \"Image\",\n    \"url\": \"https://enterprise.lemmy.ml/pictrs/image/XenaYI5hTn.png\"\n  },\n  \"matrixUserId\": \"@picard:matrix.org\",\n  \"inbox\": \"https://enterprise.lemmy.ml/u/picard/inbox\",\n  \"outbox\": \"https://enterprise.lemmy.ml/u/picard/outbox\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://enterprise.lemmy.ml/inbox\"\n  },\n  \"published\": \"2020-01-17T01:38:22.348392Z\",\n  \"updated\": \"2021-08-13T00:11:15.941990Z\",\n  \"publicKey\": {\n    \"id\": \"https://enterprise.lemmy.ml/u/picard#main-key\",\n    \"owner\": \"https://enterprise.lemmy.ml/u/picard\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0lP99/s5Vv+XbPdkeqIJ\\nwoD4GFnHmBnBHdEKChEUWfWj1TtioC/rGNoXFQeXQA3Amhy4nxSceiDnUgwkkuQY\\nv0MtIW58NzgknEavtllxL+LSds5pg3gANaDIk8UiWTkqXTg0GnlJMpCK1Chen0l/\\nszL6DEvUyTSuS5ZYDXFgewF89Pe7U0S15V5U2Harv7AgJYDyxmUL0D1pGuUCRqcE\\nl5MTHJjrXeNnH1w2g8aly8YlO/Cr0L51rFg/lBF23vni7ZLv8HbmWh6YpaAf1R8h\\nE45zKR7OHqymdjzrg1ITBwovefpwMkVgnJ+Wdr4HPnFlBSkXPoZeM11+Z8L0anzA\\nXwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/private_message.json",
    "content": "{\n  \"id\": \"https://enterprise.lemmy.ml/private_message/1621\",\n  \"type\": \"Note\",\n  \"attributedTo\": \"https://enterprise.lemmy.ml/u/picard\",\n  \"to\": [\"https://queer.hacktivis.me/users/lanodan\"],\n  \"content\": \"<p>Hello hello, testing</p>\\n\",\n  \"mediaType\": \"text/html\",\n  \"source\": {\n    \"content\": \"Hello hello, testing\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"published\": \"2021-10-21T10:13:14.597721Z\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lemmy/objects/tombstone.json",
    "content": "{\n  \"id\": \"https://lemmy.ml/comment/110273\",\n  \"type\": \"Tombstone\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/activities/create_note_reply.json",
    "content": "{\n  \"actor\": \"https://c.tide.tk/users/1\",\n  \"object\": {\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"https://c.tide.tk/comments/52\",\n    \"type\": \"Note\",\n    \"mediaType\": \"text/html\",\n    \"source\": {\n      \"content\": \"test comment\",\n      \"mediaType\": \"text/markdown\"\n    },\n    \"attributedTo\": \"https://c.tide.tk/users/1\",\n    \"content\": \"<p>test comment</p>\\n\",\n    \"published\": \"2021-09-16T01:20:27.558063+00:00\",\n    \"inReplyTo\": \"https://c.tide.tk/posts/51\",\n    \"to\": \"https://c.tide.tk/users/1\",\n    \"cc\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://c.tide.tk/communities/1\"\n    ]\n  },\n  \"to\": \"https://c.tide.tk/users/1\",\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://c.tide.tk/communities/1\"\n  ],\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://c.tide.tk/comments/52/create\",\n  \"type\": \"Create\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/activities/create_page.json",
    "content": "{\n  \"actor\": \"https://b.tide.tk/apub/users/1\",\n  \"object\": {\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"https://b.tide.tk/apub/posts/60\",\n    \"type\": \"Page\",\n    \"name\": \"test post from b\",\n    \"summary\": \"test post from b\",\n    \"to\": \"https://c.tide.tk/communities/1\",\n    \"cc\": \"https://www.w3.org/ns/activitystreams#Public\",\n    \"published\": \"2020-12-19T19:20:26.941381+00:00\",\n    \"attributedTo\": \"https://b.tide.tk/apub/users/1\",\n    \"url\": \"https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html\"\n  },\n  \"to\": \"https://c.tide.tk/communities/1\",\n  \"cc\": \"https://www.w3.org/ns/activitystreams#Public\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://b.tide.tk/apub/posts/60/create\",\n  \"type\": \"Create\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/activities/create_page_image.json",
    "content": "{\n  \"actor\": \"http://ltthostname.local:3334/apub/users/3\",\n  \"object\": {\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"http://ltthostname.local:3334/apub/posts/46\",\n    \"type\": \"Note\",\n    \"name\": \"image\",\n    \"to\": \"http://localhost:8536/c/elsewhere\",\n    \"cc\": \"https://www.w3.org/ns/activitystreams#Public\",\n    \"attributedTo\": \"http://ltthostname.local:3334/apub/users/3\",\n    \"attachment\": [\n      {\n        \"type\": \"Image\",\n        \"url\": \"http://ltthostname.local:3334/api/stable/posts/46/href\"\n      }\n    ],\n    \"sensitive\": false,\n    \"published\": \"2022-08-06T18:35:01.043072+00:00\",\n    \"summary\": \"image\"\n  },\n  \"to\": \"http://localhost:8536/c/elsewhere\",\n  \"cc\": \"https://www.w3.org/ns/activitystreams#Public\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"http://ltthostname.local:3334/apub/posts/46/create\",\n  \"type\": \"Create\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/activities/delete_note.json",
    "content": "{\n  \"actor\": \"https://narwhal.city/users/3\",\n  \"object\": \"https://narwhal.city/posts/12\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://narwhal.city/posts/12/delete\",\n  \"type\": \"Delete\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/activities/follow.json",
    "content": "{\n  \"actor\": \"https://dev.narwhal.city/users/1\",\n  \"object\": \"https://beehaw.org/c/foss\",\n  \"to\": \"https://beehaw.org/c/foss\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://dev.narwhal.city/communities/90/followers/1\",\n  \"type\": \"Follow\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/objects/group.json",
    "content": "{\n  \"publicKey\": {\n    \"id\": \"https://narwhal.city//communities/12#main-key\",\n    \"owner\": \"https://narwhal.city/communities/12\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtktBbjovDSQmjZo1SIGK\\n1TP1FKuIj8JlFgY6iGrAA5IBUN8PPKRzvo0U0FDvF+7SsUx+yiY0JrU1KzWcJxRr\\nCfTrjNzaKeMS4E6ZU9czf8D157JUJQtkgikObxwU84eY5K+jic1ZgGv2eX77E6f/\\nBZFO8StdS73g8a1vxPEsJVBn/VEVdsD9fg3uvhwFN7UrUKoKGf+1h2PajeX1aPZb\\ntD3ql3Xff2IZFZu6Euj80OezozQ6/AqZx+qW6HfjvSf30C8ZGYU1PSF6MczY+Sg6\\n6nyPMfmbKykYgWqfRMZ/NKaldsIjN8nMRDCfHASt6+pNmZgWh9HvSaFiSFKIn3Xj\\nXwIDAQAB\\n-----END PUBLIC KEY-----\\n\",\n    \"signatureAlgorithm\": \"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"\n  },\n  \"featured\": \"https://narwhal.city/communities/12/featured\",\n  \"inbox\": \"https://narwhal.city/communities/12/inbox\",\n  \"outbox\": \"https://narwhal.city/communities/12/outbox\",\n  \"followers\": \"https://narwhal.city/communities/12/followers\",\n  \"preferredUsername\": \"Iotide\",\n  \"summary\": \"This is for talking about lotide\\r\\n\\r\\n\\r\\nI accidentally called it iotide because I misread the text when I made it lol\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"featured\": {\n        \"@id\": \"toot:featured\",\n        \"@type\": \"@id\"\n      },\n      \"toot\": \"http://joinmastodon.org/ns#\"\n    }\n  ],\n  \"id\": \"https://narwhal.city/communities/12\",\n  \"type\": \"Group\",\n  \"name\": \"Iotide\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/objects/note.json",
    "content": "{\n  \"source\": {\n    \"mediaType\": \"text/markdown\",\n    \"content\": \"ed: now featuring Bob Dylan and RNG\"\n  },\n  \"attributedTo\": \"https://narwhal.city/users/3\",\n  \"content\": \"<p>ed: now featuring Bob Dylan and RNG</p>\\n\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"inReplyTo\": \"https://narwhal.city/posts/9\",\n  \"to\": \"https://narwhal.city/users/1\",\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://narwhal.city/communities/4\"\n  ],\n  \"id\": \"https://narwhal.city/comments/3\",\n  \"type\": \"Note\",\n  \"mediaType\": \"text/html\",\n  \"published\": \"2020-12-31T06:47:24.470801+00:00\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/objects/page.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://narwhal.city/posts/9\",\n  \"type\": \"Page\",\n  \"name\": \"What's Dylan Grillin'? (reupload)\",\n  \"to\": \"https://narwhal.city/communities/4\",\n  \"attributedTo\": \"https://narwhal.city/users/1\",\n  \"published\": \"2020-12-30T07:29:19.460932+00:00\",\n  \"url\": \"https://www.youtube.com/watch?v=ZI4LGTXscR4\",\n  \"summary\": \"What's Dylan Grillin'? (reupload)\",\n  \"cc\": \"https://www.w3.org/ns/activitystreams#Public\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/objects/person.json",
    "content": "{\n  \"publicKey\": {\n    \"id\": \"https://narwhal.city//users/3#main-key\",\n    \"owner\": \"https://narwhal.city/users/3\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvC+ZURasjlyX1o4FqMWB\\npAppKWU2zPV7cUokKsnKo9m2PKw+53mmVUMQ66LtN80l/WCK/hy7r2lDKvpyt3YO\\nnEsNcSCYLaYnTLDNkE2u14kx8jKOFiyRKKVKCNA32b+XvM+rLDmfaNOeBsB92mVR\\nVmIz+WO+0FVPtg1MQMKWIoe6SgKW8SHpz/qVeggYNMKp/b2ai7Of0KTSbYIcqFR2\\nT8g/6L5Mmjz4zKIn+a5GFmBNTMTCsJTxa5yOjPwefh/9SrukWt01N5KLrIpmApms\\nRoJSsBWh0xo7N+v23PaFHEkaJ2zCtT5zkzITa8bUfHoIc3rM6Ipa1uFlnmrnUIZE\\nUQIDAQAB\\n-----END PUBLIC KEY-----\\n\",\n    \"signatureAlgorithm\": \"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"\n  },\n  \"inbox\": \"https://narwhal.city/users/3/inbox\",\n  \"outbox\": \"https://narwhal.city/users/3/outbox\",\n  \"preferredUsername\": \"57H\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://narwhal.city/inbox\"\n  },\n  \"summary\": \"\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\"\n  ],\n  \"id\": \"https://narwhal.city/users/3\",\n  \"type\": \"Person\",\n  \"name\": \"57H\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/lotide/objects/tombstone.json",
    "content": "{\n  \"former_type\": \"Note\",\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://narwhal.city/posts/12\",\n  \"type\": \"Tombstone\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/create_note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\"\n    }\n  ],\n  \"id\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/activity\",\n  \"type\": \"Create\",\n  \"actor\": \"https://mastodon.madrid/users/felix\",\n  \"published\": \"2021-11-05T11:46:50Z\",\n  \"to\": [\"https://mastodon.madrid/users/felix/followers\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://mamot.fr/users/retiolus\"\n  ],\n  \"object\": {\n    \"id\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645\",\n    \"type\": \"Note\",\n    \"summary\": null,\n    \"inReplyTo\": \"https://mamot.fr/users/retiolus/statuses/107224244380204526\",\n    \"published\": \"2021-11-05T11:46:50Z\",\n    \"url\": \"https://mastodon.madrid/@felix/107224289116410645\",\n    \"attributedTo\": \"https://mastodon.madrid/users/felix\",\n    \"to\": [\"https://mastodon.madrid/users/felix/followers\"],\n    \"cc\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://mamot.fr/users/retiolus\"\n    ],\n    \"sensitive\": false,\n    \"atomUri\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645\",\n    \"inReplyToAtomUri\": \"https://mamot.fr/users/retiolus/statuses/107224244380204526\",\n    \"conversation\": \"tag:mamot.fr,2021-11-05:objectId=64635960:objectType=Conversation\",\n    \"content\": \"<p><span class=\\\"h-card\\\"><a href=\\\"https://mamot.fr/@retiolus\\\" class=\\\"u-url mention\\\">@<span>retiolus</span></a></span> i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>\",\n    \"contentMap\": {\n      \"en\": \"<p><span class=\\\"h-card\\\"><a href=\\\"https://mamot.fr/@retiolus\\\" class=\\\"u-url mention\\\">@<span>retiolus</span></a></span> i have neverbeendisappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>\"\n    },\n    \"attachment\": [],\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://mamot.fr/users/retiolus\",\n        \"name\": \"@retiolus@mamot.fr\"\n      }\n    ],\n    \"replies\": {\n      \"id\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies\",\n      \"type\": \"Collection\",\n      \"first\": {\n        \"type\": \"CollectionPage\",\n        \"next\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies?only_other_accounts=true&page=true\",\n        \"partOf\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies\",\n        \"items\": []\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/delete.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\"\n    }\n  ],\n  \"id\": \"https://mastodon.madrid/users/felix/statuses/107773559874184870#delete\",\n  \"type\": \"Delete\",\n  \"actor\": \"https://mastodon.madrid/users/felix\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"object\": {\n    \"id\": \"https://mastodon.madrid/users/felix/statuses/107773559874184870\",\n    \"type\": \"Tombstone\",\n    \"atomUri\": \"https://mastodon.madrid/users/felix/statuses/107773559874184870\"\n  },\n  \"signature\": {\n    \"type\": \"RsaSignature2017\",\n    \"creator\": \"https://mastodon.madrid/users/felix#main-key\",\n    \"created\": \"2022-02-10T11:54:18Z\",\n    \"signatureValue\": \"NjGnbkvouSP/cSusR7+sz39iEYxWXCu6nFmBXU3t8ETPkmbpMF5ASeJixXvpTOqbOfkMoWfXncw+jDsbqZ3ELaHGG1gZ5wHWym7mk7YCjQokpF3oPhTWmlEJCVKgewXMrfI4Ok8GGsUMGzuki9EyBDGc/UNBMEAhcxV5Huu7QSQDowcbIwxS3ImxFmtKFceh6mv/kMiXUerCgkYSm6rYZeXZGMTUpvcn9gP6X6Ed6UsrLjCSb3Fj0Naz7LHtzZXRSZDZF/SX2Vw/xKJIgEGzSCv+LKZGvEEkK8PPfMJJhi8cBJebkqOnBGtE6gYK2z2cm/oGorZtXU2L05pXmLAlYQ==\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/flag.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://mastodon.example/ccb4f39a-506a-490e-9a8c-71831c7713a4\",\n  \"type\": \"Flag\",\n  \"actor\": \"https://mastodon.example/actor\",\n  \"content\": \"Please take a look at this user and their posts\",\n  \"object\": [\n    \"https://example.com/users/1\",\n    \"https://example.com/posts/380590\",\n    \"https://example.com/posts/380591\"\n  ],\n  \"to\": \"https://example.com/users/1\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/follow.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf\",\n  \"type\": \"Follow\",\n  \"actor\": \"https://masto.asonix.dog/users/asonix\",\n  \"object\": \"https://ds9.lemmy.ml/c/testcom\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/like_page.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://mastodon.madrid/users/felix#likes/212340\",\n  \"type\": \"Like\",\n  \"actor\": \"https://mastodon.madrid/users/felix\",\n  \"object\": \"https://ds9.lemmy.ml/post/147\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/private_message.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\"\n    }\n  ],\n  \"id\": \"https://mastodon.world/users/nutomic/statuses/110854468010322301\",\n  \"type\": \"Note\",\n  \"summary\": null,\n  \"inReplyTo\": \"https://mastodon.world/users/nutomic/statuses/110854464248188528\",\n  \"published\": \"2023-08-08T14:29:04Z\",\n  \"url\": \"https://mastodon.world/@nutomic/110854468010322301\",\n  \"attributedTo\": \"https://mastodon.world/users/nutomic\",\n  \"to\": [\"https://ds9.lemmy.ml/u/nutomic\"],\n  \"cc\": [],\n  \"sensitive\": false,\n  \"atomUri\": \"https://mastodon.world/users/nutomic/statuses/110854468010322301\",\n  \"inReplyToAtomUri\": \"https://mastodon.world/users/nutomic/statuses/110854464248188528\",\n  \"conversation\": \"tag:mastodon.world,2023-08-08:objectId=121377096:objectType=Conversation\",\n  \"content\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://ds9.lemmy.ml/u/nutomic\\\" class=\\\"u-url mention\\\">@<span>nutomic@ds9.lemmy.ml</span></a></span> 444</p>\",\n  \"contentMap\": {\n    \"es\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://ds9.lemmy.ml/u/nutomic\\\" class=\\\"u-url mention\\\">@<span>nutomic@ds9.lemmy.ml</span></a></span> 444</p>\"\n  },\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://ds9.lemmy.ml/u/nutomic\",\n      \"name\": \"@nutomic@ds9.lemmy.ml\"\n    }\n  ],\n  \"replies\": {\n    \"id\": \"https://mastodon.world/users/nutomic/statuses/110854468010322301/replies\",\n    \"type\": \"Collection\",\n    \"first\": {\n      \"type\": \"CollectionPage\",\n      \"next\": \"https://mastodon.world/users/nutomic/statuses/110854468010322301/replies?only_other_accounts=true&page=true\",\n      \"partOf\": \"https://mastodon.world/users/nutomic/statuses/110854468010322301/replies\",\n      \"items\": []\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/undo_follow.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://masto.asonix.dog/users/asonix#follows/449/undo\",\n  \"type\": \"Undo\",\n  \"actor\": \"https://masto.asonix.dog/users/asonix\",\n  \"object\": {\n    \"id\": \"https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf\",\n    \"type\": \"Follow\",\n    \"actor\": \"https://masto.asonix.dog/users/asonix\",\n    \"object\": \"https://ds9.lemmy.ml/c/testcom\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/activities/undo_like_page.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://mastodon.madrid/users/felix#likes/212341/undo\",\n  \"type\": \"Undo\",\n  \"actor\": \"https://mastodon.madrid/users/felix\",\n  \"object\": {\n    \"id\": \"https://mastodon.madrid/users/felix#likes/212341\",\n    \"type\": \"Like\",\n    \"actor\": \"https://mastodon.madrid/users/felix\",\n    \"object\": \"https://ds9.lemmy.ml/post/147\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/collections/featured.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\",\n      \"Hashtag\": \"as:Hashtag\"\n    }\n  ],\n  \"id\": \"https://mastodon.social/users/LemmyDev/collections/featured\",\n  \"type\": \"OrderedCollection\",\n  \"totalItems\": 1,\n  \"orderedItems\": [\n    {\n      \"id\": \"https://mastodon.social/users/LemmyDev/statuses/104246642906910728\",\n      \"type\": \"Note\",\n      \"summary\": null,\n      \"inReplyTo\": null,\n      \"published\": \"2020-05-28T14:52:14Z\",\n      \"url\": \"https://mastodon.social/@LemmyDev/104246642906910728\",\n      \"attributedTo\": \"https://mastodon.social/users/LemmyDev\",\n      \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n      \"cc\": [\"https://mastodon.social/users/LemmyDev/followers\"],\n      \"sensitive\": false,\n      \"atomUri\": \"https://mastodon.social/users/LemmyDev/statuses/104246642906910728\",\n      \"inReplyToAtomUri\": null,\n      \"conversation\": \"tag:mastodon.social,2020-05-28:objectId=175451535:objectType=Conversation\",\n      \"content\": \"<p>Inaugural Post for Lemmy, a decentralized, easily self-hostable <a href=\\\"https://mastodon.social/tags/reddit\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>reddit</span></a> / link aggregator alternative,intended to work in the <a href=\\\"https://mastodon.social/tags/fediverse\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>fediverse</span></a>: </p><p><a href=\\\"https://github.com/LemmyNet/lemmy/\\\" target=\\\"_blank\\\" rel=\\\"nofollow noopener noreferrer\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">github.com/LemmyNet/lemmy/</span><span class=\\\"invisible\\\"></span></a></p><p><a href=\\\"https://mastodon.social/tags/activitypub\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>activitypub</span></a></p>\",\n      \"contentMap\": {\n        \"en\": \"<p>Inaugural Post for Lemmy, a decentralized, easily self-hostable <a href=\\\"https://mastodon.social/tags/reddit\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>reddit</span></a> / link aggregator alternative, intended to work in the <a href=\\\"https://mastodon.social/tags/fediverse\\\" class=\\\"mention hashtag\\\" rel=\\\"tag\\\">#<span>fediverse</span></a>: </p><p><a href=\\\"https://github.com/LemmyNet/lemmy/\\\" target=\\\"_blank\\\" rel=\\\"nofollownoopener noreferrer\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">github.com/LemmyNet/lemmy/</span><span class=\\\"invisible\\\"></span></a></p><p><a href=\\\"https://mastodon.social/tags/activitypub\\\" class=\\\"mentionhashtag\\\" rel=\\\"tag\\\">#<span>activitypub</span></a></p>\"\n      },\n      \"attachment\": [],\n      \"tag\": [\n        {\n          \"type\": \"Hashtag\",\n          \"href\": \"https://mastodon.social/tags/reddit\",\n          \"name\": \"#reddit\"\n        },\n        {\n          \"type\": \"Hashtag\",\n          \"href\": \"https://mastodon.social/tags/fediverse\",\n          \"name\": \"#fediverse\"\n        },\n        {\n          \"type\": \"Hashtag\",\n          \"href\": \"https://mastodon.social/tags/activitypub\",\n          \"name\": \"#activitypub\"\n        }\n      ],\n      \"replies\": {\n        \"id\": \"https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies\",\n        \"type\": \"Collection\",\n        \"first\": {\n          \"type\": \"CollectionPage\",\n          \"next\": \"https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies?min_id=104246644059085152&page=true\",\n          \"partOf\": \"https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies\",\n          \"items\": [\n            \"https://mastodon.social/users/LemmyDev/statuses/104246644059085152\"\n          ]\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/objects/note_1.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\"\n    }\n  ],\n  \"id\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645\",\n  \"type\": \"Note\",\n  \"summary\": null,\n  \"inReplyTo\": \"https://mamot.fr/users/retiolus/statuses/107224244380204526\",\n  \"published\": \"2021-11-05T11:46:50Z\",\n  \"url\": \"https://mastodon.madrid/@felix/107224289116410645\",\n  \"attributedTo\": \"https://mastodon.madrid/users/felix\",\n  \"to\": [\"https://mastodon.madrid/users/felix/followers\"],\n  \"cc\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://mamot.fr/users/retiolus\"\n  ],\n  \"sensitive\": false,\n  \"atomUri\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645\",\n  \"inReplyToAtomUri\": \"https://mamot.fr/users/retiolus/statuses/107224244380204526\",\n  \"conversation\": \"tag:mamot.fr,2021-11-05:objectId=64635960:objectType=Conversation\",\n  \"content\": \"<p><span class=\\\"h-card\\\"><a href=\\\"https://mamot.fr/@retiolus\\\" class=\\\"u-url mention\\\">@<span>retiolus</span></a></span> i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>\",\n  \"contentMap\": {\n    \"en\": \"<p><span class=\\\"h-card\\\"><a href=\\\"https://mamot.fr/@retiolus\\\" class=\\\"u-url mention\\\">@<span>retiolus</span></a></span> i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>\"\n  },\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://mamot.fr/users/retiolus\",\n      \"name\": \"@retiolus@mamot.fr\"\n    }\n  ],\n  \"replies\": {\n    \"id\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies\",\n    \"type\": \"Collection\",\n    \"first\": {\n      \"type\": \"CollectionPage\",\n      \"next\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies?only_other_accounts=true&page=true\",\n      \"partOf\": \"https://mastodon.madrid/users/felix/statuses/107224289116410645/replies\",\n      \"items\": []\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/objects/note_2.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\",\n      \"blurhash\": \"toot:blurhash\",\n      \"focalPoint\": {\n        \"@container\": \"@list\",\n        \"@id\": \"toot:focalPoint\"\n      }\n    }\n  ],\n  \"id\": \"https://floss.social/users/kde/statuses/113306831140126616\",\n  \"type\": \"Note\",\n  \"summary\": null,\n  \"inReplyTo\": \"https://floss.social/users/kde/statuses/113306824627995724\",\n  \"published\": \"2024-10-14T16:57:15Z\",\n  \"url\": \"https://floss.social/@kde/113306831140126616\",\n  \"attributedTo\": \"https://floss.social/users/kde\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\n    \"https://floss.social/users/kde/followers\",\n    \"https://lemmy.kde.social/c/kde\",\n    \"https://lemmy.kde.social/c/kde/followers\"\n  ],\n  \"sensitive\": false,\n  \"atomUri\": \"https://floss.social/users/kde/statuses/113306831140126616\",\n  \"inReplyToAtomUri\": \"https://floss.social/users/kde/statuses/113306824627995724\",\n  \"conversation\": \"tag:floss.social,2024-10-14:objectId=71424279:objectType=Conversation\",\n  \"content\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://lemmy.kde.social/c/kde\\\" class=\\\"u-url mention\\\">@<span>kde@lemmy.kde.social</span></a></span> </p><p>We also need funding 💶 to keep the gears turning! Please support us with a donation:</p><p><a href=\\\"https://kde.org/donate/\\\" target=\\\"_blank\\\" rel=\\\"nofollow noopener noreferrer\\\" translate=\\\"no\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">kde.org/donate/</span><span class=\\\"invisible\\\"></span></a></p><p>[3/3]</p>\",\n  \"contentMap\": {\n    \"en\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://lemmy.kde.social/c/kde\\\" class=\\\"u-url mention\\\">@<span>kde@lemmy.kde.social</span></a></span> </p><p>We also need funding 💶 to keep the gears turning! Please support us with a donation:</p><p><a href=\\\"https://kde.org/donate/\\\" target=\\\"_blank\\\" rel=\\\"nofollow noopener noreferrer\\\" translate=\\\"no\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">kde.org/donate/</span><span class=\\\"invisible\\\"></span></a></p><p>[3/3]</p>\"\n  },\n  \"attachment\": [\n    {\n      \"type\": \"Document\",\n      \"mediaType\": \"image/jpeg\",\n      \"url\": \"https://cdn.masto.host/floss/media_attachments/files/113/306/826/682/985/891/original/c8d906a2f2ab2334.jpg\",\n      \"name\": \"The KDE dragons Katie and Konqi stand on either side of a pot filling up with gold coins. Donate!\",\n      \"blurhash\": \"USQv:h-W-qI-^,W;RPs=^-R%NZxbo#sDobSc\",\n      \"focalPoint\": [0.0, 0.0],\n      \"width\": 1500,\n      \"height\": 1095\n    }\n  ],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://lemmy.kde.social/c/kde\",\n      \"name\": \"@kde@lemmy.kde.social\"\n    }\n  ],\n  \"replies\": {\n    \"id\": \"https://floss.social/users/kde/statuses/113306831140126616/replies\",\n    \"type\": \"Collection\",\n    \"first\": {\n      \"type\": \"CollectionPage\",\n      \"next\": \"https://floss.social/users/kde/statuses/113306831140126616/replies?only_other_accounts=true&page=true\",\n      \"partOf\": \"https://floss.social/users/kde/statuses/113306831140126616/replies\",\n      \"items\": []\n    }\n  },\n  \"likes\": {\n    \"id\": \"https://floss.social/users/kde/statuses/113306831140126616/likes\",\n    \"type\": \"Collection\",\n    \"totalItems\": 39\n  },\n  \"shares\": {\n    \"id\": \"https://floss.social/users/kde/statuses/113306831140126616/shares\",\n    \"type\": \"Collection\",\n    \"totalItems\": 24\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/objects/page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"ostatus\": \"http://ostatus.org#\",\n      \"atomUri\": \"ostatus:atomUri\",\n      \"inReplyToAtomUri\": \"ostatus:inReplyToAtomUri\",\n      \"conversation\": \"ostatus:conversation\",\n      \"sensitive\": \"as:sensitive\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"votersCount\": \"toot:votersCount\"\n    }\n  ],\n  \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519\",\n  \"type\": \"Note\",\n  \"summary\": null,\n  \"inReplyTo\": null,\n  \"published\": \"2023-08-04T09:55:39Z\",\n  \"url\": \"https://masto.qa.urbanwildlife.biz/110830743680706519\",\n  \"attributedTo\": \"https://masto.qa.urbanwildlife.biz/users/mastodon\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\n    \"https://masto.qa.urbanwildlife.biz/users/mastodon/followers\",\n    \"https://enterprise.lemmy.ml/c/tenforward\",\n    \"https://enterprise.lemmy.ml/c/tenforward/followers\"\n  ],\n  \"sensitive\": false,\n  \"atomUri\": \"https://masto.qa.urbanwildlife.biz/statuses/110830743680706519\",\n  \"inReplyToAtomUri\": null,\n  \"conversation\": \"tag:dice.camp,2023-08-04:objectId=29969291:objectType=Conversation\",\n  \"content\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://enterprise.lemmy.ml/c/tenforward\\\" class=\\\"u-url mention\\\">@<span>tenforward</span></a></span> Variable never resetting at refresh</p><p>Hi! I&#39;m using a variable to count elements in my generator but every time I generate a new character, the counter&#39;s value carries on from the previous one. Is there a function to reset it (I set it to 0 at the beginning of the file)</p>\",\n  \"contentMap\": {\n    \"it\": \"<p><span class=\\\"h-card\\\" translate=\\\"no\\\"><a href=\\\"https://enterprise.lemmy.ml/c/tenforward\\\" class=\\\"u-url mention\\\">@<span>tenforward</span></a></span>Variable never resetting at refresh</p><p>Hi! I&#39;m using a variable to count elements in my generator but every time I generate a new character, the counter&#39;s value carries on from the previous one. Is there a function to reset it (I set it to 0 at the beginning of the file)</p>\"\n  },\n  \"attachment\": [],\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://enterprise.lemmy.ml/c/tenforward\",\n      \"name\": \"@tenforward@enterprise.lemmy.ml\"\n    }\n  ],\n  \"replies\": {\n    \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies\",\n    \"type\": \"Collection\",\n    \"first\": {\n      \"type\": \"CollectionPage\",\n      \"next\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies?only_other_accounts=true&page=true\",\n      \"partOf\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies\",\n      \"items\": []\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mastodon/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"featured\": {\n        \"@id\": \"toot:featured\",\n        \"@type\": \"@id\"\n      },\n      \"featuredTags\": {\n        \"@id\": \"toot:featuredTags\",\n        \"@type\": \"@id\"\n      },\n      \"alsoKnownAs\": {\n        \"@id\": \"as:alsoKnownAs\",\n        \"@type\": \"@id\"\n      },\n      \"movedTo\": {\n        \"@id\": \"as:movedTo\",\n        \"@type\": \"@id\"\n      },\n      \"schema\": \"http://schema.org#\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\",\n      \"discoverable\": \"toot:discoverable\",\n      \"Device\": \"toot:Device\",\n      \"Ed25519Signature\": \"toot:Ed25519Signature\",\n      \"Ed25519Key\": \"toot:Ed25519Key\",\n      \"Curve25519Key\": \"toot:Curve25519Key\",\n      \"EncryptedMessage\": \"toot:EncryptedMessage\",\n      \"publicKeyBase64\": \"toot:publicKeyBase64\",\n      \"deviceId\": \"toot:deviceId\",\n      \"claim\": {\n        \"@type\": \"@id\",\n        \"@id\": \"toot:claim\"\n      },\n      \"fingerprintKey\": {\n        \"@type\": \"@id\",\n        \"@id\": \"toot:fingerprintKey\"\n      },\n      \"identityKey\": {\n        \"@type\": \"@id\",\n        \"@id\": \"toot:identityKey\"\n      },\n      \"devices\": {\n        \"@type\": \"@id\",\n        \"@id\": \"toot:devices\"\n      },\n      \"messageFranking\": \"toot:messageFranking\",\n      \"messageType\": \"toot:messageType\",\n      \"cipherText\": \"toot:cipherText\",\n      \"suspended\": \"toot:suspended\",\n      \"focalPoint\": {\n        \"@container\": \"@list\",\n        \"@id\": \"toot:focalPoint\"\n      }\n    }\n  ],\n  \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon\",\n  \"type\": \"Person\",\n  \"following\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/following\",\n  \"followers\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/followers\",\n  \"inbox\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/inbox\",\n  \"outbox\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/outbox\",\n  \"featured\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/collections/featured\",\n  \"featuredTags\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/collections/tags\",\n  \"preferredUsername\": \"mastodon\",\n  \"name\": \"Mastodon\",\n  \"summary\": \"\",\n  \"url\": \"https://masto.qa.urbanwildlife.biz/@mastodon\",\n  \"manuallyApprovesFollowers\": false,\n  \"discoverable\": false,\n  \"published\": \"2022-10-03T00:00:00Z\",\n  \"devices\": \"https://masto.qa.urbanwildlife.biz/users/mastodon/collections/devices\",\n  \"publicKey\": {\n    \"id\": \"https://masto.qa.urbanwildlife.biz/users/mastodon#main-key\",\n    \"owner\": \"https://masto.qa.urbanwildlife.biz/users/mastodon\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtBdE55VmV9gTrhJmRF1K\\neX7xTRo17JGQ7d1/KJWsQ1zH62GGeG/E+BG3h/BRtfgI7Z9jwfNEyx8g/Ue8rSeZ\\n3M7yc09/Z90uwGVY24hxwAJyzWIN2cv5ayhdtk268byT6NX98a9PQcHlx5i6Bhef\\nMlpY73I5gxYlofvwJTHq/VupXVw9K76KId2AgR2z8tLiXPc8TED56HulDWdMlWn3\\n9B4mWNYmzMBF7lOl58Ws6bFsiv8GnI3uEywzUGhXqz4242FGveHdAGBaCpUYrm8W\\nmT8PArqv3B4fCD1ghakSmxRr3y9clwhkC+kB/aoT6z313uZYbQuvZF1bfbh6EZWm\\nIQIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"tag\": [],\n  \"attachment\": [],\n  \"endpoints\": {\n    \"sharedInbox\": \"https://masto.qa.urbanwildlife.biz/inbox\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"mediaType\": \"image/png\",\n    \"url\": \"https://masto.qa.urbanwildlife.biz/system/accounts/avatars/109/105/103/301/739/269/original/2cf61ff96e94cb1d.png\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mbin/activities/accept.json",
    "content": "{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://some-mbin.instance/f/object/2721ffc3-f8a9-417e-a124-af057434a3af#accept\",\n  \"type\": \"Accept\",\n  \"actor\": \"https://some-mbin.instance/m/someMag\",\n  \"object\": {\n    \"id\": \"https://some-other.instance/f/object/c51ea652-e594-4920-a989-f5350f0cec05\",\n    \"type\": \"Follow\",\n    \"actor\": \"https://some-other.instance/u/someUser\",\n    \"object\": \"https://some-mbin.instance/m/someMag\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mbin/activities/flag.json",
    "content": "{\n  \"@context\": [\"https://www.w3.org/ns/activitystreams\"],\n  \"id\": \"https://mbin-test1/reports/45f8a01d-a73e-4575-bffa-c9f24c61f458\",\n  \"type\": \"Flag\",\n  \"actor\": \"https://mbin-test1/u/BentiGorlich\",\n  \"object\": [\"https://lemmy-test/post/4\", \"https://lemmy-test/u/BentiGorlich\"],\n  \"audience\": \"https://lemmy-test/c/test_mag\",\n  \"summary\": \"dikjhgasdpas dsaü\",\n  \"content\": \"dikjhgasdpas dsaü\",\n  \"to\": [\"https://lemmy-test/c/test_mag\"]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mbin/objects/instance.json",
    "content": "{\n  \"@context\": [\"https:\\/\\/fedia.io\\/contexts\"],\n  \"id\": \"https:\\/\\/fedia.io\\/i\\/actor\",\n  \"type\": \"Application\",\n  \"name\": \"Mbin\",\n  \"inbox\": \"https:\\/\\/fedia.io\\/i\\/inbox\",\n  \"outbox\": \"https:\\/\\/fedia.io\\/i\\/outbox\",\n  \"preferredUsername\": \"fedia.io\",\n  \"manuallyApprovesFollowers\": true,\n  \"publicKey\": {\n    \"id\": \"https:\\/\\/fedia.io\\/i\\/actor#main-key\",\n    \"owner\": \"https:\\/\\/fedia.io\\/i\\/actor\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\r\\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4P8hDVpL+DpvNvl5s+S\\r\\nAuRoIl8C00sSfgFfXwMKKxzSEfD2FJBvQuFhu3DSmD26owdItQqKTfMKop7YBvTj\\r\\nvwngDyBCz9nDSBQtaVG4lPuo\\/45Fcdu+jr9SPC7Bd5JFDIejKf86ONDMfFwuz1Ns\\r\\nyf1sj\\/UEVV8fO9CX+aey9E67\\/SZ\\/SxpbU6b02jY7hWN5wGHvIxJifpRabhVIcTja\\r\\nV4CZiv6IObKQQ7h\\/nK4ly2K7CpA9meljldu2CgV0q6QTnJNqNw12yPhUPVC\\/ywOd\\r\\nUhPUaTEeU0DBuJMzxr9bX9fMLlegYuVV6btOp8JAds0C3rUWv\\/bTpymh+CUDL8QL\\r\\nA9KNqJ\\/aFmKZ5z58lRuKW2xJos5ScJnpzkWSHxzC6ZSAvD5zUfCRy42s43qyIcxR\\r\\nyvzjly9vZIzN5YyDG5QE5YOPMVDSR9cRfq2hi+vVxwTSoYn73EKiRZfVOA\\/i6l35\\r\\nXY6EVPUSykk\\/YmOeoKyc4FJQ3ARLBFcI0iOAr1CnseX8KKjGvS3pu3JJdlZEUYv8\\r\\ngO6kPArPa53VDmlFxRL9uPLR2TGKmVjjLO6SY1sGc1jQAfIvbsg5NY2Q503aLVPB\\r\\nnZHo\\/gtR9ugFfJ8SxnZgGXiuzx6L7+6IZgZYnngGK5KV0h0o7YF2umi5fJJeEtHd\\r\\nCKOHcjXz2DAn6MD8BCqbfmMCAwEAAQ==\\r\\n-----END PUBLIC KEY-----\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mobilizon/objects/event.json",
    "content": "{\n  \"timezone\": \"Europe/London\",\n  \"isOnline\": false,\n  \"contacts\": [\"https://rendezvous.nomagic.uk/@emorrp1\"],\n  \"cc\": [\"https://rendezvous.nomagic.uk/@emorrp1/followers\"],\n  \"id\": \"https://rendezvous.nomagic.uk/events/b81c0531-a57c-497d-93ba-af0f8b255498\",\n  \"inLanguage\": \"en\",\n  \"endTime\": \"2022-12-11T21:00:00+00:00\",\n  \"repliesModerationOption\": \"allow_all\",\n  \"content\": \"<p>£6 each.</p><p></p><p>The dance style is like a posh ceilidh, with some exciting new ways to turn your partner, set patterns and learn some reels by heart. Looking forward to sharing the Hamilton House and the Inverness with those of you who can make it along to this one.</p><p></p><p>For anyone unfamiliar with <a target=\\\"_blank\\\" rel=\\\"noopener noreferrer ugc\\\" href=\\\"https://www.exeterceilidhs.net/\\\">ceilidhs</a>, they&#39;re very social and energetic dances that are very accessible because everyone gets told exactly what to do by a caller and when to do it. Here&#39;s a <a target=\\\"_blank\\\" rel=\\\"noopener noreferrer ugc\\\" href=\\\"https://www.facebook.com/emorrp1/videos/735558448180\\\">sample video</a> from when I learned CaledonianDancing at uni. The cover photo is by Dave Conner CC-BY-2.0.</p>\",\n  \"category\": \"SPORTS\",\n  \"actor\": \"https://rendezvous.nomagic.uk/@emorrp1\",\n  \"type\": \"Event\",\n  \"url\": \"https://rendezvous.nomagic.uk/events/b81c0531-a57c-497d-93ba-af0f8b255498\",\n  \"remainingAttendeeCapacity\": null,\n  \"anonymousParticipationEnabled\": true,\n  \"ical:status\": \"CONFIRMED\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"joinMode\": \"free\",\n  \"location\": {\n    \"address\": {\n      \"addressCountry\": \"United Kingdom\",\n      \"addressLocality\": \"Teignbridge\",\n      \"addressRegion\": \"England\",\n      \"postalCode\": \"EX6 7TW\",\n      \"streetAddress\": \"Devon Expressway\",\n      \"type\": \"PostalAddress\"\n    },\n    \"id\": \"https://rendezvous.nomagic.uk/address/e4c95383-15ac-4cc7-adf6-723d74ee2ccc\",\n    \"latitude\": 50.66881615,\n    \"longitude\": -3.537739788359949,\n    \"name\": \"The Kenn Centre\",\n    \"type\": \"Place\"\n  },\n  \"startTime\": \"2022-12-11T19:00:00+00:00\",\n  \"published\": \"2022-09-27T14:33:14Z\",\n  \"draft\": false,\n  \"participantCount\": 0,\n  \"uuid\": \"b81c0531-a57c-497d-93ba-af0f8b255498\",\n  \"maximumAttendeeCapacity\": 0,\n  \"tag\": [\n    {\n      \"href\": \"https://rendezvous.nomagic.uk/tags/dance\",\n      \"name\": \"#Dance\",\n      \"type\": \"Hashtag\"\n    },\n    {\n      \"href\": \"https://rendezvous.nomagic.uk/tags/caledonian\",\n      \"name\": \"#Caledonian\",\n      \"type\": \"Hashtag\"\n    },\n    {\n      \"href\": \"https://rendezvous.nomagic.uk/tags/lesson\",\n      \"name\": \"#Lesson\",\n      \"type\": \"Hashtag\"\n    }\n  ],\n  \"updated\": \"2022-09-27T14:39:18Z\",\n  \"attributedTo\": \"https://rendezvous.nomagic.uk/@devon_caledonian_society\",\n  \"commentsEnabled\": true,\n  \"attachment\": [\n    {\n      \"mediaType\": \"image/jpeg\",\n      \"name\": \"Banner\",\n      \"type\": \"Document\",\n      \"url\": \"https://rendezvous.nomagic.uk/media/cd75bf2f61b66004fe20af4797f5aa847ae1f9ea1c118f53093d6fc4e51a6045.jpg?name=devon_caledonian_society%27s%20banner.jpg\"\n    }\n  ],\n  \"name\": \"Caledonian scottish dance class\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"addressRegion\": \"sc:addressRegion\",\n      \"timezone\": {\n        \"@id\": \"mz:timezone\",\n        \"@type\": \"sc:Text\"\n      },\n      \"isOnline\": {\n        \"@id\": \"mz:isOnline\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"inLanguage\": \"sc:inLanguage\",\n      \"address\": {\n        \"@id\": \"sc:address\",\n        \"@type\": \"sc:PostalAddress\"\n      },\n      \"discoverable\": \"toot:discoverable\",\n      \"repliesModerationOption\": {\n        \"@id\": \"mz:repliesModerationOption\",\n        \"@type\": \"mz:repliesModerationOptionType\"\n      },\n      \"sc\": \"http://schema.org#\",\n      \"mz\": \"https://joinmobilizon.org/ns#\",\n      \"category\": \"sc:category\",\n      \"joinModeType\": {\n        \"@id\": \"mz:joinModeType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"Hashtag\": \"as:Hashtag\",\n      \"propertyID\": \"sc:propertyID\",\n      \"PostalAddress\": \"sc:PostalAddress\",\n      \"discussions\": {\n        \"@id\": \"mz:discussions\",\n        \"@type\": \"@id\"\n      },\n      \"remainingAttendeeCapacity\": \"sc:remainingAttendeeCapacity\",\n      \"streetAddress\": \"sc:streetAddress\",\n      \"anonymousParticipationEnabled\": {\n        \"@id\": \"mz:anonymousParticipationEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"addressLocality\": \"sc:addressLocality\",\n      \"joinMode\": {\n        \"@id\": \"mz:joinMode\",\n        \"@type\": \"mz:joinModeType\"\n      },\n      \"location\": {\n        \"@id\": \"sc:location\",\n        \"@type\": \"sc:Place\"\n      },\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"participantCount\": {\n        \"@id\": \"mz:participantCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"uuid\": \"sc:identifier\",\n      \"maximumAttendeeCapacity\": \"sc:maximumAttendeeCapacity\",\n      \"participationMessage\": {\n        \"@id\": \"mz:participationMessage\",\n        \"@type\": \"sc:Text\"\n      },\n      \"openness\": {\n        \"@id\": \"mz:openness\",\n        \"@type\": \"@id\"\n      },\n      \"members\": {\n        \"@id\": \"mz:members\",\n        \"@type\": \"@id\"\n      },\n      \"events\": {\n        \"@id\": \"mz:events\",\n        \"@type\": \"@id\"\n      },\n      \"resources\": {\n        \"@id\": \"mz:resources\",\n        \"@type\": \"@id\"\n      },\n      \"addressCountry\": \"sc:addressCountry\",\n      \"posts\": {\n        \"@id\": \"mz:posts\",\n        \"@type\": \"@id\"\n      },\n      \"commentsEnabled\": {\n        \"@id\": \"pt:commentsEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"value\": \"sc:value\",\n      \"PropertyValue\": \"sc:PropertyValue\",\n      \"repliesModerationOptionType\": {\n        \"@id\": \"mz:repliesModerationOptionType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"todos\": {\n        \"@id\": \"mz:todos\",\n        \"@type\": \"@id\"\n      },\n      \"ical\": \"http://www.w3.org/2002/12/cal/ical#\",\n      \"postalCode\": \"sc:postalCode\",\n      \"memberCount\": {\n        \"@id\": \"mz:memberCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"@language\": \"und\"\n    }\n  ],\n  \"mediaType\": \"text/html\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mobilizon/objects/group.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"addressRegion\": \"sc:addressRegion\",\n      \"timezone\": {\n        \"@id\": \"mz:timezone\",\n        \"@type\": \"sc:Text\"\n      },\n      \"isOnline\": {\n        \"@id\": \"mz:isOnline\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"inLanguage\": \"sc:inLanguage\",\n      \"address\": {\n        \"@id\": \"sc:address\",\n        \"@type\": \"sc:PostalAddress\"\n      },\n      \"discoverable\": \"toot:discoverable\",\n      \"repliesModerationOption\": {\n        \"@id\": \"mz:repliesModerationOption\",\n        \"@type\": \"mz:repliesModerationOptionType\"\n      },\n      \"sc\": \"http://schema.org#\",\n      \"mz\": \"https://joinmobilizon.org/ns#\",\n      \"category\": \"sc:category\",\n      \"joinModeType\": {\n        \"@id\": \"mz:joinModeType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"Hashtag\": \"as:Hashtag\",\n      \"propertyID\": \"sc:propertyID\",\n      \"PostalAddress\": \"sc:PostalAddress\",\n      \"discussions\": {\n        \"@id\": \"mz:discussions\",\n        \"@type\": \"@id\"\n      },\n      \"remainingAttendeeCapacity\": \"sc:remainingAttendeeCapacity\",\n      \"streetAddress\": \"sc:streetAddress\",\n      \"anonymousParticipationEnabled\": {\n        \"@id\": \"mz:anonymousParticipationEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"addressLocality\": \"sc:addressLocality\",\n      \"joinMode\": {\n        \"@id\": \"mz:joinMode\",\n        \"@type\": \"mz:joinModeType\"\n      },\n      \"location\": {\n        \"@id\": \"sc:location\",\n        \"@type\": \"sc:Place\"\n      },\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"participantCount\": {\n        \"@id\": \"mz:participantCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"uuid\": \"sc:identifier\",\n      \"maximumAttendeeCapacity\": \"sc:maximumAttendeeCapacity\",\n      \"participationMessage\": {\n        \"@id\": \"mz:participationMessage\",\n        \"@type\": \"sc:Text\"\n      },\n      \"openness\": {\n        \"@id\": \"mz:openness\",\n        \"@type\": \"@id\"\n      },\n      \"members\": {\n        \"@id\": \"mz:members\",\n        \"@type\": \"@id\"\n      },\n      \"events\": {\n        \"@id\": \"mz:events\",\n        \"@type\": \"@id\"\n      },\n      \"resources\": {\n        \"@id\": \"mz:resources\",\n        \"@type\": \"@id\"\n      },\n      \"addressCountry\": \"sc:addressCountry\",\n      \"posts\": {\n        \"@id\": \"mz:posts\",\n        \"@type\": \"@id\"\n      },\n      \"commentsEnabled\": {\n        \"@id\": \"pt:commentsEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"value\": \"sc:value\",\n      \"PropertyValue\": \"sc:PropertyValue\",\n      \"repliesModerationOptionType\": {\n        \"@id\": \"mz:repliesModerationOptionType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"todos\": {\n        \"@id\": \"mz:todos\",\n        \"@type\": \"@id\"\n      },\n      \"ical\": \"http://www.w3.org/2002/12/cal/ical#\",\n      \"postalCode\": \"sc:postalCode\",\n      \"memberCount\": {\n        \"@id\": \"mz:memberCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"@language\": \"und\"\n    }\n  ],\n  \"discoverable\": true,\n  \"discussions\": \"https://mobilizon.fr/@contribateliers/discussions\",\n  \"endpoints\": {\n    \"discussions\": \"https://mobilizon.fr/@contribateliers/discussions\",\n    \"events\": \"https://mobilizon.fr/@contribateliers/events\",\n    \"members\": \"https://mobilizon.fr/@contribateliers/members\",\n    \"posts\": \"https://mobilizon.fr/@contribateliers/posts\",\n    \"resources\": \"https://mobilizon.fr/@contribateliers/resources\",\n    \"sharedInbox\": \"https://mobilizon.fr/inbox\",\n    \"todos\": \"https://mobilizon.fr/@contribateliers/todos\"\n  },\n  \"events\": \"https://mobilizon.fr/@contribateliers/events\",\n  \"followers\": \"https://mobilizon.fr/@contribateliers/followers\",\n  \"following\": \"https://mobilizon.fr/@contribateliers/following\",\n  \"icon\": {\n    \"mediaType\": null,\n    \"type\": \"Image\",\n    \"url\": \"https://mobilizon.fr/media/a94f7f8da4b39f6b375f55bd8664abff4ae61d33496df7ee23ad6bf473c3632f.png?name=contribateliers%27s%20avatar.png\"\n  },\n  \"id\": \"https://mobilizon.fr/@contribateliers\",\n  \"image\": {\n    \"mediaType\": null,\n    \"type\": \"Image\",\n    \"url\": \"https://mobilizon.fr/media/7fe251dd5f8b5abcea10c31b655c09afee457efdd49f3087ba78b054b3f0dbeb.jpg?name=contribateliers%27s%20banner.jpg\"\n  },\n  \"inbox\": \"https://mobilizon.fr/@contribateliers/inbox\",\n  \"location\": {\n    \"address\": {\n      \"addressCountry\": null,\n      \"addressLocality\": null,\n      \"addressRegion\": null,\n      \"postalCode\": null,\n      \"streetAddress\": null,\n      \"type\": \"PostalAddress\"\n    },\n    \"id\": \"https://mobilizon.fr/address/935f207e-4c0f-4818-8762-51d6ab2ed27e\",\n    \"name\": null,\n    \"type\": \"Place\"\n  },\n  \"manuallyApprovesFollowers\": false,\n  \"memberCount\": 13,\n  \"members\": \"https://mobilizon.fr/@contribateliers/members\",\n  \"name\": \"Contribateliers\",\n  \"openness\": \"open\",\n  \"outbox\": \"https://mobilizon.fr/@contribateliers/outbox\",\n  \"posts\": \"https://mobilizon.fr/@contribateliers/posts\",\n  \"preferredUsername\": \"contribateliers\",\n  \"publicKey\": {\n    \"id\": \"https://mobilizon.fr/@contribateliers#main-key\",\n    \"owner\": \"https://mobilizon.fr/@contribateliers\",\n    \"publicKeyPem\": \"-----BEGIN RSA PUBLIC KEY-----\\nMIIBCgKCAQEA1laog+0zKOkGdUHfWQ+lIJq5LOwWzGKLeqXzSdvaUzfk2X5Q5gTf\\nbjh7pWJaWo2uxrIeNKRJSpmxeBn/lNR3+OrG05/MiYW6Y42q+ZL18coUDht46u23\\nHH9+fFblmvY905cNslJ4/NouxpN0ai5JytZOzlNnJCan241rS4gkeLAy+LDW6UOd\\nTvDPMJQlrAl8gr+OamRUxd4RL/8ws7/FbqNiAetXmN/5knjkQe5rFi0D/3fQtWEv\\n/kSTG6CmnBhpeKE8eqp1sD0+CMROfOb7ceVIpJvUKAPHsENRE6DQFF9j3wl8AXjd\\ndtGxTyOYYaMXCPyAUBjH/Rt6uV5Bc5x2CQIDAQAB\\n-----END RSA PUBLIC KEY-----\\n\\n\"\n  },\n  \"resources\": \"https://mobilizon.fr/@contribateliers/resources\",\n  \"summary\": \"<p>Des ateliers pour contribuer au libre sans rien y connaître.</p>\",\n  \"todos\": \"https://mobilizon.fr/@contribateliers/todos\",\n  \"type\": \"Group\",\n  \"url\": \"https://mobilizon.fr/@contribateliers\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/mobilizon/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"addressRegion\": \"sc:addressRegion\",\n      \"timezone\": {\n        \"@id\": \"mz:timezone\",\n        \"@type\": \"sc:Text\"\n      },\n      \"isOnline\": {\n        \"@id\": \"mz:isOnline\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"inLanguage\": \"sc:inLanguage\",\n      \"address\": {\n        \"@id\": \"sc:address\",\n        \"@type\": \"sc:PostalAddress\"\n      },\n      \"discoverable\": \"toot:discoverable\",\n      \"repliesModerationOption\": {\n        \"@id\": \"mz:repliesModerationOption\",\n        \"@type\": \"mz:repliesModerationOptionType\"\n      },\n      \"sc\": \"http://schema.org#\",\n      \"mz\": \"https://joinmobilizon.org/ns#\",\n      \"category\": \"sc:category\",\n      \"joinModeType\": {\n        \"@id\": \"mz:joinModeType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"Hashtag\": \"as:Hashtag\",\n      \"propertyID\": \"sc:propertyID\",\n      \"PostalAddress\": \"sc:PostalAddress\",\n      \"discussions\": {\n        \"@id\": \"mz:discussions\",\n        \"@type\": \"@id\"\n      },\n      \"remainingAttendeeCapacity\": \"sc:remainingAttendeeCapacity\",\n      \"streetAddress\": \"sc:streetAddress\",\n      \"anonymousParticipationEnabled\": {\n        \"@id\": \"mz:anonymousParticipationEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"addressLocality\": \"sc:addressLocality\",\n      \"joinMode\": {\n        \"@id\": \"mz:joinMode\",\n        \"@type\": \"mz:joinModeType\"\n      },\n      \"location\": {\n        \"@id\": \"sc:location\",\n        \"@type\": \"sc:Place\"\n      },\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"participantCount\": {\n        \"@id\": \"mz:participantCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"uuid\": \"sc:identifier\",\n      \"maximumAttendeeCapacity\": \"sc:maximumAttendeeCapacity\",\n      \"participationMessage\": {\n        \"@id\": \"mz:participationMessage\",\n        \"@type\": \"sc:Text\"\n      },\n      \"openness\": {\n        \"@id\": \"mz:openness\",\n        \"@type\": \"@id\"\n      },\n      \"members\": {\n        \"@id\": \"mz:members\",\n        \"@type\": \"@id\"\n      },\n      \"events\": {\n        \"@id\": \"mz:events\",\n        \"@type\": \"@id\"\n      },\n      \"resources\": {\n        \"@id\": \"mz:resources\",\n        \"@type\": \"@id\"\n      },\n      \"addressCountry\": \"sc:addressCountry\",\n      \"posts\": {\n        \"@id\": \"mz:posts\",\n        \"@type\": \"@id\"\n      },\n      \"commentsEnabled\": {\n        \"@id\": \"pt:commentsEnabled\",\n        \"@type\": \"sc:Boolean\"\n      },\n      \"value\": \"sc:value\",\n      \"PropertyValue\": \"sc:PropertyValue\",\n      \"repliesModerationOptionType\": {\n        \"@id\": \"mz:repliesModerationOptionType\",\n        \"@type\": \"rdfs:Class\"\n      },\n      \"todos\": {\n        \"@id\": \"mz:todos\",\n        \"@type\": \"@id\"\n      },\n      \"ical\": \"http://www.w3.org/2002/12/cal/ical#\",\n      \"postalCode\": \"sc:postalCode\",\n      \"memberCount\": {\n        \"@id\": \"mz:memberCount\",\n        \"@type\": \"sc:Integer\"\n      },\n      \"@language\": \"und\"\n    }\n  ],\n  \"discoverable\": false,\n  \"discussions\": null,\n  \"endpoints\": {\n    \"discussions\": null,\n    \"events\": null,\n    \"members\": null,\n    \"posts\": null,\n    \"resources\": null,\n    \"sharedInbox\": \"https://mobilizon.fr/inbox\",\n    \"todos\": null\n  },\n  \"events\": null,\n  \"followers\": \"https://mobilizon.fr/@sanof44/followers\",\n  \"following\": \"https://mobilizon.fr/@sanof44/following\",\n  \"id\": \"https://mobilizon.fr/@sanof44\",\n  \"inbox\": \"https://mobilizon.fr/@sanof44/inbox\",\n  \"manuallyApprovesFollowers\": false,\n  \"members\": null,\n  \"name\": \"Sanof44\",\n  \"openness\": \"moderated\",\n  \"outbox\": \"https://mobilizon.fr/@sanof44/outbox\",\n  \"posts\": null,\n  \"preferredUsername\": \"sanof44\",\n  \"publicKey\": {\n    \"id\": \"https://mobilizon.fr/@sanof44#main-key\",\n    \"owner\": \"https://mobilizon.fr/@sanof44\",\n    \"publicKeyPem\": \"-----BEGIN RSA PUBLIC KEY-----\\nMIIBCgKCAQEAneK5zzdQQ/6ElSpPv1mj34IMoIIHcTK+iEjZYd85yPfG4krK5bqI\\nkUw0TUXFekpntLfDSsGohayrvD2WhN2b499y/A9wdl77RVLIAcBfE3UXr/TfDnjh\\nsQEEzV4ghcYaKmXZa/ct2sSt6poT/WhahVweEugfyA75UHgW5VA7nS5URhd7uZUw\\nS2CI8fXigDbJlB9AqcxvR7Uncgsn0JCCt5boP8X1jDrh5PEsqsqePm9ZpxvvX4WD\\n1yib/ZPBsTo50hJgHoA9bLXO14KvAOeIrzgOlJkyjWTQ+rk+5ewXIZuM0ECPEzAC\\nRcpopBjqk07lMxPu1OMG4D+oI0n0K+PgNwIDAQAB\\n-----END RSA PUBLIC KEY-----\\n\\n\"\n  },\n  \"resources\": null,\n  \"summary\": \"\",\n  \"todos\": null,\n  \"type\": \"Person\",\n  \"url\": \"https://mobilizon.fr/@sanof44\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/nodebb/objects/group.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\"\n  ],\n  \"id\": \"https://bb.devnull.land/category/2\",\n  \"url\": \"https://bb.devnull.land/category/2/general-discussion\",\n  \"inbox\": \"https://bb.devnull.land/category/2/inbox\",\n  \"outbox\": \"https://bb.devnull.land/category/2/outbox\",\n  \"type\": \"Group\",\n  \"name\": \"General Discussion\",\n  \"preferredUsername\": \"general\",\n  \"summary\": \"<p>A place to talk about whatever you want</p>\\n<hr /><p dir=\\\"auto\\\">This is a forum category containing topical discussion. You can start new discussions by mentioning this category.</p>\\n\",\n  \"icon\": {\n    \"type\": \"Image\",\n    \"mediaType\": \"image/png\",\n    \"url\": \"https://bb.devnull.land/assets/uploads/category/category-2-icon.png\"\n  },\n  \"publicKey\": {\n    \"id\": \"https://bb.devnull.land/category/2#key\",\n    \"owner\": \"https://bb.devnull.land/category/2\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAunqTJfBQ1PpsKW6/EDGe\\nMFQubvI8vL9VomZBfcgJWbEtCmAwpaR7LP+Du/OcoRDbkm04BQJR0xT2QEOO4YDs\\nZjm520C0O34iw/bUdmnFMJEaFiyJUn7zEaqPIUf3+etPslAdq3rivHXYlpuP2i1U\\nHGvLO4N08k6LDpAbgO5sdXGPP2k2HAo8Sch8PEqdiMj68i5v1cIz4Q0vvPnAf5UY\\nmeMbnQg7yx0o+WPu6QTmd7DFYfGG36LOfRJKpqjzlVXjq2iQhU4XiqHWG0KJvzQ7\\nsIqlAtb3ESVchDamNzHQi6rsMJR9zCZpcUt8VnxiL7B8tVKk4mBoHxkISY9Sj9iO\\nnQIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"endpoints\": {\n    \"sharedInbox\": \"https://bb.devnull.land/inbox\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/nodebb/objects/page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"Emoji\": \"toot:Emoji\"\n    }\n  ],\n  \"id\": \"https://bb.devnull.land/post/1\",\n  \"type\": \"Article\",\n  \"to\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://bb.devnull.land/category/2\"\n  ],\n  \"cc\": [\"https://bb.devnull.land/uid/1/followers\"],\n  \"inReplyTo\": null,\n  \"published\": \"2025-10-07T15:22:42.176Z\",\n  \"updated\": null,\n  \"url\": \"https://bb.devnull.land/post/1\",\n  \"attributedTo\": \"https://bb.devnull.land/uid/1\",\n  \"context\": \"https://bb.devnull.land/topic/1\",\n  \"audience\": \"https://bb.devnull.land/category/2\",\n  \"summary\": \" <h3>Welcome to your brand new NodeBB forum!</h3> <p>This is what a topic and post looks like. As an administrator, you can edit the post's title and content.<br /> To customise your forum, go to the <a href=\\\"https://bb.devnull.land/admin\\\">Administrator Control Panel</a>. You can modify all aspects of your forum there, including installation of third-party plugins.</p> <h4>Additional Resources</h4> <ul> <li><a href=\\\"https://docs.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">NodeBB Documentation</a></li> <li><a href=\\\"https://community.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">Community Support Forum</a></li> <li><a href=\\\"https://github.com/nodebb/nodebb\\\" rel=\\\"nofollow ugc\\\">Project repository</a></li> </ul>\",\n  \"name\": \"Welcome to your NodeBB!\",\n  \"preview\": {\n    \"type\": \"Note\",\n    \"attributedTo\": \"https://bb.devnull.land/uid/1\",\n    \"content\": \"<h3>Welcome to your brand new NodeBB forum!</h3>\\n<p>This is what a topic and post looks like. As an administrator, you can edit the post's title and content.<br />\\nTo customise your forum, go to the <a href=\\\"https://bb.devnull.land/admin\\\">Administrator Control Panel</a>. You can modify all aspects of your forum there, including installation of third-party plugins.</p>\\n<h4>Additional Resources</h4>\\n<ul>\\n<li><a href=\\\"https://docs.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">NodeBB Documentation</a></li>\\n<li><a href=\\\"https://community.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">Community Support Forum</a></li>\\n<li><a href=\\\"https://github.com/nodebb/nodebb\\\" rel=\\\"nofollow ugc\\\">Project repository</a></li>\\n</ul>\\n\",\n    \"published\": \"2025-10-07T15:22:42.176Z\",\n    \"attachment\": []\n  },\n  \"content\": \"<h3>Welcome to your brand new NodeBB forum!</h3>\\n<p>This is what a topic and post looks like. As an administrator, you can edit the post's title and content.<br />\\nTo customise your forum, go to the <a href=\\\"https://bb.devnull.land/admin\\\">Administrator Control Panel</a>. You can modify all aspects of your forum there, including installation of third-party plugins.</p>\\n<h4>Additional Resources</h4>\\n<ul>\\n<li><a href=\\\"https://docs.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">NodeBB Documentation</a></li>\\n<li><a href=\\\"https://community.nodebb.org/\\\" rel=\\\"nofollow ugc\\\">Community Support Forum</a></li>\\n<li><a href=\\\"https://github.com/nodebb/nodebb\\\" rel=\\\"nofollow ugc\\\">Project repository</a></li>\\n</ul>\\n\",\n  \"source\": {\n    \"content\": \"### Welcome to your brand new NodeBB forum!\\n\\nThis is what a topic and post looks like. As an administrator, you can edit the post\\\\'s title and content.\\nTo customise your forum, go to the [Administrator Control Panel](https://bb.devnull.land/admin). You can modify all aspects of your forum there, including installation of third-party plugins.\\n\\n#### Additional Resources\\n\\n* [NodeBB Documentation](https://docs.nodebb.org/)\\n* [Community SupportForum](https://community.nodebb.org/)\\n* [Project repository](https://github.com/nodebb/nodebb)\",\n    \"mediaType\": \"text/markdown\"\n  },\n  \"tag\": [],\n  \"attachment\": [],\n  \"replies\": \"https://bb.devnull.land/post/1/replies\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/nodebb/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\"\n  ],\n  \"id\": \"https://bb.devnull.land/uid/1\",\n  \"url\": \"https://bb.devnull.land/user/julian\",\n  \"followers\": \"https://bb.devnull.land/uid/1/followers\",\n  \"following\": \"https://bb.devnull.land/uid/1/following\",\n  \"inbox\": \"https://bb.devnull.land/uid/1/inbox\",\n  \"outbox\": \"https://bb.devnull.land/uid/1/outbox\",\n  \"type\": \"Person\",\n  \"name\": \"julian\",\n  \"preferredUsername\": \"julian\",\n  \"summary\": \"<p dir=\\\"auto\\\">This is a test account for NodeBB ActivityPub Development</p>\\n\",\n  \"icon\": null,\n  \"image\": null,\n  \"published\": \"2025-10-07T15:22:41.457Z\",\n  \"attachment\": [],\n  \"publicKey\": {\n    \"id\": \"https://bb.devnull.land/uid/1#key\",\n    \"owner\": \"https://bb.devnull.land/uid/1\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArSOkxd0fjmW69jN5CnJ2\\nLLw+A4TGjMKqDVlPUTuY3trkCk4O1jVa3d+kyC+y8CF56VNHOQTjkq4po0b+2k6V\\nDtaGTexrjPVvRqJhk7+3trP6t584jT9IxEXy6hs72CatpJGN3/tEgAfLXpDnw6pa\\nRBbGDX31lri7ssdHcIlR9TioK3+U1RisGKmuuGjuv1WDEiR5arbBbIfzNsfMyjgr\\nMWIR10VkE7axy/ybO8y6kATVtSoq8qQcw2/KJwzqnDPmZPtmbX/93JP6+sIBXS4T\\nf409xGZp3fHVEDRqLFiEAn9rHg4cxRp71DFql2EpRdt/Z1oZGZpN9y4VJRoLXGdC\\nAwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"endpoints\": {\n    \"sharedInbox\": \"https://bb.devnull.land/inbox\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/peertube/activities/announce_video.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"RsaSignature2017\": \"https://w3id.org/security#RsaSignature2017\"\n    }\n  ],\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://tilvids.com/accounts/thelinuxexperiment/followers\"],\n  \"type\": \"Announce\",\n  \"id\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/announces/299\",\n  \"actor\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel\",\n  \"object\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/peertube/objects/group.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"RsaSignature2017\": \"https://w3id.org/security#RsaSignature2017\"\n    },\n    {\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"sc\": \"http://schema.org/\",\n      \"playlists\": {\n        \"@id\": \"pt:playlists\",\n        \"@type\": \"@id\"\n      },\n      \"support\": {\n        \"@type\": \"sc:Text\",\n        \"@id\": \"pt:support\"\n      },\n      \"lemmy\": \"https://join-lemmy.org/ns#\",\n      \"postingRestrictedToMods\": \"lemmy:postingRestrictedToMods\"\n    }\n  ],\n  \"type\": \"Group\",\n  \"id\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel\",\n  \"following\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel/following\",\n  \"followers\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel/followers\",\n  \"playlists\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel/playlists\",\n  \"inbox\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel/inbox\",\n  \"outbox\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel/outbox\",\n  \"preferredUsername\": \"thelinuxexperiment_channel\",\n  \"url\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel\",\n  \"name\": \"The Linux Experiment\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://tilvids.com/inbox\"\n  },\n  \"publicKey\": {\n    \"id\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel#main-key\",\n    \"owner\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7mWF3Il0lE+nWiArDK4B\\n8Z9rUCYR/C9651CcqPFIpHFLkJgoAkYxeMqfCo7lbXil1abaQERjgAYAJtdfObvY\\neqUrHejEHAClFIO5BilyTP8b02RVZX6xxtTNF7jUEePFI0xOtPtt3Yz+YP0c6rz6\\noyCCpqTy8LRfDkD9RATQrYfFxZCQ2yo2SlCoymNrDjoVwPI0XMZWHyMthKcaVwAq\\ni+dYd0pmNUxdY9V042tIg+YwR3mOYvkXCNqy1SDygcIY6N5kdqioFoKxMK3MFApK\\nY7tkfZkZXLlBdzHjjtYGHictaZzNYl4HV6onx//A21w0A7dGimlYd5bYLwz/BteD\\nTwIDAQAB\\n-----END PUBLIC KEY-----\"\n  },\n  \"published\": \"2020-06-30T13:45:17.984Z\",\n  \"icon\": [\n    {\n      \"type\": \"Image\",\n      \"mediaType\": \"image/jpeg\",\n      \"height\": 48,\n      \"width\": 48,\n      \"url\": \"https://tilvids.com/lazy-static/avatars/1bbe97f1-d283-4db4-8bdd-e5320564aff9.jpg\"\n    },\n    {\n      \"type\": \"Image\",\n      \"mediaType\": \"image/jpeg\",\n      \"height\": 120,\n      \"width\": 120,\n      \"url\": \"https://tilvids.com/lazy-static/avatars/13b0214b-edc0-4c5b-a04d-be648a3a370a.jpg\"\n    }\n  ],\n  \"image\": [\n    {\n      \"type\": \"Image\",\n      \"mediaType\": \"image/jpeg\",\n      \"height\": 317,\n      \"width\": 1920,\n      \"url\": \"https://tilvids.com/lazy-static/banners/1a8d6881-30c8-47cb-8576-7af62d869c45.jpg\"\n    }\n  ],\n  \"summary\": \"I'm Nick, and I like to tinker with Linux stuff. I'll bumble through distro reviews, tutorials, and general helpful tidbits and impressions on Linux desktop environments, applications, and news. \\n\\nYou might see a bit of Linux gaming here and there, and some more personal opinion pieces, but in the end, it's more or less all about Linux and FOSS !\\n\\nIf you want to stay up to snuff, follow me on Mastodon: https://mastodon.social/@thelinuxEXP \\n\\nIf you can, consider supporting the channel here: \\nhttps://www.patreon.com/thelinuxexperiment\",\n  \"support\": \"Support the channel on Patreon: \\nhttps://www.patreon.com/thelinuxexperiment\\n\\nSupport on Liberapay:\\nhttps://liberapay.com/TheLinuxExperiment/\",\n  \"postingRestrictedToMods\": true,\n  \"attributedTo\": [\n    {\n      \"type\": \"Person\",\n      \"id\": \"https://tilvids.com/accounts/thelinuxexperiment\"\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/peertube/objects/note.json",
    "content": "{\n  \"type\": \"Note\",\n  \"id\": \"https://video.antopie.org/videos/watch/4294a720-f263-4ea4-9392-cf9cea4d5277/comments/200873\",\n  \"content\": \"@af2@bae.st idk\",\n  \"mediaType\": \"text/markdown\",\n  \"inReplyTo\": \"https://bae.st/objects/87c1cbf5-542a-491d-af57-0414c8648381\",\n  \"updated\": \"2022-04-29T07:52:32.555Z\",\n  \"published\": \"2022-04-29T07:52:32.548Z\",\n  \"url\": \"https://video.antopie.org/videos/watch/4294a720-f263-4ea4-9392-cf9cea4d5277/comments/200873\",\n  \"attributedTo\": \"https://video.antopie.org/accounts/yoge6785555\",\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://bae.st/users/af2\",\n      \"name\": \"@af2@bae.st\"\n    }\n  ],\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://video.antopie.org/accounts/yoge6785555/followers\"],\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"RsaSignature2017\": \"https://w3id.org/security#RsaSignature2017\"\n    },\n    {\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"sc\": \"http://schema.org#\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"uuid\": \"sc:identifier\",\n      \"category\": \"sc:category\",\n      \"licence\": \"sc:license\",\n      \"subtitleLanguage\": \"sc:subtitleLanguage\",\n      \"sensitive\": \"as:sensitive\",\n      \"language\": \"sc:inLanguage\",\n      \"isLiveBroadcast\": \"sc:isLiveBroadcast\",\n      \"liveSaveReplay\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:liveSaveReplay\"\n      },\n      \"permanentLive\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:permanentLive\"\n      },\n      \"Infohash\": \"pt:Infohash\",\n      \"Playlist\": \"pt:Playlist\",\n      \"PlaylistElement\": \"pt:PlaylistElement\",\n      \"originallyPublishedAt\": \"sc:datePublished\",\n      \"views\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:views\"\n      },\n      \"state\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:state\"\n      },\n      \"size\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:size\"\n      },\n      \"fps\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:fps\"\n      },\n      \"startTimestamp\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:startTimestamp\"\n      },\n      \"stopTimestamp\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:stopTimestamp\"\n      },\n      \"position\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:position\"\n      },\n      \"commentsEnabled\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:commentsEnabled\"\n      },\n      \"downloadEnabled\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:downloadEnabled\"\n      },\n      \"waitTranscoding\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:waitTranscoding\"\n      },\n      \"support\": {\n        \"@type\": \"sc:Text\",\n        \"@id\": \"pt:support\"\n      },\n      \"likes\": {\n        \"@id\": \"as:likes\",\n        \"@type\": \"@id\"\n      },\n      \"dislikes\": {\n        \"@id\": \"as:dislikes\",\n        \"@type\": \"@id\"\n      },\n      \"playlists\": {\n        \"@id\": \"pt:playlists\",\n        \"@type\": \"@id\"\n      },\n      \"shares\": {\n        \"@id\": \"as:shares\",\n        \"@type\": \"@id\"\n      },\n      \"comments\": {\n        \"@id\": \"as:comments\",\n        \"@type\": \"@id\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/peertube/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    { \"RsaSignature2017\": \"https://w3id.org/security#RsaSignature2017\" },\n    {\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"sc\": \"http://schema.org/\",\n      \"playlists\": { \"@id\": \"pt:playlists\", \"@type\": \"@id\" },\n      \"support\": { \"@type\": \"sc:Text\", \"@id\": \"pt:support\" },\n      \"lemmy\": \"https://join-lemmy.org/ns#\",\n      \"postingRestrictedToMods\": \"lemmy:postingRestrictedToMods\"\n    }\n  ],\n  \"type\": \"Person\",\n  \"id\": \"https://tilvids.com/accounts/thelinuxexperiment\",\n  \"following\": \"https://tilvids.com/accounts/thelinuxexperiment/following\",\n  \"followers\": \"https://tilvids.com/accounts/thelinuxexperiment/followers\",\n  \"playlists\": \"https://tilvids.com/accounts/thelinuxexperiment/playlists\",\n  \"inbox\": \"https://tilvids.com/accounts/thelinuxexperiment/inbox\",\n  \"outbox\": \"https://tilvids.com/accounts/thelinuxexperiment/outbox\",\n  \"preferredUsername\": \"thelinuxexperiment\",\n  \"url\": \"https://tilvids.com/accounts/thelinuxexperiment\",\n  \"name\": \"The Linux Experiment\",\n  \"endpoints\": { \"sharedInbox\": \"https://tilvids.com/inbox\" },\n  \"publicKey\": {\n    \"id\": \"https://tilvids.com/accounts/thelinuxexperiment#main-key\",\n    \"owner\": \"https://tilvids.com/accounts/thelinuxexperiment\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbMvBSLhwEA3VXQ3TPgd\\nDCeVpicrjGlk5tRg9OMBMY/xRhT4M3T8H2uYMUmIQJubUcooqAImWL7bYyXig0Ms\\nby18vLyAgIR7V7ymvJbJxF2WZV33CC7Ad1yjqLlnhydcG+pWKWqkjP7SXzAy/EHo\\n46OhDQK1+Q6FXfDrLAGEDRq5z+qTi5dh1hi/c9ZvI0+3PBg1IfAf5zLeo1AoydV7\\nvISCm7kyClABwOW3OjPP86SbAlQL6STFOO3s6EdvvVifTkacC/gl8ad8TI8610Wa\\n5wLsjdE8LIky9lLUsFYvVPrJ6v5havxCSmc6W1tkDicitpFylN2X914L36bn609M\\n8QIDAQAB\\n-----END PUBLIC KEY-----\"\n  },\n  \"published\": \"2020-06-30T13:45:17.950Z\",\n  \"icon\": [\n    {\n      \"type\": \"Image\",\n      \"mediaType\": \"image/jpeg\",\n      \"height\": 48,\n      \"width\": 48,\n      \"url\": \"https://tilvids.com/lazy-static/avatars/e74c2c6b-1f6b-4506-9d03-2cbba1635b20.jpg\"\n    },\n    {\n      \"type\": \"Image\",\n      \"mediaType\": \"image/jpeg\",\n      \"height\": 120,\n      \"width\": 120,\n      \"url\": \"https://tilvids.com/lazy-static/avatars/bdaa7218-ba3c-43ba-abd3-cfd081394c18.jpg\"\n    }\n  ],\n  \"summary\": \"I'm Nick, and I like to tinker with Linux stuff. I'll bumble through distro reviews, tutorials, and general helpful tidbits and impressions on Linux desktop environments, applications, and news. \\n\\nYou might see a bit of Linux gaming here and there, and some more personal opinion pieces, but in the end, it's more or less all about Linux and FOSS !\\n\\nIf you want to stay up to snuff, follow me on Mastodon @TheLinuxEXP@mastodon.social\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/peertube/objects/video.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    {\n      \"RsaSignature2017\": \"https://w3id.org/security#RsaSignature2017\"\n    },\n    {\n      \"pt\": \"https://joinpeertube.org/ns#\",\n      \"sc\": \"http://schema.org/\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"category\": \"sc:category\",\n      \"licence\": \"sc:license\",\n      \"subtitleLanguage\": \"sc:subtitleLanguage\",\n      \"automaticallyGenerated\": \"pt:automaticallyGenerated\",\n      \"sensitive\": \"as:sensitive\",\n      \"language\": \"sc:inLanguage\",\n      \"identifier\": \"sc:identifier\",\n      \"isLiveBroadcast\": \"sc:isLiveBroadcast\",\n      \"liveSaveReplay\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:liveSaveReplay\"\n      },\n      \"permanentLive\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:permanentLive\"\n      },\n      \"latencyMode\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:latencyMode\"\n      },\n      \"Infohash\": \"pt:Infohash\",\n      \"tileWidth\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:tileWidth\"\n      },\n      \"tileHeight\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:tileHeight\"\n      },\n      \"tileDuration\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:tileDuration\"\n      },\n      \"aspectRatio\": {\n        \"@type\": \"sc:Float\",\n        \"@id\": \"pt:aspectRatio\"\n      },\n      \"uuid\": {\n        \"@type\": \"sc:identifier\",\n        \"@id\": \"pt:uuid\"\n      },\n      \"originallyPublishedAt\": \"sc:datePublished\",\n      \"uploadDate\": \"sc:uploadDate\",\n      \"hasParts\": \"sc:hasParts\",\n      \"views\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:views\"\n      },\n      \"state\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:state\"\n      },\n      \"size\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:size\"\n      },\n      \"fps\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:fps\"\n      },\n      \"commentsEnabled\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:commentsEnabled\"\n      },\n      \"canReply\": \"pt:canReply\",\n      \"commentsPolicy\": {\n        \"@type\": \"sc:Number\",\n        \"@id\": \"pt:commentsPolicy\"\n      },\n      \"downloadEnabled\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:downloadEnabled\"\n      },\n      \"waitTranscoding\": {\n        \"@type\": \"sc:Boolean\",\n        \"@id\": \"pt:waitTranscoding\"\n      },\n      \"support\": {\n        \"@type\": \"sc:Text\",\n        \"@id\": \"pt:support\"\n      },\n      \"likes\": {\n        \"@id\": \"as:likes\",\n        \"@type\": \"@id\"\n      },\n      \"dislikes\": {\n        \"@id\": \"as:dislikes\",\n        \"@type\": \"@id\"\n      },\n      \"shares\": {\n        \"@id\": \"as:shares\",\n        \"@type\": \"@id\"\n      },\n      \"comments\": {\n        \"@id\": \"as:comments\",\n        \"@type\": \"@id\"\n      },\n      \"PropertyValue\": \"sc:PropertyValue\",\n      \"value\": \"sc:value\"\n    }\n  ],\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://tilvids.com/accounts/thelinuxexperiment/followers\"],\n  \"type\": \"Video\",\n  \"id\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1\",\n  \"name\": \"Mesa, Wayland & X.org in trouble, Debian leaves X, Facebook blocks Linux: Linux & Open Source News\",\n  \"duration\": \"PT1145S\",\n  \"uuid\": \"e7946124-7b72-4ad7-9d22-844a84bb2de1\",\n  \"category\": {\n    \"identifier\": \"15\",\n    \"name\": \"Science & Technology\"\n  },\n  \"licence\": {\n    \"identifier\": \"2\",\n    \"name\": \"Attribution - Share Alike\"\n  },\n  \"language\": {\n    \"identifier\": \"en\",\n    \"name\": \"English\"\n  },\n  \"views\": 360,\n  \"sensitive\": false,\n  \"waitTranscoding\": true,\n  \"state\": 1,\n  \"commentsEnabled\": true,\n  \"canReply\": null,\n  \"commentsPolicy\": 1,\n  \"downloadEnabled\": true,\n  \"published\": \"2025-02-01T11:59:45.094Z\",\n  \"originallyPublishedAt\": \"2025-02-01T11:39:50.000Z\",\n  \"updated\": \"2025-02-04T09:00:50.396Z\",\n  \"tag\": [],\n  \"mediaType\": \"text/markdown\",\n  \"content\": \"Head to https://squarespace.com/thelinuxexperiment to save 10% off your first purchase of a website or domain using code thelinuxexperiment\\r\\n\\r\\nGrab a brand new laptop or desktop running Linux: https://www.tuxedocomputers.com/en# \\r\\n\\r\\n\\r\\n👏 SUPPORT THE CHANNEL:\\r\\nGet access to:\\r\\n- a Daily Linux News show\\r\\n- a weekly patroncast for more personal thoughts\\r\\n- polls on the next topics I cover,\\r\\n- your name in the credits\\r\\n\\r\\nYouTube: https://www.youtube.com/@thelinuxexp/join\\r\\nPatreon: https://www.patreon.com/thelinuxexperiment\\r\\n\\r\\nOr, you can donate whatever you want:\\r\\nhttps://paypal.me/thelinuxexp\\r\\nLiberapay: https://liberapay.com/TheLinuxExperiment/\\r\\n\\r\\n👕 GET TLE MERCH\\r\\nSupport the channel AND get cool new gear: https://the-linux-experiment.creator-spring.com/\\r\\n\\r\\n🏆 FOLLOW ME ON THE FEDIVERSE:\\r\\nMastodon: https://mastodon.social/web/@thelinuxEXP\\r\\nPixelfed: https://pixelfed.social/TLENick\\r\\nPeerTube: https://tilvids.com/c/thelinuxexperiment_channel/videos\\r\\n\\r\\n🎙 LINUX AND OPEN SOURCE NEWS PODCAST:\\r\\nListen to the latestLinux and open source news, with more in depth coverage, and ad-free!  https://podcast.thelinuxexp.com\\r\\n\\r\\nTimestamps:\\r\\n00:00 Intro\\r\\n00:34 Sponsor: Squarespace\\r\\n01:42 Mesa, Wayland and X.org lose their hosting\\r\\n03:57 Debian quits Twitter\\r\\n06:07 GNOME 48 alpha is out\\r\\n08:14 Kernel wifi maintainersteps down without replacement\\r\\n10:15 Facebook blocked posts linked to Linux\\r\\n12:12 OpenAI accuses another model of stealing their stolen work\\r\\n14:13Steam Deck is getting outclassed\\r\\n17:15 Sponsor: Tuxedo Computers\\r\\n18:10 Support the channel\\r\\n\\r\\nLinks:\\r\\n\\r\\nMesa, Wayland and X.org lose their hosting\\r\\nhttps://www.phoronix.com/news/2025-XOrg-FreeDesktop-Cloud\\r\\n\\r\\nDebian quits Twitter\\r\\nhttps://news.itsfoss.com/debian-logs-off-twitter/\\r\\n\\r\\nGNOME 48 alpha is out\\r\\nhttps://discourse.gnome.org/t/gnome-48-alpha-released/26414\\r\\nhttps://download.gnome.org/teams/releng/48.alpha.8/NEWS\\r\\n\\r\\nKernelwifi maintainer steps down without replacement\\r\\nhttps://linuxiac.com/linux-kernel-surpasses-40-million-lines/\\r\\nhttps://www.phoronix.com/news/Linux-Wireless-Maintainer-2025\\r\\n\\r\\nFacebook blocking posts linked to Linux\\r\\nhttps://distrowatch.com/weekly.php?issue=20250127#sitenews\\r\\nhttps://www.theregister.com/2025/01/28/facebook_blocks_distrowatch/\\r\\n\\r\\nOpenAI accuses another model of stealing their stolen work\\r\\nhttps://www.techradar.com/pro/us-navy-bans-use-of-deepseek-in-any-capacity-due-to-potential-security-and-ethical-concerns\\r\\nhttps://www.techradar.com/computing/artificial-intelligence/openai-says-deepseek-used-its-models-illegally-and-it-has-evidence-to-prove-it-new-report-claims\\r\\n\\r\\nSteam Deck is getting outclassed\\r\\nhttps://www.forbes.com/sites/jasonevangelho/2025/01/28/the-steam-deck-suddenly-has-a-serious-switch-2-problem/\",\n  \"support\": \"Support the channel on Patreon: \\r\\nhttps://www.patreon.com/thelinuxexperiment\\r\\n\\r\\nSupport on Liberapay:\\r\\nhttps://liberapay.com/TheLinuxExperiment/\",\n  \"subtitleLanguage\": [],\n  \"icon\": [\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://tilvids.com/lazy-static/thumbnails/904efceb-0715-476f-b0dc-b5fba6769851.jpg\",\n      \"mediaType\": \"image/jpeg\",\n      \"width\": 280,\n      \"height\": 157\n    },\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://tilvids.com/lazy-static/previews/ef6088ee-c83a-4fcf-8be2-58db95ca5135.jpg\",\n      \"mediaType\": \"image/jpeg\",\n      \"width\": 850,\n      \"height\": 480\n    }\n  ],\n  \"preview\": [\n    {\n      \"type\": \"Image\",\n      \"rel\": [\"storyboard\"],\n      \"url\": [\n        {\n          \"mediaType\": \"image/jpeg\",\n          \"href\": \"https://tilvids.com/lazy-static/storyboards/b94d7ec4-97d4-4860-a4aa-220c5cf5beae.jpg\",\n          \"width\": 2112,\n          \"height\": 1188,\n          \"tileWidth\": 192,\n          \"tileHeight\": 108,\n          \"tileDuration\": \"PT10S\"\n        }\n      ]\n    }\n  ],\n  \"aspectRatio\": 1.7778,\n  \"url\": [\n    {\n      \"type\": \"Link\",\n      \"mediaType\": \"text/html\",\n      \"href\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1\"\n    },\n    {\n      \"type\": \"Link\",\n      \"mediaType\": \"application/x-mpegURL\",\n      \"href\": \"https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/2020efb9-9f43-4e37-b268-3470a4bb89cd-master.m3u8\",\n      \"tag\": [\n        {\n          \"type\": \"Infohash\",\n          \"name\": \"bade027756842ecef7a1fb7b437dcaa52eb72350\"\n        },\n        {\n          \"type\": \"Infohash\",\n          \"name\": \"dc1091029454a93ae893b207cfb1e7faf8d4d8b8\"\n        },\n        {\n          \"type\": \"Infohash\",\n          \"name\": \"c83b5123b8dcb1b81b53fbdb4c95903cf61a2022\"\n        },\n        {\n          \"type\": \"Link\",\n          \"name\": \"sha256\",\n          \"mediaType\": \"application/json\",\n          \"href\": \"https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/0c0d34b1-ab46-4fc8-ae02-c97c23bfb2db-segments-sha256.json\"\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"video/mp4\",\n          \"href\": \"https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/0b870685-4461-47a3-8fac-e5531cd8acf5-1080-fragmented.mp4\",\n          \"height\": 1080,\n          \"width\": 1920,\n          \"size\": 245864545,\n          \"fps\": 60,\n          \"attachment\": [\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"audio\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"video\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"peertube_format_flag\",\n              \"value\": \"fragmented\"\n            }\n          ]\n        },\n        {\n          \"type\": \"Link\",\n          \"rel\": [\"metadata\", \"video/mp4\"],\n          \"mediaType\": \"application/json\",\n          \"href\": \"https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729362\",\n          \"height\": 1080,\n          \"width\": 1920,\n          \"fps\": 60\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent\",\n          \"href\": \"https://tilvids.com/lazy-static/torrents/cf3222e4-b9fe-4cb3-8b43-2da8afd83895-1080-hls.torrent\",\n          \"height\": 1080,\n          \"width\": 1920,\n          \"fps\": 60\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent;x-scheme-handler/magnet\",\n          \"href\": \"magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Fcf3222e4-b9fe-4cb3-8b43-2da8afd83895-1080-hls.torrent&xt=urn:btih:f9b4ddffa454ad6a7d5d7000d307c33f84aba1d1&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F0b870685-4461-47a3-8fac-e5531cd8acf5-1080-fragmented.mp4\",\n          \"height\": 1080,\n          \"width\": 1920,\n          \"fps\": 60\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"video/mp4\",\n          \"href\": \"https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/339ea14b-0fb9-495b-870e-218a9a6c22f9-360-fragmented.mp4\",\n          \"height\": 360,\n          \"width\": 640,\n          \"size\": 62546436,\n          \"fps\": 30,\n          \"attachment\": [\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"audio\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"video\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"peertube_format_flag\",\n              \"value\": \"fragmented\"\n            }\n          ]\n        },\n        {\n          \"type\": \"Link\",\n          \"rel\": [\"metadata\", \"video/mp4\"],\n          \"mediaType\": \"application/json\",\n          \"href\": \"https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729352\",\n          \"height\": 360,\n          \"width\": 640,\n          \"fps\": 30\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent\",\n          \"href\": \"https://tilvids.com/lazy-static/torrents/dbdbd47d-42e8-4544-bb78-ae7835312cab-360-hls.torrent\",\n          \"height\": 360,\n          \"width\": 640,\n          \"fps\": 30\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent;x-scheme-handler/magnet\",\n          \"href\": \"magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Fdbdbd47d-42e8-4544-bb78-ae7835312cab-360-hls.torrent&xt=urn:btih:913416ac02f6bbfe7bb46e0b19bfe2a4a48d40b8&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F339ea14b-0fb9-495b-870e-218a9a6c22f9-360-fragmented.mp4\",\n          \"height\": 360,\n          \"width\": 640,\n          \"fps\": 30\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"video/mp4\",\n          \"href\": \"https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/15585bf4-ff07-4687-8c01-537922958877-144-fragmented.mp4\",\n          \"height\": 144,\n          \"width\": 256,\n          \"size\": 31021375,\n          \"fps\": 30,\n          \"attachment\": [\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"audio\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"ffprobe_codec_type\",\n              \"value\": \"video\"\n            },\n            {\n              \"type\": \"PropertyValue\",\n              \"name\": \"peertube_format_flag\",\n              \"value\": \"fragmented\"\n            }\n          ]\n        },\n        {\n          \"type\": \"Link\",\n          \"rel\": [\"metadata\", \"video/mp4\"],\n          \"mediaType\": \"application/json\",\n          \"href\": \"https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729356\",\n          \"height\": 144,\n          \"width\": 256,\n          \"fps\": 30\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent\",\n          \"href\": \"https://tilvids.com/lazy-static/torrents/f8a4e994-7be7-46b5-b823-29e041baf687-144-hls.torrent\",\n          \"height\": 144,\n          \"width\": 256,\n          \"fps\": 30\n        },\n        {\n          \"type\": \"Link\",\n          \"mediaType\": \"application/x-bittorrent;x-scheme-handler/magnet\",\n          \"href\": \"magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Ff8a4e994-7be7-46b5-b823-29e041baf687-144-hls.torrent&xt=urn:btih:6594dbb8a43e77ae7565fcd5744019f630c97706&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F15585bf4-ff07-4687-8c01-537922958877-144-fragmented.mp4\",\n          \"height\": 144,\n          \"width\": 256,\n          \"fps\": 30\n        }\n      ]\n    },\n    {\n      \"type\": \"Link\",\n      \"name\": \"tracker-http\",\n      \"rel\": [\"tracker\", \"http\"],\n      \"href\": \"https://tilvids.com/tracker/announce\"\n    },\n    {\n      \"type\": \"Link\",\n      \"name\": \"tracker-websocket\",\n      \"rel\": [\"tracker\", \"websocket\"],\n      \"href\": \"wss://tilvids.com:443/tracker/socket\"\n    }\n  ],\n  \"likes\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/likes\",\n  \"dislikes\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/dislikes\",\n  \"shares\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/announces\",\n  \"comments\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/comments\",\n  \"hasParts\": \"https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/chapters\",\n  \"attributedTo\": [\n    {\n      \"type\": \"Person\",\n      \"id\": \"https://tilvids.com/accounts/thelinuxexperiment\"\n    },\n    {\n      \"type\": \"Group\",\n      \"id\": \"https://tilvids.com/video-channels/thelinuxexperiment_channel\"\n    }\n  ],\n  \"isLiveBroadcast\": false,\n  \"liveSaveReplay\": null,\n  \"permanentLive\": null,\n  \"latencyMode\": null\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/activities/create_note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://greenish.red/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"actor\": \"https://greenish.red/users/nutomic\",\n  \"cc\": [\"https://greenish.red/users/nutomic/followers\"],\n  \"context\": \"https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56\",\n  \"context_id\": 6336544,\n  \"directMessage\": false,\n  \"id\": \"https://greenish.red/activities/db61d52b-9c35-486a-bf27-bbd4edc6c6a1\",\n  \"object\": {\n    \"actor\": \"https://greenish.red/users/nutomic\",\n    \"attachment\": [],\n    \"attributedTo\": \"https://greenish.red/users/nutomic\",\n    \"cc\": [\"https://greenish.red/users/nutomic/followers\"],\n    \"content\": \"<span class=\\\"h-card\\\"><a class=\\\"u-url mention\\\" data-user=\\\"ACimPLEXPDd7enu3cm\\\" href=\\\"https://enterprise.lemmy.ml/u/picard\\\" rel=\\\"ugc\\\">@<span>lanodan</span></a></span> test\",\n    \"context\": \"https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56\",\n    \"conversation\": \"https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56\",\n    \"id\": \"https://greenish.red/objects/1a522f2e-d5ab-454b-93d7-e58bc0650c2a\",\n    \"inReplyTo\": \"https://enterprise.lemmy.ml/post/55143\",\n    \"published\": \"2021-10-26T10:28:35.602455Z\",\n    \"sensitive\": false,\n    \"source\": \"@lanodan@ds9.lemmy.ml test\",\n    \"summary\": \"\",\n    \"tag\": [\n      {\n        \"href\": \"https://enterprise.lemmy.ml/u/picard\",\n        \"name\": \"@lanodan@ds9.lemmy.ml\",\n        \"type\": \"Mention\"\n      }\n    ],\n    \"to\": [\n      \"https://enterprise.lemmy.ml/u/picard\",\n      \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"type\": \"Note\"\n  },\n  \"published\": \"2021-10-26T10:28:35.595650Z\",\n  \"to\": [\n    \"https://enterprise.lemmy.ml/u/picard\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"type\": \"Create\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/activities/delete.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://greenish.red/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"actor\": \"https://greenish.red/users/vpzom\",\n  \"attachment\": [],\n  \"attributedTo\": \"https://greenish.red/users/vpzom\",\n  \"cc\": [],\n  \"conversation\": null,\n  \"id\": \"https://greenish.red/activities/52f0b259-596e-429f-8a1b-c0b455f8932b\",\n  \"object\": \"https://greenish.red/objects/38e2b983-ebf5-4387-9bc2-3b80305469c9\",\n  \"tag\": [\n    {\n      \"href\": \"https://voyager.lemmy.ml/c/main\",\n      \"name\": \"@main@voyager.lemmy.ml\",\n      \"type\": \"Mention\"\n    },\n    {\n      \"href\": \"https://voyager.lemmy.ml/u/dess_voy_41u2\",\n      \"name\": \"@dess_voy_41u2@voyager.lemmy.ml\",\n      \"type\": \"Mention\"\n    }\n  ],\n  \"to\": [\n    \"https://greenish.red/users/vpzom/followers\",\n    \"https://voyager.lemmy.ml/c/main\",\n    \"https://voyager.lemmy.ml/u/dess_voy_41u2\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"type\": \"Delete\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/activities/follow.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://mycrowd.ca/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"actor\": \"https://mycrowd.ca/users/kinetix\",\n  \"cc\": [],\n  \"id\": \"https://mycrowd.ca/activities/dab6a4d3-0db0-41ee-8aab-7bfa4929b4fd\",\n  \"object\": \"https://lemmy.ca/u/kinetix\",\n  \"state\": \"pending\",\n  \"to\": [\"https://lemmy.ca/u/kinetix\"],\n  \"type\": \"Follow\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/objects/chat_message.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://queer.hacktivis.me/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"attributedTo\": \"https://queer.hacktivis.me/users/lanodan\",\n  \"content\": \"Hi!\",\n  \"id\": \"https://queer.hacktivis.me/objects/2\",\n  \"published\": \"2020-02-12T14:08:20Z\",\n  \"to\": [\"https://enterprise.lemmy.ml/u/picard\"],\n  \"type\": \"ChatMessage\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/objects/note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://queer.hacktivis.me/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"actor\": \"https://queer.hacktivis.me/users/lanodan\",\n  \"attachment\": [],\n  \"attributedTo\": \"https://queer.hacktivis.me/users/lanodan\",\n  \"cc\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"content\": \"Have what?\",\n  \"context\": \"https://queer.hacktivis.me/contexts/34cba3d2-2f35-4169-aeff-56af9bfeb753\",\n  \"conversation\": \"https://queer.hacktivis.me/contexts/34cba3d2-2f35-4169-aeff-56af9bfeb753\",\n  \"id\": \"https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2\",\n  \"inReplyTo\": \"https://enterprise.lemmy.ml/post/55143\",\n  \"published\": \"2021-10-07T18:06:52.555500Z\",\n  \"sensitive\": null,\n  \"source\": \"@popolon@pleroma.popolon.org Have what?\",\n  \"summary\": \"\",\n  \"tag\": [\n    {\n      \"href\": \"https://pleroma.popolon.org/users/popolon\",\n      \"name\": \"@popolon@pleroma.popolon.org\",\n      \"type\": \"Mention\"\n    }\n  ],\n  \"to\": [\n    \"https://pleroma.popolon.org/users/popolon\",\n    \"https://queer.hacktivis.me/users/lanodan/followers\"\n  ],\n  \"type\": \"Note\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/pleroma/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://queer.hacktivis.me/schemas/litepub-0.1.jsonld\",\n    {\n      \"@language\": \"und\"\n    }\n  ],\n  \"alsoKnownAs\": [],\n  \"attachment\": [],\n  \"capabilities\": {\n    \"acceptsChatMessages\": true\n  },\n  \"discoverable\": false,\n  \"endpoints\": {\n    \"oauthAuthorizationEndpoint\": \"https://queer.hacktivis.me/oauth/authorize\",\n    \"oauthRegistrationEndpoint\": \"https://queer.hacktivis.me/api/v1/apps\",\n    \"oauthTokenEndpoint\": \"https://queer.hacktivis.me/oauth/token\",\n    \"sharedInbox\": \"https://queer.hacktivis.me/inbox\",\n    \"uploadMedia\": \"https://queer.hacktivis.me/api/ap/upload_media\"\n  },\n  \"featured\": \"https://queer.hacktivis.me/users/lanodan/collections/featured\",\n  \"followers\": \"https://queer.hacktivis.me/users/lanodan/followers\",\n  \"following\": \"https://queer.hacktivis.me/users/lanodan/following\",\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://queer.hacktivis.me/media/d23cf9b0-5586-4592-aca5-9a52777a6042/avatar_HD.png\"\n  },\n  \"id\": \"https://queer.hacktivis.me/users/lanodan\",\n  \"image\": {\n    \"type\": \"Image\",\n    \"url\": \"https://queer.hacktivis.me/media/37b6ce56-8c24-4e64-bd70-a76e84ab0c69/53a48a3a49ed5e5637a84e4f3663df17f8d764244bbc1027ba03cfc446e8b7bd.jpg\"\n  },\n  \"inbox\": \"https://queer.hacktivis.me/users/lanodan/inbox\",\n  \"manuallyApprovesFollowers\": false,\n  \"name\": \"Haelwenn /элвэн/ :bzh: \",\n  \"outbox\": \"https://queer.hacktivis.me/users/lanodan/outbox\",\n  \"preferredUsername\": \"lanodan\",\n  \"publicKey\": {\n    \"id\": \"https://queer.hacktivis.me/users/lanodan#main-key\",\n    \"owner\": \"https://queer.hacktivis.me/users/lanodan\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsWOgdjSMc010qvxC3njI\\nXJlFWMJ5gJ8QXCW/PajYdsHPM6d+jxBNJ6zp9/tIRa2m7bWHTSkuHQ7QthOpt6vu\\n+dAWpKRLS607SPLItn/qUcyXvgN+H8shfyhMxvkVs9jXdtlBsLUVE7UNpN0dxzqe\\nI79QWbf7o4amgaIWGRYB+OYMnIxKt+GzIkivZdSVSYjfxNnBYkMCeUxm5EpPIxKS\\nP5bBHAVRRambD5NUmyKILuC60/rYuc/C+vmgpY2HCWFS2q6o34dPr9enwL6t4b3m\\nS1t/EJHk9rGaaDqSGkDEfyQI83/7SDebWKuETMKKFLZi1vMgQIFuOYCIhN6bIiZm\\npQIDAQAB\\n-----END PUBLIC KEY-----\\n\\n\"\n  },\n  \"summary\": \"---Lang: Français(natif), English(fluent), LSF(🤏~👌), русский (еле-еле), <br/>Politics: Anarchist as in DIY/DIWO, freedom of association, anti-authoritarian, anti-identitarianism<br/><br/>Pronouns: meh, pick any, have fun<br/>Timezone: Let&#39;s say Mars, I have a non-24h cycle<br/>```<br/>🦊🦄⚧🂡ⓥ :anarchy: 👿🐧 :gentoo:<br/>Pleroma maintainer (mostly backend)<br/>BadWolf developer<br/>Gentoo contributor<br/><br/>Dayjob: yogoko.fr<br/><br/>That person which uses HJKL in games<br/><br/>Just because computer bad: X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*<br/><br/>banner from: <a href=\\\"https://soc.flyingcube.tech/objects/56f79be2-9013-4559-9826-f7dc392417db\\\">https://soc.flyingcube.tech/objects/56f79be2-9013-4559-9826-f7dc392417db</a><br/>Federation-bots: <a class=\\\"hashtag\\\" data-tag=\\\"nobot\\\" href=\\\"https://queer.hacktivis.me/tag/nobot\\\">#nobot</a>\",\n  \"tag\": [\n    {\n      \"icon\": {\n        \"type\": \"Image\",\n        \"url\": \"https://queer.hacktivis.me/emoji/custom/symbols/anarchy.png\"\n      },\n      \"id\": \"https://queer.hacktivis.me/emoji/custom/symbols/anarchy.png\",\n      \"name\": \":anarchy:\",\n      \"type\": \"Emoji\",\n      \"updated\": \"1970-01-01T00:00:00Z\"\n    },\n    {\n      \"icon\": {\n        \"type\": \"Image\",\n        \"url\": \"https://queer.hacktivis.me/emoji/custom/mastodon.xyz/bzh.png\"\n      },\n      \"id\": \"https://queer.hacktivis.me/emoji/custom/mastodon.xyz/bzh.png\",\n      \"name\": \":bzh:\",\n      \"type\": \"Emoji\",\n      \"updated\": \"1970-01-01T00:00:00Z\"\n    },\n    {\n      \"icon\": {\n        \"type\": \"Image\",\n        \"url\": \"https://queer.hacktivis.me/emoji/custom/gentoo.png\"\n      },\n      \"id\": \"https://queer.hacktivis.me/emoji/custom/gentoo.png\",\n      \"name\": \":gentoo:\",\n      \"type\": \"Emoji\",\n      \"updated\": \"1970-01-01T00:00:00Z\"\n    }\n  ],\n  \"type\": \"Person\",\n  \"url\": \"https://queer.hacktivis.me/users/lanodan\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/smithereen/activities/create_note.json",
    "content": "{\n  \"type\": \"Create\",\n  \"id\": \"https://friends.grishka.me/posts/66561/activityCreate\",\n  \"published\": \"2021-11-09T11:42:35Z\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://ds9.lemmy.ml/u/nutomic\"],\n  \"actor\": \"https://friends.grishka.me/users/1\",\n  \"object\": {\n    \"type\": \"Note\",\n    \"id\": \"https://friends.grishka.me/posts/66561\",\n    \"attributedTo\": \"https://friends.grishka.me/users/1\",\n    \"content\": \"<p>So does this federate now?</p>\",\n    \"inReplyTo\": \"https://ds9.lemmy.ml/post/1723\",\n    \"published\": \"2021-11-09T11:42:35Z\",\n    \"tag\": [\n      {\n        \"type\": \"Mention\",\n        \"href\": \"https://ds9.lemmy.ml/u/nutomic\"\n      }\n    ],\n    \"url\": \"https://friends.grishka.me/posts/66561\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\"https://ds9.lemmy.ml/u/nutomic\"],\n    \"replies\": {\n      \"type\": \"Collection\",\n      \"id\": \"https://friends.grishka.me/posts/66561/replies\",\n      \"first\": {\n        \"type\": \"CollectionPage\",\n        \"partOf\": \"https://friends.grishka.me/posts/66561/replies\",\n        \"next\": \"https://friends.grishka.me/posts/66561/replies?page=1\"\n      }\n    },\n    \"sensitive\": false,\n    \"likes\": \"https://friends.grishka.me/posts/66561/likes\"\n  },\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"sensitive\": \"as:sensitive\"\n    }\n  ],\n  \"signature\": {\n    \"creator\": \"https://friends.grishka.me/users/1#main-key\",\n    \"created\": \"2021-11-09T11:42:35Z\",\n    \"type\": \"RsaSignature2017\",\n    \"signatureValue\": \"MmEf4hjfwfQbm/W8qfONwf0uEXO4dhKApX8PlodSNi9x6E4kEgBvx7BrKg3gtqnXfU/cbGdVIN/yCz8+v7Tp2T2kj1yRpD7WjbgwzkrOlhxLi3zPXd4En/cVVdZYSfc7R6DGflXOSeOZPnKbrmY6i+1kYkM80Yc+LFtoj0Ftdgc/YbwMynt1OwPvDbB5bJo1NVyRnpNqlqia2VNmdAh1+2vREXZmINsCOFMC5c0RVzEENYMw+ZPsbVdXfoz4wfqK2u2i7SlcDKVErVNPrKn71wfGWRRiLUNupokY1x3jsWeZlPqGvAP3WGS9ChU+FxhnVHbtxIf0QmeOas3okLDSjw==\"\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/smithereen/objects/note.json",
    "content": "{\n  \"type\": \"Note\",\n  \"id\": \"https://friends.grishka.me/posts/66561\",\n  \"attributedTo\": \"https://friends.grishka.me/users/1\",\n  \"content\": \"<p>So does this federate now?</p>\",\n  \"inReplyTo\": \"https://ds9.lemmy.ml/post/1723\",\n  \"published\": \"2021-11-09T11:42:35Z\",\n  \"tag\": [\n    {\n      \"type\": \"Mention\",\n      \"href\": \"https://ds9.lemmy.ml/u/nutomic\"\n    }\n  ],\n  \"url\": \"https://friends.grishka.me/posts/66561\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://ds9.lemmy.ml/u/nutomic\"],\n  \"replies\": {\n    \"type\": \"Collection\",\n    \"id\": \"https://friends.grishka.me/posts/66561/replies\",\n    \"first\": {\n      \"type\": \"CollectionPage\",\n      \"partOf\": \"https://friends.grishka.me/posts/66561/replies\",\n      \"next\": \"https://friends.grishka.me/posts/66561/replies?page=1\"\n    }\n  },\n  \"sensitive\": false,\n  \"likes\": \"https://friends.grishka.me/posts/66561/likes\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"sensitive\": \"as:sensitive\"\n    }\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/smithereen/objects/person.json",
    "content": "{\n  \"type\": \"Person\",\n  \"id\": \"https://friends.grishka.me/users/1\",\n  \"name\": \"Григорий Клюшников\",\n  \"icon\": {\n    \"type\": \"Image\",\n    \"image\": {\n      \"type\": \"Image\",\n      \"url\": \"https://friends.grishka.me/i/6QLsOws97AWp5N_osd74C1IC1ijnFopyCBD9MSEeXNQ/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg\",\n      \"mediaType\": \"image/jpeg\",\n      \"width\": 1280,\n      \"height\": 960\n    },\n    \"width\": 573,\n    \"height\": 572,\n    \"cropRegion\": [\n      0.26422762870788574, 0.3766937553882599, 0.7113820910453796,\n      0.9728997349739075\n    ],\n    \"url\": \"https://friends.grishka.me/i/ql_49PQcETAWgY_nC-Qj63H_Oa6FyOAEoWFkUSSkUvQ/c:573:572:nowe:338:362/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg\",\n    \"mediaType\": \"image/jpeg\"\n  },\n  \"summary\": \"<p>Делаю эту хрень, пытаюсь вырвать социальные сети из жадных лап корпораций</p>\\n<p></p>\\n<p></p>\\n<p></p>\\n<p>This server does NOT support direct messages. Please write me <a href=\\\"https://t.me/grishka\\\">on Telegram</a> or Matrix (@grishk:matrix<span>.org</span>).</p>\",\n  \"url\": \"https://friends.grishka.me/grishka\",\n  \"preferredUsername\": \"grishka\",\n  \"inbox\": \"https://friends.grishka.me/users/1/inbox\",\n  \"outbox\": \"https://friends.grishka.me/users/1/outbox\",\n  \"followers\": \"https://friends.grishka.me/users/1/followers\",\n  \"following\": \"https://friends.grishka.me/users/1/following\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://friends.grishka.me/activitypub/sharedInbox\"\n  },\n  \"publicKey\": {\n    \"id\": \"https://friends.grishka.me/users/1#main-key\",\n    \"owner\": \"https://friends.grishka.me/users/1\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjlakm+i/d9ER/hIeR7KfiFW+SdLZj2SkKIeM8cmR+YFJuh9ghFqXrkFEjcaqUnAFqe5gYDNSQACnDLA8y4DnzjfGNIohKAnRoa9x6GORmfKQvcnjaTZ53S1NvUiPPyc0Pv/vfCtY/Ab0CEXe5BLqL38oZn817Jf7pBrPRTYH7m012kvwAUTT6k0Y8lPITBEG7nzYbbuGcrN9Y/RDdwE08jmBXlZ45bahRH3VNXVpQE17dCzJB+7k+iJ1R7YCoI+DuMlBYGXGE2KVk46NZTuLnOjFV9SyXfWX4/SrJM4oxev+SX2N75tQgmNZmVVHeqg2ZcbC0WCfNjJOi2HHS9MujwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"wall\": \"https://friends.grishka.me/users/1/wall\",\n  \"firstName\": \"Григорий\",\n  \"lastName\": \"Клюшников\",\n  \"middleName\": \"Александрович\",\n  \"vcard:bday\": \"1993-01-22\",\n  \"gender\": \"http://schema.org#Male\",\n  \"supportsFriendRequests\": true,\n  \"friends\": \"https://friends.grishka.me/users/1/friends\",\n  \"groups\": \"https://friends.grishka.me/users/1/groups\",\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"sm\": \"http://smithereen.software/ns#\",\n      \"cropRegion\": {\n        \"@id\": \"sm:cropRegion\",\n        \"@container\": \"@list\"\n      },\n      \"wall\": {\n        \"@id\": \"sm:wall\",\n        \"@type\": \"@id\"\n      },\n      \"sc\": \"http://schema.org#\",\n      \"firstName\": \"sc:givenName\",\n      \"lastName\": \"sc:familyName\",\n      \"middleName\": \"sc:additionalName\",\n      \"gender\": {\n        \"@id\": \"sc:gender\",\n        \"@type\": \"sc:GenderType\"\n      },\n      \"supportsFriendRequests\": \"sm:supportsFriendRequests\",\n      \"maidenName\": \"sm:maidenName\",\n      \"friends\": {\n        \"@id\": \"sm:friends\",\n        \"@type\": \"@id\"\n      },\n      \"groups\": {\n        \"@id\": \"sm:groups\",\n        \"@type\": \"@id\"\n      },\n      \"vcard\": \"http://www.w3.org/2006/vcard/ns#\"\n    },\n    \"https://w3id.org/security/v1\"\n  ]\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/wordpress/activities/announce.json",
    "content": "{\n  \"@context\": [\"https://www.w3.org/ns/activitystreams\"],\n  \"id\": \"https://pfefferle.org/lemmy-part-4/#activity#activity\",\n  \"type\": \"Announce\",\n  \"audience\": \"https://pfefferle.org/@pfefferle.org\",\n  \"published\": \"2024-05-03T12:32:29Z\",\n  \"updated\": \"2024-05-06T08:20:33Z\",\n  \"to\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers\"\n  ],\n  \"cc\": [],\n  \"object\": {\n    \"id\": \"https://pfefferle.org/lemmy-part-4/#activity\",\n    \"type\": \"Update\",\n    \"audience\": \"https://pfefferle.org/@pfefferle.org\",\n    \"published\": \"2024-05-03T12:32:29Z\",\n    \"updated\": \"2024-05-06T08:20:33Z\",\n    \"to\": [\n      \"https://www.w3.org/ns/activitystreams#Public\",\n      \"https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers\"\n    ],\n    \"cc\": [],\n    \"object\": {\n      \"id\": \"https://pfefferle.org/lemmy-part-4/\",\n      \"type\": \"Article\",\n      \"attachment\": [],\n      \"attributedTo\": \"https://pfefferle.org/author/pfefferle/\",\n      \"audience\": \"https://pfefferle.org/@pfefferle.org\",\n      \"content\": \"\\u003Cp\\u003EIdentifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.          \\u003C/p\\u003E\",\n      \"contentMap\": {\n        \"en\": \"\\u003Cp\\u003EIdentifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.          \\u003C/p\\u003E\"\n      },\n      \"name\": \"Lemmy (Part 4)\",\n      \"published\": \"2024-05-03T12:32:29Z\",\n      \"summary\": \"Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object [...]\",\n      \"tag\": [],\n      \"updated\": \"2024-05-06T08:20:33Z\",\n      \"url\": \"https://pfefferle.org/lemmy-part-4/\",\n      \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers\"\n      ],\n      \"cc\": []\n    },\n    \"actor\": \"https://pfefferle.org/author/pfefferle/\"\n  },\n  \"actor\": \"https://pfefferle.org/@pfefferle.org\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/wordpress/objects/group.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    \"https://purl.archive.org/socialweb/webfinger\",\n    {\n      \"schema\": \"http://schema.org#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"lemmy\": \"https://join-lemmy.org/ns#\",\n      \"litepub\": \"http://litepub.social/ns#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"featured\": {\n        \"@id\": \"toot:featured\",\n        \"@type\": \"@id\"\n      },\n      \"featuredTags\": {\n        \"@id\": \"toot:featuredTags\",\n        \"@type\": \"@id\"\n      },\n      \"moderators\": {\n        \"@id\": \"lemmy:moderators\",\n        \"@type\": \"@id\"\n      },\n      \"alsoKnownAs\": {\n        \"@id\": \"as:alsoKnownAs\",\n        \"@type\": \"@id\"\n      },\n      \"movedTo\": {\n        \"@id\": \"as:movedTo\",\n        \"@type\": \"@id\"\n      },\n      \"attributionDomains\": {\n        \"@id\": \"toot:attributionDomains\",\n        \"@type\": \"@id\"\n      },\n      \"implements\": {\n        \"@id\": \"https://w3id.org/fep/844e/implements\",\n        \"@type\": \"@id\",\n        \"@container\": \"@list\"\n      },\n      \"postingRestrictedToMods\": \"lemmy:postingRestrictedToMods\",\n      \"discoverable\": \"toot:discoverable\",\n      \"indexable\": \"toot:indexable\",\n      \"invisible\": \"litepub:invisible\"\n    }\n  ],\n  \"generator\": {\n    \"type\": \"Application\",\n    \"implements\": [\n      {\n        \"href\": \"https://datatracker.ietf.org/doc/html/rfc9421\",\n        \"name\": \"RFC-9421: HTTP Message Signatures\"\n      }\n    ]\n  },\n  \"type\": \"Group\",\n  \"inbox\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/inbox\",\n  \"outbox\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/outbox\",\n  \"following\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/following\",\n  \"followers\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/followers\",\n  \"streams\": [],\n  \"preferredUsername\": \"dbzer0.com\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://dbzer0.com/wp-json/activitypub/1.0/inbox\"\n  },\n  \"publicKey\": {\n    \"id\": \"https://dbzer0.com/?author=0#main-key\",\n    \"owner\": \"https://dbzer0.com/?author=0\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEI3WTumR109MNB4O/YJ\\nyUQO5i1+dlftm9TmRFkgH+3cTQ1yR4Xhp/V6XFq4P+y/s8HUhFWlckr2BWY15qHJ\\nnlzSWY2wtivhl/vQ6ZqlngGwS9uhsai+a090eGNrrwSB/jIKp4N7JPe8n06SVDfQ\\nmu9BNyWrhqvHIkQsC4fgSHQrwEfH1hQx2KdE4J0hMJSPkLm1m2Pd5FRZVdxLZqMU\\nWGCWn/wB5fTRb/2PpnxMrSETxEHL7hoI5HqPGaEGPvJUFLLKOPfYLlTJ/d5E0W3T\\n5EThDUH811JWBvFhYNHltPu7J7FhrqAClxDXJsFyrxwDIN1YYEqI9P33z6KedgJi\\nVQIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"manuallyApprovesFollowers\": false,\n  \"attributionDomains\": [\"dbzer0.com\"],\n  \"alsoKnownAs\": [\n    \"https://dbzer0.com/?author=0\",\n    \"https://dbzer0.com\",\n    \"https://dbzer0.com/\"\n  ],\n  \"featured\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/collections/featured\",\n  \"featuredTags\": \"https://dbzer0.com/wp-json/activitypub/1.0/actors/0/collections/tags\",\n  \"discoverable\": true,\n  \"indexable\": true,\n  \"webfinger\": \"dbzer0.com@dbzer0.com\",\n  \"moderators\": \"https://dbzer0.com/wp-json/activitypub/1.0/collections/moderators\",\n  \"postingRestrictedToMods\": true,\n  \"attachment\": [\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Blog\",\n      \"value\": \"<p><a href=\\\"https://dbzer0.com/\\\" target=\\\"_blank\\\" rel=\\\"nofollow noopener noreferrer me\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">dbzer0.com/</span><span class=\\\"invisible\\\"></span></a></p>\"\n    },\n    {\n      \"type\": \"Link\",\n      \"name\": \"Blog\",\n      \"href\": \"https://dbzer0.com/\",\n      \"rel\": [\"nofollow\", \"noopener\", \"noreferrer\", \"me\"]\n    }\n  ],\n  \"attributedTo\": \"https://dbzer0.com/wp-json/activitypub/1.0/collections/moderators\",\n  \"name\": \"A Division by Zer0\",\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2024/12/cropped-8b790ffc-2b23-461a-ae98-1bd743cf66bc.webp?fit=512%2C512&#038;ssl=1\"\n  },\n  \"published\": \"2005-02-11T23:20:15Z\",\n  \"summary\": \"<p>A bug in the code of the universe</p>\\n\",\n  \"tag\": [\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/quote-of-the-day/\",\n      \"name\": \"#QuoteOfTheDay\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/anarchism/\",\n      \"name\": \"#anarchism\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/reddit/\",\n      \"name\": \"#reddit\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/site-updates/\",\n      \"name\": \"#SiteUpdates\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/aihorde/\",\n      \"name\": \"#aiHorde\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/capitalism/\",\n      \"name\": \"#Capitalism\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/wordpress/\",\n      \"name\": \"#Wordpress\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/libertarianism/\",\n      \"name\": \"#Libertarianism\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/rant/\",\n      \"name\": \"#Rant\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/linux/\",\n      \"name\": \"#Linux\"\n    }\n  ],\n  \"url\": \"https://dbzer0.com\",\n  \"id\": \"https://dbzer0.com/?author=0\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/wordpress/objects/note.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"Hashtag\": \"as:Hashtag\"\n    }\n  ],\n  \"id\": \"https://pfefferle.org?c=148\",\n  \"type\": \"Note\",\n  \"attributedTo\": \"https://pfefferle.org/author/pfefferle/\",\n  \"content\": \"<p>Nice! Hello from WordPress!</p>\",\n  \"contentMap\": {\n    \"en\": \"<p>Nice! Hello from WordPress!</p>\"\n  },\n  \"inReplyTo\": \"https://socialhub.activitypub.rocks/ap/object/ce040f1ead95964f6dbbf1084b81432d\",\n  \"published\": \"2024-04-30T15:21:13Z\",\n  \"tag\": [],\n  \"url\": \"https://pfefferle.org?c=148\",\n  \"to\": [\n    \"https://www.w3.org/ns/activitystreams#Public\",\n    \"https://pfefferle.org/wp-json/activitypub/1.0/users/0/followers\"\n  ],\n  \"cc\": []\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/wordpress/objects/page.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    {\n      \"Hashtag\": \"as:Hashtag\",\n      \"sensitive\": \"as:sensitive\",\n      \"dcterms\": \"http://purl.org/dc/terms/\",\n      \"gts\": \"https://gotosocial.org/ns#\",\n      \"interactionPolicy\": {\n        \"@id\": \"gts:interactionPolicy\",\n        \"@type\": \"@id\"\n      },\n      \"canQuote\": {\n        \"@id\": \"gts:canQuote\",\n        \"@type\": \"@id\"\n      },\n      \"canReply\": {\n        \"@id\": \"gts:canReply\",\n        \"@type\": \"@id\"\n      },\n      \"canLike\": {\n        \"@id\": \"gts:canLike\",\n        \"@type\": \"@id\"\n      },\n      \"canAnnounce\": {\n        \"@id\": \"gts:canAnnounce\",\n        \"@type\": \"@id\"\n      },\n      \"automaticApproval\": {\n        \"@id\": \"gts:automaticApproval\",\n        \"@type\": \"@id\"\n      },\n      \"manualApproval\": {\n        \"@id\": \"gts:manualApproval\",\n        \"@type\": \"@id\"\n      },\n      \"always\": {\n        \"@id\": \"gts:always\",\n        \"@type\": \"@id\"\n      }\n    }\n  ],\n  \"type\": \"Article\",\n  \"attachment\": [\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?fit=980%2C980&#038;ssl=1\",\n      \"mediaType\": \"image/png\",\n      \"name\": \"A fleet of varied spacships\"\n    },\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.jpg?fit=696%2C1024&#038;ssl=1\",\n      \"mediaType\": \"image/jpeg\"\n    },\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-1.png?fit=980%2C980&#038;ssl=1\",\n      \"mediaType\": \"image/png\"\n    },\n    {\n      \"type\": \"Image\",\n      \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1-scaled.jpg?fit=768%2C1024&#038;ssl=1\",\n      \"mediaType\": \"image/jpeg\"\n    }\n  ],\n  \"attributedTo\": \"https://dbzer0.com/blog/author/db0/\",\n  \"audience\": \"https://dbzer0.com/?author=0\",\n  \"content\": \"<p>I recently got my hands on a 3D printer and have been slowly enhancing and customizing my board game collection through printing new insertsand replacing components (typically wooden cubes, or cardboard tokens) with 3D printed version of them<sup><a href=\\\"#9171b805-3e14-40f7-9061-1583b24368c3\\\">1</a></sup>.</p><p>When I reached the time to do my <a href=\\\"https://boardgamegeek.com/boardgame/315727/last-light\\\">Last Light</a> board game I initially just printed some planet/extractors supports and called it a day. However after I finished with my improvements for <a href=\\\"https://boardgamegeek.com/boardgame/359871/arcs\\\">Arcs</a>, where I replaced the basic spaceships with 3D printed custom versions,I got the idea to do the same for Last Light. Unfortunately, unlike Arcs I couldn&#8217;t find any spaceships someone else prepared for it, so I continued with other projects until as part of these projects I discovered a new workflow.</p><p>You see, there are GenAI models which can generate full 3D models from an existing image. They&#8217;re not as great with any random image, but if you feed them something specifically looking like a 3D model, they can be very good. I also have access to Generative AI through the <a href=\\\"https://aihorde.net/\\\">AI Horde</a> and can generate any number of images for it through local compute (mostly mine), and recently a new very powerful model was added to it<sup><a href=\\\"#06101c1f-78af-44ab-ac66-3b4e086ac40f\\\">2</a></sup>. Finally an AI Horde LLM can be used to take the core idea for a conceptand expand/brainstorm on it to improve its look.</p><p>My first test for this was printing a large Gargoyle as a gift. As I couldn&#8217;t find one in the shape or design I wanted, I just generated one myself. This was also my first proof-of-concept for how this workload could work and I was frankly blown away when I finished printing the model. The results were significantly better quality than I expected! As soon as I saw that, I knew my next project would be to see if I can generate 3 ship types for each color in Last Light. And, friends, let&#8217;s just say I might have gotten a bit carried away&#8230;</p><h2>My Epic Last Light Custom Ship Expansion</h2><p>Initially I generated only one fleet per color. Each fleet includes one large/dreadnought, one medium/frigate and one small/fighter spaceship. The small ships are are designed to look maneuverable, while the dreadnoughts to look imposing. Once I did the primary colors, Ithought to myself, &#8220;What if someone picks a color, but doesn&#8217;t like the design of the ships for that color. And also, why even limit myself? Youknow what, I&#8217;m gonna make <strong>two</strong> designs per color&#8221;. Honestly I was having way too much time making these designs and on the second wave of, I decided to be even more creative with the options.</p><p>So without further ado, let&#8217;s see the results. I&#8217;ll separate the headings below for each of the primary colors of Last Light, and the two options I designed for it. Each color has one design for the primary color, and one for an off-color of some way. For example White and Silver, Blue and Cyan and so on, so as to have each off-color clearly belong to one of the primary colors in the game. For each color, you can find a download link to the models and more pictures on Makeworld in the image caption.</p><div><div><h2>Black</h2><div><div><div><figure><img width=\\\"696\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.jpg?resize=696%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080011-last-light-spaceships-black-theme\\\">Black: Shadows</a></strong></figcaption></figure></div><p>For this design, I wanted to make something that is all sharp pointy edges and malicious force. Interesting tidbit, the Frigate was the first ship I printed and it came up slightly too large compared to the original medium spaceship cardboard token. Second interesting tidbit, the fighter design originally belonged to the Grey designs (see below), but I decided that it&#8217;s better to make it part of the shadows as a cohesive whole, and modified its design prompt for the dreadnought.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Fighter Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-1.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"683\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-1.png?resize=683%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080011-last-light-spaceships-black-theme\\\">Grey: Geometric</a></strong></figcaption></figure><p>Of course I wished for a Borg-like brutalist design as a choice as well. The models you see below are actually hand-painted as I run out of grey filament. I had to do a few re-designs for the pyramid in order to appear that it has less of a base (since there&#8217;s no ground in space). I would have liked a spherical spaceship to complete this trio, but that doesn&#8217;t make for an easy miniature. So I went instead for a hexagonal fighter.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-2.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Frigate Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Large-2.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Orange</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong>Orange: Forgeworld</strong></figcaption></figure><p>For orange, I went with something that might look like it comes out of Dwarven blacksmiths</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-2.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-2.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-3.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2079964-last-light-spaceships-brown-theme\\\">Brown: Subterranean</a></strong></figcaption></figure><p>I used brown as a darkened version of orange. I felt a fitting theme for this color was a subterranean design.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-3.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-3.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Red</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073721-last-light-spaceships-red-theme\\\">Red: Volcanic</a></strong></figcaption></figure><p>For red, the best concept I could come up was a volcanic design</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097955-last-light-spaceships-pink-theme\\\">Pink: Organic</a></strong></figcaption></figure><p>Pink is effectively desaturated red so it felt adequate for a secondary color. A fitting design for this color was a organic bioships, taking inspiration from things like Zerg and Tyranids. Initially I wanted the winged mosquito-like ship to be the small version, but through trial and error I couldn&#8217;t make a printable version. The wings and base kept breaking off. As a medium version however it&#8217;s surprisingly stable.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Freadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Yellow</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073748-last-light-spaceships-yellow-theme\\\">Yellow: Smooth</a></strong></figcaption></figure><p>Yellow gets a more straightforward spaceship design. I tried to make this somewhat long, so hopefully they won&#8217;ttake too much space.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Small2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Fighter Print</figcaption></figure><figure><imgwidth=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097967-last-light-spaceships-gold-theme\\\">Gold: Gilded</a></strong></figcaption></figure><p>Gold fits very well as the secondary yellow color as its metallic cousin. This design is meant to give vibes of Croesus or some other gaudy personality. Also unlike their yellow cousins, they tend to be more short and stocky.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-8.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Purple</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073151-last-light-spaceships-purple-theme\\\">Purple: Centrifugal</a></strong></figcaption></figure><p>For purple I went for a round/centrifugal theme, to keep things interesting. Initially this was supposed to be my white color ships, but I then had a better idea for something to match the whitetheme (see below), so I decided to turn these purple instead. Unfortunately the fighter and dreadnought prints are not very well balanced, so they tend to tilt on the board, but that doesn&#8217;t affect gameplay much.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-9.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-8.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-8.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097986-last-light-spaceships-fuchsia-theme\\\">Fuchsia: Coral</a></strong></figcaption></figure><p>The secondary purple color was difficult to decide, since purple is a mix or red and blue, so any off-color I chose, would have a chance to look like it belongs as an off-colorto either of them. In the end I went for a Fuchsia color, but in practicality I didn&#8217;t have a fuchsia filament or acrylic, so when I painted them by hand, I made them more Bordeaux. </p><p>Initially my early design were planning to be more celestial-themed, but I quickly found troubles when the GenAI modelcouldn&#8217;t draw spaceships that looked, well, like spaceships. I then came up with the idea for a coral-themed design, and it came out so well, I kept the celestial design only for the fighter ship, given how few print details appear at that size. The medium and large ships have enough area for the coral fractality to actually appear. And they appear quite unique on the board as well!</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-10.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-11.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-9.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-9.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Green</h2><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073207-last-light-spaceships-green-theme\\\">Green: Botanical</a></strong></figcaption></figure><div><div><p>Green obviously had to be plant based and this was the first color I went outside of the usual metallic spaceship design and generated something more organic. For this theme I went for something plant-like and botanical. </p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-11.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-10.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-10.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097975-last-light-spaceships-neon-green-theme\\\">Neon-Green: Mycelial</a></strong></figcaption></figure><p>I couldn&#8217;t decide on an secondary green color. Initially I was planning to make some sort of jingoistic design in khaki, but then I run into a neon-green filament color on sale and inspiration took me towards a more bioluminescent green color. And what better theme for this, than mushrooms! </p><p>Unfortunately the filament itself ended up being too similar in hue to the existing green I used for the Botanical design, but on the other hand, the mycelial designs came out great. The frigate design in fact was the first winged design I printed and it came out so well, that it then gave me confidence to attempt it a second time with the Organic frigates.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-13.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-11.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-11.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-11.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Drednought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>White</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073255-last-light-spaceships-white-theme\\\">White: Crystalline</a></strong></figcaption></figure><p>After I had my initial spaceships for white, it occurred to me that white would also go well for an energy-based race, and what better way SciFi trope for energy than crystals. Don&#8217;t ask me, I didn&#8217;t make the rules!</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-13.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-14.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-12.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/big.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080027-last-light-spaceships-silver-theme\\\">Silver: Plasma</a></strong></figcaption></figure><p>Interestingly enough, it was my 8yo kid that came up with the ship design for this color scheme as he was observing my process and wanted to make some spaceships of his own. The Dreadnought is supposed to be some sort of healing/repair support ship. Given the secondary color for the silver theme I got out of the model, I decided to call this design &#8220;Plasma&#8221;.</p><p>Unfortunately the details of these designs areapparently too fine to be adequately printed and I also tried on a different filament as well, so the actual print barely have any details. I might have to redo these.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small2-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-15.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-13.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Blue</h2><div><div><figure><img width=\\\"683\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art3-1.png?resize=683%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2099401-last-light-spaceships-blue-theme\\\">Blue: Chunky</a></strong></figcaption></figure><p>For the blue designs, I wantedto go for something chunky and utilitarian, kinda like the firefly-class ships. These generations were I think my very first ones, and they were before I started color coding the prompts themselves, which is why these ships all have the same aluminum look.</p><figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-9.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-15.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Fighter Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-16.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Frigate Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-13.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-13.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073794-last-light-spaceships-cyan-theme\\\">Cyan: Marine</a></strong></figcaption></figure><p>Last, but not least, I created an aquatic-creature inspired design and this is the one that convinced me I should go for more weird and organically-inspired designs for my off-color designs. The Jellyfish-themed dreadnought in fact came out so great, even standing stable on the table on its tentacles, that I knew I needed to do more of that. It&#8217;s no wonder next ships I designed after this were the mycelial ones. Can you tell what aquatic life-form inspired the frigate and fighter classes?</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-16.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-17.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-15.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-14.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Dreadnought Print</figcaption></figure></figure></div></div></div></div><ol><li>Seriously, a 3D printer is the best addition to a boardgame hobby. <a href=\\\"#9171b805-3e14-40f7-9061-1583b24368c3-link\\\">↩︎</a></li><li><a href=\\\"https://github.com/Tongyi-MAI/Z-Image\\\">Z-Image</a> <a href=\\\"#06101c1f-78af-44ab-ac66-3b4e086ac40f-link\\\">↩︎</a></li></ol><p>So there you have it. This project has been ongoing for a few weeks now and I&#8217;m really happy how it turned out. I hope my boardgaming partners will find the enhancements to Last Light just as cool as I do 🙂</p><p>Now that I also opened this door of combining Generative AI with 3D printing and boardgames, the sky&#8217;s my limit! I have so many ideas to upgrade each and every boring component I have! Subscribe to this blog via RSS <a href=\\\"https://lemmy.dbzer0.com/c/dbzer0.com@dbzer0.com\\\">or fediverse</a> or follow my makerworld account to see what new stuff I create.</p><p></p>\",\n  \"context\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/context\",\n  \"contentMap\": {\n    \"en\": \"<p>I recently got my hands on a 3D printer and have been slowly enhancing and customizing my board game collection through printing new inserts and replacing components (typically wooden cubes, or cardboard tokens) with 3D printed version of them<sup><a href=\\\"#9171b805-3e14-40f7-9061-1583b24368c3\\\">1</a></sup>.</p><p>When I reached the time to do my <a href=\\\"https://boardgamegeek.com/boardgame/315727/last-light\\\">Last Light</a> board game I initially just printed some planet/extractors supports and called it a day. However after I finished with my improvements for <a href=\\\"https://boardgamegeek.com/boardgame/359871/arcs\\\">Arcs</a>, where I replaced the basic spaceships with 3D printed custom versions,I got the idea to do the same for Last Light. Unfortunately, unlike Arcs I couldn&#8217;t find any spaceships someone else prepared for it, so I continued with other projects until as part of these projects I discovered a new workflow.</p><p>You see, there are GenAI models which can generate full 3D models from an existing image. They&#8217;re not as great with any random image, but if you feed them something specifically looking like a 3D model, they can be very good. I also have access to Generative AI through the <a href=\\\"https://aihorde.net/\\\">AI Horde</a> and can generate any number of images for it through local compute (mostly mine), and recently a new very powerful model was added to it<sup><a href=\\\"#06101c1f-78af-44ab-ac66-3b4e086ac40f\\\">2</a></sup>. Finally an AI Horde LLM can be used to take the core idea for a concept and expand/brainstorm on it to improve its look.</p><p>My first test for this was printing a large Gargoyle as a gift. As I couldn&#8217;t find one in the shape or design I wanted, I just generated one myself. This was also my first proof-of-concept for how this workload could work and I was frankly blown away whenI finished printing the model. The results were significantly better quality than I expected! As soon as I saw that, I knew my next project would be to see if I can generate 3 ship types for each color in Last Light. And, friends, let&#8217;s just say I might have gotten a bit carried away&#8230;</p><h2>My Epic Last Light Custom Ship Expansion</h2><p>Initially I generated only one fleet per color. Each fleet includes one large/dreadnought, one medium/frigate and onesmall/fighter spaceship. The small ships are are designed to look maneuverable, while the dreadnoughts to look imposing. Once I did the primary colors, I thought to myself, &#8220;What if someone picks a color, but doesn&#8217;t like the design of the ships for that color. And also, why even limit myself? You know what, I&#8217;m gonna make <strong>two</strong> designs per color&#8221;. Honestly I was having way too much time making these designs and on the second wave of, I decided to be even more creative with the options.</p><p>So without further ado, let&#8217;s see the results. I&#8217;ll separate the headings below for each of the primary colors of Last Light, and the two options I designed for it. Each color has one design for the primary color, and one for an off-color of some way. For example White and Silver, Blue and Cyan and so on, so as to have each off-color clearly belong to one of the primary colors in the game.For each color, you can find a download link to the models and more pictures on Makeworld in the image caption.</p><div><div><h2>Black</h2><div><div><div><figure><img width=\\\"696\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.jpg?resize=696%2C1024&#038;ssl=1\\\" alt=\\\"\\\"/><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080011-last-light-spaceships-black-theme\\\">Black: Shadows</a></strong></figcaption></figure></div><p>For this design, I wanted to make something that is all sharp pointy edges and malicious force. Interesting tidbit, the Frigate was the first shipI printed and it came up slightly too large compared to the original medium spaceship cardboard token. Second interesting tidbit, the fighter design originally belonged to the Grey designs (see below), but I decided that it&#8217;s better to make it part of the shadows as a cohesive whole, and modified its design prompt for the dreadnought.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Fighter Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-1.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Black Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"683\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-1.png?resize=683%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080011-last-light-spaceships-black-theme\\\">Grey: Geometric</a></strong></figcaption></figure><p>Of course I wished for a Borg-like brutalist design as a choice as well. The models you see below are actually hand-painted as I run out of grey filament. I had to do a few re-designs for the pyramid in order to appear that it has less of a base (since there&#8217;s no ground in space). I would have liked a spherical spaceship to complete this trio, but that doesn&#8217;t make for an easy miniature. So I went instead for a hexagonal fighter.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-2.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Frigate Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Large-2.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-1.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Grey Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Orange</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong>Orange: Forgeworld</strong></figcaption></figure><p>For orange, I went with something that might look like it comes out of Dwarven blacksmiths</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-2.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-2.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Orange Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-3.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\"/><figcaption>Orange Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2079964-last-light-spaceships-brown-theme\\\">Brown: Subterranean</a></strong></figcaption></figure><p>I used brown as a darkened version of orange. I felt a fitting theme for this color was a subterranean design.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-3.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-3.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Brown Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Red</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073721-last-light-spaceships-red-theme\\\">Red: Volcanic</a></strong></figcaption></figure><p>For red, the best concept I could come up was a volcanic design</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-4.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Red Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097955-last-light-spaceships-pink-theme\\\">Pink: Organic</a></strong></figcaption></figure><p>Pink is effectively desaturatedred so it felt adequate for a secondary color. A fitting design for this color was a organic bioships, taking inspiration from things like Zerg and Tyranids. Initially I wanted the winged mosquito-like ship to be the small version, but through trial and error I couldn&#8217;t make a printable version. The wings and base kept breaking off. As a medium version however it&#8217;s surprisingly stable.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Freadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-5.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Pink Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Yellow</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073748-last-light-spaceships-yellow-theme\\\">Yellow: Smooth</a></strong></figcaption></figure><p>Yellow gets a more straightforward spaceship design. I tried to make this somewhat long, so hopefully they won&#8217;t take too much space.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Small2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-6.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Yellow Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097967-last-light-spaceships-gold-theme\\\">Gold: Gilded</a></strong></figcaption></figure><p>Gold fits very well as the secondary yellow color as its metallic cousin. This design is meant to give vibes of Croesus or some other gaudy personality. Also unlike their yellow cousins, they tend to be more short and stocky.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-8.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-7.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Gold Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Purple</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073151-last-light-spaceships-purple-theme\\\">Purple: Centrifugal</a></strong></figcaption></figure><p>For purple I went for a round/centrifugal theme, to keep things interesting. Initially this was supposed to be my white color ships, but I then had a better idea for something to match the white theme (see below), so I decided to turn these purple instead. Unfortunately the fighter and dreadnought prints are not very well balanced, so they tend to tilton the board, but that doesn&#8217;t affect gameplay much.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-5.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-9.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-8.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-8.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Purple Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-4.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097986-last-light-spaceships-fuchsia-theme\\\">Fuchsia: Coral</a></strong></figcaption></figure><p>The secondary purple color was difficult to decide, since purple is a mix or red and blue, so any off-color I chose, would have a chance to look like it belongs as an off-color toeither of them. In the end I went for a Fuchsia color, but in practicality I didn&#8217;t have a fuchsia filament or acrylic, so when I painted them by hand, I made them more Bordeaux. </p><p>Initially my early design were planning to be more celestial-themed, but I quickly found troubles when the GenAI model couldn&#8217;t draw spaceships that looked, well, like spaceships. I then came up with the idea for a coral-themed design, and it came out so well, I kept the celestial design only for the fighter ship, given how few print details appear at that size. The medium and large ships have enough area for the coral fractality to actually appear. And they appear quite unique on the board as well!</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-6.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-10.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-11.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-9.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\"/><figcaption>Fuchsia Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-9.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Fuchsia Dreadnought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Green</h2><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073207-last-light-spaceships-green-theme\\\">Green: Botanical</a></strong></figcaption></figure><div><div><p>Green obviously had to be plant based and this was the first color I went outside of the usual metallic spaceship design and generated something more organic. For this theme I went for something plant-like and botanical. </p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-11.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium2-1.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Frigate Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-10.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-2.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-10.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Green Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-7.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2097975-last-light-spaceships-neon-green-theme\\\">Neon-Green: Mycelial</a></strong></figcaption></figure><p>I couldn&#8217;t decide on an secondary green color. Initially I was planning to make some sort of jingoistic design in khaki, but then I run into a neon-green filament color on sale and inspiration took me towards a more bioluminescent green color. And what better theme for this,than mushrooms! </p><p>Unfortunately the filament itself ended up being too similar in hue to the existing green I used for the Botanical design, but on theother hand, the mycelial designs came out great. The frigate design in fact was the first winged design I printed and it came out so well, that it then gaveme confidence to attempt it a second time with the Organic frigates.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-13.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-11.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-11.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-11.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Neon-Green Drednought Print</figcaption></figure></figure></div></div></div></div><div><div><h2>White</h2><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-8.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073255-last-light-spaceships-white-theme\\\">White: Crystalline</a></strong></figcaption></figure><p>After I had my initial spaceships for white, it occurred to me that white would also go well for an energy-based race, and what better way SciFi trope for energy than crystals. Don&#8217;t ask me, I didn&#8217;t make the rules!</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Fighter Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-13.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-14.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-12.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Dreadnought Design</figcaption></figure><figure><img width=\\\"725\\\" height=\\\"966\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/big.jpg?resize=725%2C966&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>White Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-9.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2080027-last-light-spaceships-silver-theme\\\">Silver: Plasma</a></strong></figcaption></figure><p>Interestingly enough, it was my 8yo kid that came up with the ship design for this color scheme as he was observing my process and wanted to make some spaceships of his own. The Dreadnought is supposed to be some sort of healing/repair support ship. Given the secondary color for the silver theme I got out of the model, I decided to call this design &#8220;Plasma&#8221;.</p><p>Unfortunately the details of these designs are apparently too fine to be adequately printed and I also tried on a different filament as well, so the actual print barely have any details. I might have to redo these.</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small2-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-15.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-13.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large2-3.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-12.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Silver Frigate Print</figcaption></figure></figure></div></div></div></div><div><div><h2>Blue</h2><div><div><figure><img width=\\\"683\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art3-1.png?resize=683%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2099401-last-light-spaceships-blue-theme\\\">Blue: Chunky</a></strong></figcaption></figure><p>For the blue designs, I wanted togo for something chunky and utilitarian, kinda like the firefly-class ships. These generations were I think my very first ones, and they were before I started color coding the prompts themselves, which is why these ships all have the same aluminum look.</p><figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-9.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-15.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Fighter Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-16.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Frigate Print</figcaption></figure><figure><img width=\\\"704\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-13.png?resize=704%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-13.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Blue Dreadnought Print</figcaption></figure></figure></div></div><div><div><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption><strong><a href=\\\"https://makerworld.com/en/models/2073794-last-light-spaceships-cyan-theme\\\">Cyan: Marine</a></strong></figcaption></figure><p>Last, but not least, I created an aquatic-creature inspired design and this is the one that convinced me I should go for more weird and organically-inspired designs for my off-color designs. The Jellyfish-themed dreadnought in fact came out so great, even standing stable on the table on its tentacles, that I knew I needed to do more of that. It&#8217;s no wonder next ships I designed after this were the mycelial ones. Can you tell what aquatic life-form inspired the frigate and fighter classes?</p><figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-10.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Fighter Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-16.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Fighter Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-17.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Frigate Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/medium-15.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Frigate Print</figcaption></figure><figure><img width=\\\"980\\\" height=\\\"980\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-14.png?resize=980%2C980&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Dreadnought Design</figcaption></figure><figure><img width=\\\"768\\\" height=\\\"1024\\\" src=\\\"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/large-14.jpg?resize=768%2C1024&#038;ssl=1\\\" alt=\\\"\\\" /><figcaption>Cyan Dreadnought Print</figcaption></figure></figure></div></div></div></div><ol><li>Seriously, a 3D printer is the best addition to a boardgame hobby. <a href=\\\"#9171b805-3e14-40f7-9061-1583b24368c3-link\\\">↩︎</a></li><li><a href=\\\"https://github.com/Tongyi-MAI/Z-Image\\\">Z-Image</a> <a href=\\\"#06101c1f-78af-44ab-ac66-3b4e086ac40f-link\\\">↩︎</a></li></ol><p>So there you have it. This project has been ongoing for a few weeks now and I&#8217;m really happy how it turned out. I hope my boardgaming partners will find the enhancements to Last Light just as cool as I do 🙂</p><p>Now that I also opened this door of combining Generative AI with 3D printing and boardgames, the sky&#8217;s my limit! I have so many ideas to upgrade each and every boring component I have! Subscribe to this blog via RSS <a href=\\\"https://lemmy.dbzer0.com/c/dbzer0.com@dbzer0.com\\\">or fediverse</a> or follow my makerworld accountto see what new stuff I create.</p><p></p>\"\n  },\n  \"name\": \"An Epic 3D-Printed Fan Expansion for Last Light\",\n  \"nameMap\": {\n    \"en\": \"An Epic 3D-Printed Fan Expansion for Last Light\"\n  },\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?resize=150%2C150&#038;ssl=1\",\n    \"mediaType\": \"image/png\",\n    \"name\": \"A fleet of varied spacships\"\n  },\n  \"image\": {\n    \"type\": \"Image\",\n    \"url\": \"https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?fit=980%2C980&#038;ssl=1\",\n    \"mediaType\": \"image/png\",\n    \"name\": \"A fleet of varied spacships\"\n  },\n  \"preview\": {\n    \"type\": \"Note\",\n    \"content\": \"I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone.\"\n  },\n  \"published\": \"2025-12-10T15:53:45Z\",\n  \"summary\": \"I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone.\",\n  \"summaryMap\": {\n    \"en\": \"I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone.\"\n  },\n  \"tag\": [\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/3d-printing/\",\n      \"name\": \"#3DPrinting\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/ai/\",\n      \"name\": \"#ai\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/boardgames/\",\n      \"name\": \"#Boardgames\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/genai/\",\n      \"name\": \"#GenAI\"\n    },\n    {\n      \"type\": \"Hashtag\",\n      \"href\": \"https://dbzer0.com/blog/tag/last-light/\",\n      \"name\": \"#LastLight\"\n    }\n  ],\n  \"updated\": \"2025-12-10T18:23:32Z\",\n  \"url\": \"https://dbzer0.com/blog/an-epic-3d-printed-fan-expansion-for-last-light/\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://dbzer0.com/wp-json/activitypub/1.0/actors/1/followers\"],\n  \"mediaType\": \"text/html\",\n  \"replies\": {\n    \"id\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies\",\n    \"type\": \"Collection\",\n    \"first\": {\n      \"id\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies?page=1\",\n      \"type\": \"CollectionPage\",\n      \"partOf\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies\",\n      \"items\": []\n    }\n  },\n  \"likes\": {\n    \"id\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/likes\",\n    \"type\": \"Collection\",\n    \"totalItems\": 2\n  },\n  \"shares\": {\n    \"id\": \"https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/shares\",\n    \"type\": \"Collection\",\n    \"totalItems\": 0\n  },\n  \"interactionPolicy\": {\n    \"canAnnounce\": {\n      \"automaticApproval\": \"https://www.w3.org/ns/activitystreams#Public\",\n      \"always\": \"https://www.w3.org/ns/activitystreams#Public\"\n    },\n    \"canLike\": {\n      \"automaticApproval\": \"https://www.w3.org/ns/activitystreams#Public\",\n      \"always\": \"https://www.w3.org/ns/activitystreams#Public\"\n    },\n    \"canQuote\": {\n      \"automaticApproval\": \"https://www.w3.org/ns/activitystreams#Public\",\n      \"always\": \"https://www.w3.org/ns/activitystreams#Public\"\n    },\n    \"canReply\": {\n      \"automaticApproval\": \"https://www.w3.org/ns/activitystreams#Public\",\n      \"always\": \"https://www.w3.org/ns/activitystreams#Public\"\n    }\n  },\n  \"id\": \"https://dbzer0.com/?p=27948\"\n}\n"
  },
  {
    "path": "crates/apub/apub/assets/wordpress/objects/person.json",
    "content": "{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\",\n    \"https://purl.archive.org/socialweb/webfinger\",\n    {\n      \"schema\": \"http://schema.org#\",\n      \"toot\": \"http://joinmastodon.org/ns#\",\n      \"webfinger\": \"https://webfinger.net/#\",\n      \"lemmy\": \"https://join-lemmy.org/ns#\",\n      \"manuallyApprovesFollowers\": \"as:manuallyApprovesFollowers\",\n      \"PropertyValue\": \"schema:PropertyValue\",\n      \"value\": \"schema:value\",\n      \"Hashtag\": \"as:Hashtag\",\n      \"featured\": {\n        \"@id\": \"toot:featured\",\n        \"@type\": \"@id\"\n      },\n      \"featuredTags\": {\n        \"@id\": \"toot:featuredTags\",\n        \"@type\": \"@id\"\n      },\n      \"moderators\": {\n        \"@id\": \"lemmy:moderators\",\n        \"@type\": \"@id\"\n      },\n      \"postingRestrictedToMods\": \"lemmy:postingRestrictedToMods\",\n      \"discoverable\": \"toot:discoverable\",\n      \"indexable\": \"toot:indexable\",\n      \"resource\": \"webfinger:resource\"\n    }\n  ],\n  \"id\": \"https://pfefferle.org/author/pfefferle/\",\n  \"type\": \"Person\",\n  \"attachment\": [\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Blog\",\n      \"value\": \"<a rel=\\\"me\\\" title=\\\"https://pfefferle.org/\\\" target=\\\"_blank\\\" href=\\\"https://pfefferle.org/\\\">pfefferle.org</a>\"\n    },\n    {\n      \"type\": \"PropertyValue\",\n      \"name\": \"Profile\",\n      \"value\": \"<a rel=\\\"me\\\" title=\\\"https://pfefferle.org/author/pfefferle/\\\" target=\\\"_blank\\\" href=\\\"https://pfefferle.org/author/pfefferle/\\\">pfefferle.org</a>\"\n    }\n  ],\n  \"name\": \"Matthias Pfefferle\",\n  \"icon\": {\n    \"type\": \"Image\",\n    \"url\": \"https://secure.gravatar.com/avatar/a2bdca7870e859658cece96c044b3be5?s=120&#038;d=mm&#038;r=g\"\n  },\n  \"published\": \"2014-02-10T15:23:08Z\",\n  \"summary\": \"<p>Ich arbeite als Open Web Lead für Automattic.</p>\\n\",\n  \"tag\": [],\n  \"url\": \"https://pfefferle.org/author/pfefferle/\",\n  \"inbox\": \"https://pfefferle.org/wp-json/activitypub/1.0/users/1/inbox\",\n  \"outbox\": \"https://pfefferle.org/wp-json/activitypub/1.0/users/1/outbox\",\n  \"following\": \"https://pfefferle.org/wp-json/activitypub/1.0/users/1/following\",\n  \"followers\": \"https://pfefferle.org/wp-json/activitypub/1.0/users/1/followers\",\n  \"preferredUsername\": \"matthias\",\n  \"endpoints\": {\n    \"sharedInbox\": \"https://pfefferle.org/wp-json/activitypub/1.0/inbox\"\n  },\n  \"publicKey\": {\n    \"id\": \"https://pfefferle.org/author/pfefferle/#main-key\",\n    \"owner\": \"https://pfefferle.org/author/pfefferle/\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvTA5RA40nOsso04RSwyX\\nHXTojRPUMlIlArDcSy3M5GUJp9/xbxSUOdBjqd31KKB1GIi3vrLmD1Qi/ZqS95Qy\\nw2Zd3xOsCg+o9bsyOG+O6Y8Lu+HEB5JKLUbNHdiSviakJ8wGadH9Wm4WIiN20y+q\\n/u6lgxgiWfZ2CFCN6SOc28fUKi9NmKvXK+M12BhFfy1tC5KWXKDm0UbfI1+dmqhR\\n3Ffe6vEsCI/YIVVdWxQ9kouOd0XSHOGdslktkepRO7IP9i9TdwyeCa0WWRoeO5Wa\\ntVpc1Y0WuNbTM2ksIXTg0G+rO1/6KO/hrHnGu3RCfb/ZIHK5L/aWYb9B3PG3LyKV\\n+wIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  },\n  \"manuallyApprovesFollowers\": false,\n  \"featured\": \"https://pfefferle.org/wp-json/activitypub/1.0/users/1/collections/featured\",\n  \"discoverable\": true,\n  \"indexable\": true,\n  \"webfinger\": \"matthias@pfefferle.org\"\n}\n"
  },
  {
    "path": "crates/apub/apub/src/collections/community_featured.rs",
    "content": "use crate::protocol::collections::group_featured::GroupFeatured;\nuse activitypub_federation::{\n  config::Data,\n  kinds::collection::OrderedCollectionType,\n  protocol::verification::verify_domains_match,\n  traits::{Collection, Object},\n};\nuse futures::future::{join_all, try_join_all};\nuse lemmy_api_utils::{context::LemmyContext, utils::generate_featured_url};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, post::ApubPost};\nuse lemmy_db_schema::{\n  source::{community::Community, post::Post},\n  utils::FETCH_LIMIT_MAX,\n};\nuse lemmy_utils::error::LemmyError;\nuse url::Url;\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct ApubCommunityFeatured(());\n\n#[async_trait::async_trait]\nimpl Collection for ApubCommunityFeatured {\n  type Owner = ApubCommunity;\n  type DataType = LemmyContext;\n  type Kind = GroupFeatured;\n  type Error = LemmyError;\n\n  async fn read_local(\n    owner: &Self::Owner,\n    data: &Data<Self::DataType>,\n  ) -> Result<Self::Kind, Self::Error> {\n    let ordered_items = try_join_all(\n      Post::list_featured_for_community(&mut data.pool(), owner.id)\n        .await?\n        .into_iter()\n        .map(ApubPost::from)\n        .map(|p| p.into_json(data)),\n    )\n    .await?;\n    Ok(GroupFeatured {\n      r#type: OrderedCollectionType::OrderedCollection,\n      id: generate_featured_url(&owner.ap_id)?.into(),\n      total_items: ordered_items.len().try_into()?,\n      ordered_items,\n    })\n  }\n\n  async fn verify(\n    apub: &Self::Kind,\n    expected_domain: &Url,\n    _data: &Data<Self::DataType>,\n  ) -> Result<(), Self::Error> {\n    verify_domains_match(expected_domain, &apub.id)?;\n    Ok(())\n  }\n\n  async fn from_json(\n    apub: Self::Kind,\n    owner: &Self::Owner,\n    context: &Data<Self::DataType>,\n  ) -> Result<Self, Self::Error>\n  where\n    Self: Sized,\n  {\n    let mut pages = apub.ordered_items;\n    if pages.len() > FETCH_LIMIT_MAX {\n      pages = pages.get(0..FETCH_LIMIT_MAX).unwrap_or_default().to_vec();\n    }\n\n    // process items in parallel, to avoid long delay from fetch_site_metadata() and other\n    // processing\n    let stickied_posts: Vec<Post> = join_all(pages.into_iter().map(|page| async move {\n      // Dont verify/receive the `page` directly because it throws error for local post\n      page.id.dereference(context).await\n    }))\n    .await\n    // ignore any failed or unparseable items\n    .into_iter()\n    .filter_map(|p| p.ok().map(|p| p.0))\n    .collect();\n\n    Community::set_featured_posts(owner.id, stickied_posts, &mut context.pool()).await?;\n\n    // This return value is unused, so just set an empty vec\n    Ok(ApubCommunityFeatured(()))\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/collections/community_follower.rs",
    "content": "use crate::protocol::collections::group_followers::GroupFollowers;\nuse activitypub_federation::{\n  config::Data,\n  kinds::collection::CollectionType,\n  protocol::verification::verify_domains_match,\n  traits::Collection,\n};\nuse lemmy_api_utils::{context::LemmyContext, utils::generate_followers_url};\nuse lemmy_apub_objects::objects::community::ApubCommunity;\nuse lemmy_db_schema::source::community::Community;\nuse lemmy_db_views_community_follower::CommunityFollowerView;\nuse lemmy_utils::error::LemmyError;\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub(crate) struct ApubCommunityFollower(());\n\n#[async_trait::async_trait]\nimpl Collection for ApubCommunityFollower {\n  type Owner = ApubCommunity;\n  type DataType = LemmyContext;\n  type Kind = GroupFollowers;\n  type Error = LemmyError;\n\n  async fn read_local(\n    community: &Self::Owner,\n    context: &Data<Self::DataType>,\n  ) -> Result<Self::Kind, Self::Error> {\n    let community_id = community.id;\n    let community_followers =\n      CommunityFollowerView::count_community_followers(&mut context.pool(), community_id).await?;\n\n    Ok(GroupFollowers {\n      id: generate_followers_url(&community.ap_id)?.into(),\n      r#type: CollectionType::Collection,\n      total_items: community_followers,\n      items: vec![],\n    })\n  }\n\n  async fn verify(\n    json: &Self::Kind,\n    expected_domain: &Url,\n    _data: &Data<Self::DataType>,\n  ) -> Result<(), Self::Error> {\n    verify_domains_match(expected_domain, &json.id)?;\n    Ok(())\n  }\n\n  async fn from_json(\n    json: Self::Kind,\n    community: &Self::Owner,\n    context: &Data<Self::DataType>,\n  ) -> Result<Self, Self::Error> {\n    Community::update_federated_followers(&mut context.pool(), community.id, json.total_items)\n      .await?;\n\n    Ok(ApubCommunityFollower(()))\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/collections/community_moderators.rs",
    "content": "use crate::{is_new_instance, protocol::collections::group_moderators::GroupModerators};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::collection::OrderedCollectionType,\n  protocol::verification::verify_domains_match,\n  traits::Collection,\n};\nuse lemmy_api_utils::{context::LemmyContext, utils::generate_moderators_url};\nuse lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson};\nuse lemmy_db_schema::source::community::{CommunityActions, CommunityModeratorForm};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub(crate) struct ApubCommunityModerators(());\n\n#[async_trait::async_trait]\nimpl Collection for ApubCommunityModerators {\n  type Owner = ApubCommunity;\n  type DataType = LemmyContext;\n  type Kind = GroupModerators;\n  type Error = LemmyError;\n\n  async fn read_local(owner: &Self::Owner, data: &Data<Self::DataType>) -> LemmyResult<Self::Kind> {\n    let moderators = CommunityModeratorView::for_community(&mut data.pool(), owner.id).await?;\n    let ordered_items = moderators\n      .into_iter()\n      .map(|m| ObjectId::<ApubPerson>::from(m.moderator.ap_id))\n      .collect();\n    Ok(GroupModerators {\n      r#type: OrderedCollectionType::OrderedCollection,\n      id: generate_moderators_url(&owner.ap_id)?.into(),\n      ordered_items,\n    })\n  }\n\n  async fn verify(\n    group_moderators: &GroupModerators,\n    expected_domain: &Url,\n    _data: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(&group_moderators.id, expected_domain)?;\n    Ok(())\n  }\n\n  async fn from_json(\n    apub: Self::Kind,\n    owner: &Self::Owner,\n    data: &Data<Self::DataType>,\n  ) -> LemmyResult<Self> {\n    handle_community_moderators(&apub.ordered_items, owner, data).await?;\n\n    // This return value is unused, so just set an empty vec\n    Ok(ApubCommunityModerators(()))\n  }\n}\n\npub(super) async fn handle_community_moderators(\n  new_mods: &Vec<ObjectId<ApubPerson>>,\n  community: &ApubCommunity,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let community_id = community.id;\n  let current_moderators =\n    CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;\n  // Remove old mods from database which arent in the moderators collection anymore\n  for mod_user in &current_moderators {\n    let mod_id = ObjectId::from(mod_user.moderator.ap_id.clone());\n    if !new_mods.contains(&mod_id) {\n      let community_moderator_form =\n        CommunityModeratorForm::new(mod_user.community.id, mod_user.moderator.id);\n      CommunityActions::leave(&mut context.pool(), &community_moderator_form).await?;\n    }\n  }\n\n  // Add new mods to database which have been added to moderators collection\n  for mod_id in new_mods {\n    // Ignore errors as mod accounts might be deleted or instances unavailable.\n    let mod_user: Option<ApubPerson> = mod_id.dereference(context).await.ok();\n    if let Some(mod_user) = mod_user\n      && !current_moderators\n        .iter()\n        .any(|x| x.moderator.ap_id == mod_user.ap_id)\n    {\n      let community_moderator_form = CommunityModeratorForm::new(community.id, mod_user.id);\n      CommunityActions::join(&mut context.pool(), &community_moderator_form).await?;\n    }\n\n    // Only add the top mod in case of new instance\n    if is_new_instance(context).await? {\n      return Ok(());\n    }\n  }\n  Ok(())\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use super::*;\n  use lemmy_apub_objects::utils::test::{\n    file_to_json_object,\n    parse_lemmy_community,\n    parse_lemmy_person,\n  };\n  use lemmy_db_schema::{\n    source::community::{CommunityActions, CommunityModeratorForm},\n    test_data::TestData,\n  };\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_community_moderators() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let data = TestData::create(&mut context.pool()).await?;\n    let (new_mod, site) = parse_lemmy_person(&context).await?;\n    let community = parse_lemmy_community(&context).await?;\n    let community_id = community.id;\n\n    let community_moderator_form = CommunityModeratorForm::new(community.id, data.person.id);\n\n    CommunityActions::join(&mut context.pool(), &community_moderator_form).await?;\n\n    assert_eq!(site.ap_id.to_string(), \"https://enterprise.lemmy.ml/\");\n\n    let json: GroupModerators =\n      file_to_json_object(\"assets/lemmy/collections/group_moderators.json\")?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/c/tenforward\")?;\n    ApubCommunityModerators::verify(&json, &url, &context).await?;\n    ApubCommunityModerators::from_json(json, &community, &context).await?;\n    assert_eq!(context.request_count(), 0);\n\n    let current_moderators =\n      CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;\n\n    assert_eq!(current_moderators.len(), 1);\n    assert_eq!(current_moderators[0].moderator.id, new_mod.id);\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/collections/community_outbox.rs",
    "content": "use crate::{is_new_instance, protocol::collections::group_outbox::GroupOutbox};\nuse activitypub_federation::{\n  config::Data,\n  kinds::collection::OrderedCollectionType,\n  protocol::verification::verify_domains_match,\n  traits::{Activity, Collection},\n};\nuse futures::future::join_all;\nuse lemmy_api_utils::{context::LemmyContext, utils::generate_outbox_url};\nuse lemmy_apub_activities::{\n  activity_lists::AnnouncableActivities,\n  protocol::{\n    CreateOrUpdateType,\n    community::announce::AnnounceActivity,\n    create_or_update::page::CreateOrUpdatePage,\n  },\n};\nuse lemmy_apub_objects::objects::community::ApubCommunity;\nuse lemmy_db_schema::utils::FETCH_LIMIT_MAX;\nuse lemmy_db_schema_file::enums::PostSortType;\nuse lemmy_db_views_post::impls::PostQuery;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub(crate) struct ApubCommunityOutbox(());\n\n#[async_trait::async_trait]\nimpl Collection for ApubCommunityOutbox {\n  type Owner = ApubCommunity;\n  type DataType = LemmyContext;\n  type Kind = GroupOutbox;\n  type Error = LemmyError;\n\n  async fn read_local(owner: &Self::Owner, data: &Data<Self::DataType>) -> LemmyResult<Self::Kind> {\n    let site = SiteView::read_local(&mut data.pool()).await?.site;\n\n    let mut post_views = Box::pin(\n      PostQuery {\n        community_id: Some(owner.id),\n        sort: Some(PostSortType::New),\n        limit: Some(FETCH_LIMIT_MAX.try_into()?),\n        ..Default::default()\n      }\n      .list(&site, &mut data.pool()),\n    )\n    .await?\n    .items;\n\n    // Outbox must be sorted reverse chronological (newest items first). This is already done\n    // via SQL, but featured posts are always at the top so we need to manually sort it here.\n    post_views.sort_unstable_by(|p1, p2| p2.post.published_at.cmp(&p1.post.published_at));\n\n    let mut ordered_items = vec![];\n    for post_view in post_views {\n      // ignore errors, in particular if post creator was deleted\n      if let Ok(create) = CreateOrUpdatePage::new(\n        post_view.post.into(),\n        &post_view.creator.into(),\n        owner,\n        CreateOrUpdateType::Create,\n        data,\n      )\n      .await\n      {\n        let announcable = AnnouncableActivities::CreateOrUpdatePost(create);\n        if let Ok(announce) = AnnounceActivity::new(announcable.try_into()?, owner, data) {\n          ordered_items.push(announce);\n        }\n      }\n    }\n\n    Ok(GroupOutbox {\n      r#type: OrderedCollectionType::OrderedCollection,\n      id: generate_outbox_url(&owner.ap_id)?.into(),\n      total_items: owner.posts,\n      ordered_items,\n    })\n  }\n\n  async fn verify(\n    group_outbox: &GroupOutbox,\n    expected_domain: &Url,\n    _data: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(expected_domain, &group_outbox.id)?;\n    Ok(())\n  }\n\n  async fn from_json(\n    apub: Self::Kind,\n    _owner: &Self::Owner,\n    data: &Data<Self::DataType>,\n  ) -> LemmyResult<Self> {\n    // Fetch less posts on new instance to save requests\n    let fetch_limit = if is_new_instance(data).await? {\n      10\n    } else {\n      FETCH_LIMIT_MAX\n    };\n    let mut outbox_activities = apub.ordered_items;\n    if outbox_activities.len() > fetch_limit {\n      outbox_activities = outbox_activities\n        .get(0..(fetch_limit))\n        .unwrap_or_default()\n        .to_vec();\n    }\n\n    // We intentionally ignore errors here. This is because the outbox might contain posts from old\n    // Lemmy versions, or from other software which we cant parse. In that case, we simply skip the\n    // item and only parse the ones that work.\n    // process items in parallel, to avoid long delay from fetch_site_metadata() and other\n    // processing\n    join_all(outbox_activities.into_iter().map(|activity| {\n      async {\n        // Receiving announce requires at least one local community follower for anti spam purposes.\n        // This won't be the case for newly fetched communities, so we extract the inner activity\n        // and handle it directly to bypass this check.\n        let inner = activity.object.object(data).await.map(TryInto::try_into);\n        if let Ok(Ok(AnnouncableActivities::CreateOrUpdatePost(inner))) = inner {\n          let verify = inner.verify(data).await;\n          if verify.is_ok() {\n            inner.receive(data).await.ok();\n          }\n        }\n      }\n    }))\n    .await;\n\n    // This return value is unused, so just set an empty vec\n    Ok(ApubCommunityOutbox(()))\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/collections/mod.rs",
    "content": "use crate::{\n  collections::community_moderators::handle_community_moderators,\n  is_new_instance,\n  protocol::collections::url_collection::UrlCollection,\n};\nuse activitypub_federation::{\n  actix_web::response::create_http_response,\n  config::Data,\n  fetch::{collection_id::CollectionId, object_id::ObjectId},\n};\nuse actix_web::HttpResponse;\nuse community_featured::ApubCommunityFeatured;\nuse community_follower::ApubCommunityFollower;\nuse community_moderators::ApubCommunityModerators;\nuse community_outbox::ApubCommunityOutbox;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{community::ApubCommunity, person::ApubPerson},\n  protocol::group::Group,\n  utils::protocol::{AttributedTo, PersonOrGroupType},\n};\nuse lemmy_db_schema::source::{comment::Comment, post::Post};\nuse lemmy_utils::{FEDERATION_CONTEXT, error::LemmyResult, spawn_try_task};\n\npub(crate) mod community_featured;\npub(crate) mod community_follower;\npub(crate) mod community_moderators;\npub(crate) mod community_outbox;\n\npub fn fetch_community_collections(\n  community: ApubCommunity,\n  group: Group,\n  context: Data<LemmyContext>,\n) {\n  spawn_try_task(async move {\n    let outbox: CollectionId<ApubCommunityOutbox> = group.outbox.into();\n    outbox.dereference(&community, &context).await.ok();\n    if let Some(followers) = group.followers {\n      let followers: CollectionId<ApubCommunityFollower> = followers.into();\n      followers.dereference(&community, &context).await.ok();\n    }\n    // Dont fetch featured posts for new instances to save requests.\n    // But need to run this in debug mode so that api tests can pass.\n    if (cfg!(debug_assertions) || !is_new_instance(&context).await?)\n      && let Some(featured) = group.featured\n    {\n      let featured: CollectionId<ApubCommunityFeatured> = featured.into();\n      featured.dereference(&community, &context).await.ok();\n    }\n    if let Some(moderators) = group.attributed_to {\n      if let AttributedTo::Lemmy(l) = moderators {\n        let moderators: CollectionId<ApubCommunityModerators> = l.moderators().into();\n        moderators.dereference(&community, &context).await.ok();\n      } else if let AttributedTo::Peertube(p) = moderators {\n        let new_mods = p\n          .iter()\n          .filter(|p| p.kind == PersonOrGroupType::Person)\n          .map(|p| ObjectId::<ApubPerson>::from(p.id.clone().into_inner()))\n          .collect();\n        handle_community_moderators(&new_mods, &community, &context)\n          .await\n          .ok();\n      }\n    }\n    Ok(())\n  });\n}\n\nimpl UrlCollection {\n  pub(crate) async fn new_response(\n    post: &Post,\n    id: String,\n    context: &LemmyContext,\n  ) -> LemmyResult<HttpResponse> {\n    let mut ordered_items = vec![post.ap_id.clone().into()];\n    let comments = Comment::read_ap_ids_for_post(post.id, &mut context.pool()).await?;\n    ordered_items.extend(comments.into_iter().map(Into::into));\n    let collection = Self {\n      r#type: Default::default(),\n      id,\n      total_items: ordered_items.len().try_into()?,\n      ordered_items,\n    };\n    Ok(create_http_response(collection, &FEDERATION_CONTEXT)?)\n  }\n\n  /// Empty placeholder outbox used for Person, Instance, which dont implement a proper outbox.\n  pub(crate) fn new_empty_response(id: String) -> LemmyResult<HttpResponse> {\n    let collection = Self {\n      r#type: Default::default(),\n      id,\n      ordered_items: vec![],\n      total_items: 0,\n    };\n    Ok(create_http_response(collection, &FEDERATION_CONTEXT)?)\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/comment.rs",
    "content": "use super::check_community_content_fetchable;\nuse crate::protocol::collections::url_collection::UrlCollection;\nuse activitypub_federation::{config::Data, traits::Object};\nuse actix_web::{HttpRequest, HttpResponse, web::Path};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{objects::comment::ApubComment, utils::functions::context_url};\nuse lemmy_db_schema::{\n  newtypes::CommentId,\n  source::{comment::Comment, community::Community, post::Post},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse serde::Deserialize;\n\n#[derive(Deserialize)]\npub(crate) struct CommentQuery {\n  comment_id: String,\n}\n\nasync fn get_comment(\n  info: Path<CommentQuery>,\n  context: &Data<LemmyContext>,\n  request: &HttpRequest,\n) -> LemmyResult<ApubComment> {\n  let id = CommentId(info.comment_id.parse::<i32>()?);\n  // Can't use CommentView here because it excludes deleted/removed/local-only items\n  let comment: ApubComment = Comment::read(&mut context.pool(), id).await?.into();\n  let post = Post::read(&mut context.pool(), comment.post_id).await?;\n  let community = Community::read(&mut context.pool(), post.community_id).await?;\n  check_community_content_fetchable(&community, request, context).await?;\n  Ok(comment)\n}\n\n/// Return the ActivityPub json representation of a local comment over HTTP.\npub(crate) async fn get_apub_comment(\n  info: Path<CommentQuery>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let comment = get_comment(info, &context, &request).await?;\n  comment.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\npub(crate) async fn get_apub_comment_context(\n  info: Path<CommentQuery>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let comment = get_comment(info, &context, &request).await?;\n  if !comment.local {\n    return Err(LemmyErrorType::NotFound.into());\n  }\n  let post = Post::read(&mut context.pool(), comment.post_id).await?;\n  UrlCollection::new_response(&post, context_url(&comment.ap_id), &context).await\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/community.rs",
    "content": "use super::check_community_content_fetchable;\nuse crate::{\n  collections::{\n    community_featured::ApubCommunityFeatured,\n    community_follower::ApubCommunityFollower,\n    community_moderators::ApubCommunityModerators,\n    community_outbox::ApubCommunityOutbox,\n  },\n  http::{check_community_fetchable, get_instance_id},\n};\nuse activitypub_federation::{\n  actix_web::{response::create_http_response, signing_actor},\n  config::Data,\n  fetch::object_id::ObjectId,\n  traits::{Collection, Object},\n};\nuse actix_web::{\n  HttpRequest,\n  HttpResponse,\n  web::{Path, Query},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{\n  objects::{\n    SiteOrMultiOrCommunityOrUser,\n    community::ApubCommunity,\n    multi_community::ApubMultiCommunity,\n    multi_community_collection::ApubFeedCollection,\n  },\n  protocol::tags::ApubCommunityTag,\n};\nuse lemmy_db_schema::{\n  source::{community::Community, community_tag::CommunityTag, multi_community::MultiCommunity},\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::CommunityVisibility;\nuse lemmy_db_views_community_follower_approval::PendingFollowerView;\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse serde::Deserialize;\n\n#[derive(Deserialize, Clone)]\npub(crate) struct CommunityPath {\n  community_name: String,\n}\n\n#[derive(Deserialize, Clone)]\npub(crate) struct CommunityIsFollowerQuery {\n  is_follower: Option<ObjectId<SiteOrMultiOrCommunityOrUser>>,\n}\n\n/// Return the ActivityPub json representation of a local community over HTTP.\npub(crate) async fn get_apub_community_http(\n  info: Path<CommunityPath>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let community: ApubCommunity =\n    Community::read_from_name(&mut context.pool(), &info.community_name, None, true)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n\n  check_community_fetchable(&community)?;\n\n  community.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\n/// Returns an empty followers collection, only populating the size (for privacy).\npub(crate) async fn get_apub_community_followers(\n  info: Path<CommunityPath>,\n  query: Query<CommunityIsFollowerQuery>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let community = Community::read_from_name(&mut context.pool(), &info.community_name, None, false)\n    .await?\n    .ok_or(LemmyErrorType::NotFound)?;\n  if let Some(is_follower) = &query.is_follower {\n    return check_is_follower(community, is_follower, context, request).await;\n  }\n  check_community_fetchable(&community)?;\n  let followers = ApubCommunityFollower::read_local(&community.into(), &context).await?;\n  Ok(create_http_response(followers, &FEDERATION_CONTEXT)?)\n}\n\n/// Checks if a given actor follows the private community. Returns status 200 if true.\nasync fn check_is_follower(\n  community: Community,\n  is_follower: &ObjectId<SiteOrMultiOrCommunityOrUser>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  if community.visibility != CommunityVisibility::Private {\n    return Ok(HttpResponse::BadRequest().body(\"must be a private community\"));\n  }\n  // also check for http sig so that followers are not exposed publicly\n  let signing_actor =\n    signing_actor::<SiteOrMultiOrCommunityOrUser>(&request, None, &context).await?;\n  PendingFollowerView::check_has_followers_from_instance(\n    community.id,\n    get_instance_id(&signing_actor),\n    &mut context.pool(),\n  )\n  .await?;\n\n  let instance_id = get_instance_id(&is_follower.dereference(&context).await?);\n  let has_followers = PendingFollowerView::check_has_followers_from_instance(\n    community.id,\n    instance_id,\n    &mut context.pool(),\n  )\n  .await;\n  if has_followers.is_ok() {\n    Ok(HttpResponse::Ok().finish())\n  } else {\n    Ok(HttpResponse::NotFound().finish())\n  }\n}\n\n/// Returns the community outbox, which is populated by a maximum of 20 posts (but no other\n/// activities like votes or comments).\npub(crate) async fn get_apub_community_outbox(\n  info: Path<CommunityPath>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let community: ApubCommunity =\n    Community::read_from_name(&mut context.pool(), &info.community_name, None, false)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n  check_community_content_fetchable(&community, &request, &context).await?;\n  let outbox = ApubCommunityOutbox::read_local(&community, &context).await?;\n  Ok(create_http_response(outbox, &FEDERATION_CONTEXT)?)\n}\n\npub(crate) async fn get_apub_community_moderators(\n  info: Path<CommunityPath>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let community: ApubCommunity =\n    Community::read_from_name(&mut context.pool(), &info.community_name, None, false)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n  check_community_fetchable(&community)?;\n  let moderators = ApubCommunityModerators::read_local(&community, &context).await?;\n  Ok(create_http_response(moderators, &FEDERATION_CONTEXT)?)\n}\n\n/// Returns collection of featured (stickied) posts.\npub(crate) async fn get_apub_community_featured(\n  info: Path<CommunityPath>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let community: ApubCommunity =\n    Community::read_from_name(&mut context.pool(), &info.community_name, None, false)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n  check_community_content_fetchable(&community, &request, &context).await?;\n  let featured = ApubCommunityFeatured::read_local(&community, &context).await?;\n  Ok(create_http_response(featured, &FEDERATION_CONTEXT)?)\n}\n\n#[derive(Deserialize)]\npub(crate) struct MultiCommunityQuery {\n  multi_name: String,\n}\n\npub(crate) async fn get_apub_person_multi_community(\n  query: Path<MultiCommunityQuery>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let multi: ApubMultiCommunity =\n    MultiCommunity::read_from_name(&mut context.pool(), &query.multi_name, None, false)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n\n  multi.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\npub(crate) async fn get_apub_person_multi_community_follows(\n  query: Path<MultiCommunityQuery>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let multi = MultiCommunity::read_from_name(&mut context.pool(), &query.multi_name, None, false)\n    .await?\n    .ok_or(LemmyErrorType::NotFound)?\n    .into();\n\n  let collection = ApubFeedCollection::read_local(&multi, &context).await?;\n  Ok(create_http_response(collection, &FEDERATION_CONTEXT)?)\n}\n\n#[derive(Deserialize, Clone)]\npub(crate) struct CommunityTagPath {\n  community_name: String,\n  tag_name: String,\n}\n\n/// Return the ActivityPub json representation of a local community over HTTP.\npub(crate) async fn get_apub_community_tag_http(\n  info: Path<CommunityTagPath>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let community: ApubCommunity =\n    Community::read_from_name(&mut context.pool(), &info.community_name, None, true)\n      .await?\n      .ok_or(LemmyErrorType::NotFound)?\n      .into();\n\n  check_community_fetchable(&community)?;\n\n  let tag = CommunityTag::read_for_community(&mut context.pool(), community.id)\n    .await?\n    .into_iter()\n    .map(ApubCommunityTag::to_json)\n    .find(|t| t.preferred_username == info.tag_name)\n    .ok_or(LemmyErrorType::NotFound)?;\n\n  Ok(create_http_response(tag, &FEDERATION_CONTEXT)?)\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n\n  use super::*;\n  use activitypub_federation::protocol::tombstone::Tombstone;\n  use actix_web::{body::to_bytes, test::TestRequest};\n  use lemmy_apub_objects::protocol::group::Group;\n  use lemmy_db_schema::{\n    source::{\n      community::CommunityInsertForm,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n    },\n    test_data::TestData,\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use serde::de::DeserializeOwned;\n  use serial_test::serial;\n  use url::Url;\n\n  async fn init(\n    deleted: bool,\n    visibility: CommunityVisibility,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<(TestData, Community, Path<CommunityPath>)> {\n    let data = TestData::create(&mut context.pool()).await?;\n\n    let community_form = CommunityInsertForm {\n      deleted: Some(deleted),\n      ap_id: Some(Url::parse(\"http://lemmy-alpha\")?.into()),\n      visibility: Some(visibility),\n      ..CommunityInsertForm::new(\n        data.instance.id,\n        \"testcom6\".to_string(),\n        \"nada\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let community = Community::create(&mut context.pool(), &community_form).await?;\n    let path: Path<CommunityPath> = CommunityPath {\n      community_name: community.name.clone(),\n    }\n    .into();\n    Ok((data, community, path))\n  }\n\n  async fn decode_response<T: DeserializeOwned>(res: HttpResponse) -> LemmyResult<T> {\n    let body = to_bytes(res.into_body()).await.unwrap_or_default();\n    let body = std::str::from_utf8(&body)?;\n    Ok(serde_json::from_str(body)?)\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_get_community() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let (data, community, path) = init(false, CommunityVisibility::Public, &context).await?;\n    let request = TestRequest::default().to_http_request();\n\n    // fetch invalid community\n    let query = CommunityPath {\n      community_name: \"asd\".to_string(),\n    };\n    let res = get_apub_community_http(query.into(), context.clone()).await;\n    assert!(res.is_err());\n\n    // fetch valid community\n    let res = get_apub_community_http(path.clone().into(), context.clone()).await?;\n    assert_eq!(200, res.status());\n    let res_group: Group = decode_response(res).await?;\n    let community: ApubCommunity = community.into();\n    let group = community.clone().into_json(&context).await?;\n    assert_eq!(group, res_group);\n\n    let res =\n      get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await?;\n    assert_eq!(200, res.status());\n    let query = Query(CommunityIsFollowerQuery { is_follower: None });\n    let res =\n      get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone())\n        .await?;\n    assert_eq!(200, res.status());\n    let res = get_apub_community_moderators(path.clone().into(), context.clone()).await?;\n    assert_eq!(200, res.status());\n    let res = get_apub_community_outbox(path, context.clone(), request).await?;\n    assert_eq!(200, res.status());\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_get_deleted_community() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let (data, _, path) = init(true, CommunityVisibility::Public, &context).await?;\n    let request = TestRequest::default().to_http_request();\n\n    // should return tombstone\n    let res = get_apub_community_http(path.clone().into(), context.clone()).await?;\n    assert_eq!(410, res.status());\n    let res_tombstone = decode_response::<Tombstone>(res).await;\n    assert!(res_tombstone.is_ok());\n\n    let res =\n      get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await;\n    assert!(res.is_err());\n    let query = Query(CommunityIsFollowerQuery { is_follower: None });\n    let res =\n      get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone())\n        .await;\n    assert!(res.is_err());\n    let res = get_apub_community_moderators(path.clone().into(), context.clone()).await;\n    assert!(res.is_err());\n    let res = get_apub_community_outbox(path, context.clone(), request).await;\n    assert!(res.is_err());\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_get_local_only_community() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let (data, _, path) = init(false, CommunityVisibility::LocalOnlyPrivate, &context).await?;\n    let request = TestRequest::default().to_http_request();\n\n    let res = get_apub_community_http(path.clone().into(), context.clone()).await;\n    assert!(res.is_err());\n    let res =\n      get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await;\n    assert!(res.is_err());\n    let query = Query(CommunityIsFollowerQuery { is_follower: None });\n    let res =\n      get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone())\n        .await;\n    assert!(res.is_err());\n    let res = get_apub_community_moderators(path.clone().into(), context.clone()).await;\n    assert!(res.is_err());\n    let res = get_apub_community_outbox(path, context.clone(), request).await;\n    assert!(res.is_err());\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_outbox_deleted_user() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let (data, community, path) = init(false, CommunityVisibility::Public, &context).await?;\n    let request = TestRequest::default().to_http_request();\n\n    // post from deleted user shouldnt break outbox\n    let mut form = PersonInsertForm::new(\"jerry\".to_string(), String::new(), data.instance.id);\n    form.deleted = Some(true);\n    let person = Person::create(&mut context.pool(), &form).await?;\n\n    let form = PostInsertForm::new(\"title\".to_string(), person.id, community.id);\n    Post::create(&mut context.pool(), &form).await?;\n\n    let res = get_apub_community_outbox(path, context.clone(), request).await?;\n    assert_eq!(200, res.status());\n\n    data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/mod.rs",
    "content": "use activitypub_federation::{\n  actix_web::{\n    inbox::{ReceiveActivityHook, receive_activity_with_hook},\n    response::create_http_response,\n    signing_actor,\n  },\n  config::Data,\n  traits::{Activity, Object},\n};\nuse actix_web::{\n  HttpRequest,\n  HttpResponse,\n  web::{self, Bytes},\n};\nuse either::Either;\nuse lemmy_api_utils::{context::LemmyContext, plugins::plugin_hook_after};\nuse lemmy_apub_activities::activity_lists::SharedInboxActivities;\nuse lemmy_apub_objects::objects::{SiteOrMultiOrCommunityOrUser, UserOrCommunity};\nuse lemmy_db_schema::source::{\n  activity::{ReceivedActivity, SentActivity},\n  community::Community,\n};\nuse lemmy_db_schema_file::{InstanceId, enums::CommunityVisibility};\nuse lemmy_db_views_community_follower_approval::PendingFollowerView;\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError},\n};\nuse serde::Deserialize;\nuse std::time::Duration;\nuse tokio::time::timeout;\nuse tracing::debug;\nuse url::Url;\n\nmod comment;\nmod community;\nmod person;\nmod post;\npub mod routes;\npub mod site;\n\nconst INCOMING_ACTIVITY_TIMEOUT: Duration = Duration::from_secs(9);\n\npub async fn shared_inbox(\n  request: HttpRequest,\n  body: Bytes,\n  data: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let receive_fut =\n    receive_activity_with_hook::<SharedInboxActivities, UserOrCommunity, LemmyContext>(\n      request, body, Dummy, &data,\n    );\n  // Set a timeout shorter than `REQWEST_TIMEOUT` for processing incoming activities. This is to\n  // avoid taking a long time to process an incoming activity when a required data fetch times out.\n  // In this case our own instance would timeout and be marked as dead by the sender. Better to\n  // consider the activity broken and move on.\n  timeout(INCOMING_ACTIVITY_TIMEOUT, receive_fut)\n    .await\n    .with_lemmy_type(UntranslatedError::InboxTimeout.into())?\n}\n\nstruct Dummy;\n\nimpl ReceiveActivityHook<SharedInboxActivities, UserOrCommunity, LemmyContext> for Dummy {\n  async fn hook(\n    self,\n    activity: &SharedInboxActivities,\n    _actor: &UserOrCommunity,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    // Store received activities in the database. This ensures that the same activity doesn't get\n    // received and processed more than once, which would be a waste of resources.\n    debug!(\"Received activity {}\", activity.id().to_string());\n    ReceivedActivity::create(&mut context.pool(), &activity.id().clone().into()).await?;\n\n    // This could also take the actor as param, but lifetimes and serde derives are tricky.\n    // It is really a before hook, but doesnt allow modifying the data. It could use a\n    // separate method so that error in plugin causes activity to be rejected.\n    plugin_hook_after(\"activity_after_receive\", activity);\n\n    // This method could also be used to check if actor is banned, instead of checking in each\n    // activity handler.\n    Ok(())\n  }\n}\n\n#[derive(Deserialize)]\nstruct ActivityQuery {\n  type_: String,\n  id: String,\n}\n\n/// Return the ActivityPub json representation of a local activity over HTTP.\nasync fn get_activity(\n  info: web::Path<ActivityQuery>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let settings = context.settings();\n  let activity_id = Url::parse(&format!(\n    \"{}/activities/{}/{}\",\n    settings.get_protocol_and_hostname(),\n    info.type_,\n    info.id\n  ))?\n  .into();\n  let activity = SentActivity::read_from_apub_id(&mut context.pool(), &activity_id).await?;\n\n  let sensitive = activity.sensitive;\n  if sensitive {\n    Ok(HttpResponse::Forbidden().finish())\n  } else {\n    Ok(create_http_response(&activity.data, &FEDERATION_CONTEXT)?)\n  }\n}\n\n/// Ensure that the community is public and not removed/deleted.\nfn check_community_fetchable(community: &Community) -> LemmyResult<()> {\n  if !community.visibility.can_federate() {\n    return Err(LemmyErrorType::NotFound.into());\n  }\n  Ok(())\n}\n\n/// Check if posts or comments in the community are allowed to be fetched\nasync fn check_community_content_fetchable(\n  community: &Community,\n  request: &HttpRequest,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  use CommunityVisibility::*;\n  match community.visibility {\n    Public | Unlisted => Ok(()),\n    Private => {\n      let signing_actor =\n        signing_actor::<SiteOrMultiOrCommunityOrUser>(request, None, context).await?;\n      if community.local {\n        Ok(\n          PendingFollowerView::check_has_followers_from_instance(\n            community.id,\n            get_instance_id(&signing_actor),\n            &mut context.pool(),\n          )\n          .await?,\n        )\n      } else if let Some(followers_url) = community.followers_url.clone() {\n        let mut followers_url = followers_url.inner().clone();\n        followers_url\n          .query_pairs_mut()\n          .append_pair(\"is_follower\", signing_actor.id().as_str());\n        let req = context.client().get(followers_url.as_str());\n        let req = context.sign_request(req, Bytes::new()).await?;\n        context.client().execute(req).await?.error_for_status()?;\n        Ok(())\n      } else {\n        Err(LemmyErrorType::NotFound.into())\n      }\n    }\n    LocalOnlyPublic | LocalOnlyPrivate => Err(LemmyErrorType::NotFound.into()),\n  }\n}\n\npub(in crate::http) fn get_instance_id(s: &SiteOrMultiOrCommunityOrUser) -> InstanceId {\n  use Either::*;\n  match s {\n    Left(Left(s)) => s.instance_id,\n    Left(Right(m)) => m.instance_id,\n    Right(Left(u)) => u.instance_id,\n    Right(Right(c)) => c.instance_id,\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/person.rs",
    "content": "use crate::protocol::collections::url_collection::UrlCollection;\nuse activitypub_federation::{config::Data, traits::Object};\nuse actix_web::{HttpResponse, web::Path};\nuse lemmy_api_utils::{context::LemmyContext, utils::generate_outbox_url};\nuse lemmy_apub_objects::objects::person::ApubPerson;\nuse lemmy_db_schema::{source::person::Person, traits::ApubActor};\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse serde::Deserialize;\n\n#[derive(Deserialize)]\npub(crate) struct PersonQuery {\n  user_name: String,\n}\n\n/// Return the ActivityPub json representation of a local person over HTTP.\npub(crate) async fn get_apub_person_http(\n  info: Path<PersonQuery>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let user_name = info.into_inner().user_name;\n  // This needs to be able to read deleted persons, so that it can send tombstones\n  let person: ApubPerson = Person::read_from_name(&mut context.pool(), &user_name, None, true)\n    .await?\n    .ok_or(LemmyErrorType::NotFound)?\n    .into();\n\n  person.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\npub(crate) async fn get_apub_person_outbox(\n  info: Path<PersonQuery>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let person = Person::read_from_name(&mut context.pool(), &info.user_name, None, false)\n    .await?\n    .ok_or(LemmyErrorType::NotFound)?;\n  let outbox_id = generate_outbox_url(&person.ap_id)?.to_string();\n  UrlCollection::new_empty_response(outbox_id)\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/post.rs",
    "content": "use super::check_community_content_fetchable;\nuse crate::protocol::collections::url_collection::UrlCollection;\nuse activitypub_federation::{config::Data, traits::Object};\nuse actix_web::{HttpRequest, HttpResponse, web};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::{objects::post::ApubPost, utils::functions::context_url};\nuse lemmy_db_schema::{\n  newtypes::PostId,\n  source::{community::Community, post::Post},\n};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse serde::Deserialize;\n\n#[derive(Deserialize)]\npub(crate) struct PostQuery {\n  post_id: String,\n}\n\nasync fn get_post(\n  info: web::Path<PostQuery>,\n  context: &Data<LemmyContext>,\n  request: &HttpRequest,\n) -> LemmyResult<ApubPost> {\n  let id = PostId(info.post_id.parse::<i32>()?);\n  // Can't use PostView here because it excludes deleted/removed/local-only items\n  let post: ApubPost = Post::read(&mut context.pool(), id).await?.into();\n  let community = Community::read(&mut context.pool(), post.community_id).await?;\n\n  check_community_content_fetchable(&community, request, context).await?;\n\n  Ok(post)\n}\n\n/// Return the ActivityPub json representation of a local post over HTTP.\npub(crate) async fn get_apub_post(\n  info: web::Path<PostQuery>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let post = get_post(info, &context, &request).await?;\n  post.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\npub(crate) async fn get_apub_post_context(\n  info: web::Path<PostQuery>,\n  context: Data<LemmyContext>,\n  request: HttpRequest,\n) -> LemmyResult<HttpResponse> {\n  let post = get_post(info, &context, &request).await?;\n  if !post.local {\n    return Err(LemmyErrorType::NotFound.into());\n  }\n  UrlCollection::new_response(&post, context_url(&post.ap_id), &context).await\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/routes.rs",
    "content": "use crate::http::{\n  comment::{get_apub_comment, get_apub_comment_context},\n  community::{\n    get_apub_community_featured,\n    get_apub_community_followers,\n    get_apub_community_http,\n    get_apub_community_moderators,\n    get_apub_community_outbox,\n    get_apub_community_tag_http,\n    get_apub_person_multi_community,\n    get_apub_person_multi_community_follows,\n  },\n  get_activity,\n  person::{get_apub_person_http, get_apub_person_outbox},\n  post::{get_apub_post, get_apub_post_context},\n  shared_inbox,\n  site::{get_apub_site_http, get_apub_site_outbox},\n};\nuse actix_web::{\n  guard::{Guard, GuardContext},\n  http::{Method, header},\n  web,\n};\n\npub fn config(cfg: &mut web::ServiceConfig) {\n  cfg\n    .route(\"/\", web::get().to(get_apub_site_http))\n    .route(\"/site_outbox\", web::get().to(get_apub_site_outbox))\n    .route(\n      \"/c/{community_name}\",\n      web::get().to(get_apub_community_http),\n    )\n    .route(\n      \"/c/{community_name}/followers\",\n      web::get().to(get_apub_community_followers),\n    )\n    .route(\n      \"/c/{community_name}/outbox\",\n      web::get().to(get_apub_community_outbox),\n    )\n    .route(\n      \"/c/{community_name}/featured\",\n      web::get().to(get_apub_community_featured),\n    )\n    .route(\n      \"/c/{community_name}/moderators\",\n      web::get().to(get_apub_community_moderators),\n    )\n    .route(\n      \"/c/{community_name}/tag/{tag_name}\",\n      web::get().to(get_apub_community_tag_http),\n    )\n    .route(\"/u/{user_name}\", web::get().to(get_apub_person_http))\n    .route(\n      \"/u/{user_name}/outbox\",\n      web::get().to(get_apub_person_outbox),\n    )\n    .route(\n      \"/m/{multi_name}\",\n      web::get().to(get_apub_person_multi_community),\n    )\n    .route(\n      \"/m/{multi_name}/following\",\n      web::get().to(get_apub_person_multi_community_follows),\n    )\n    .route(\"/post/{post_id}\", web::get().to(get_apub_post))\n    .route(\n      \"/post/{post_id}/context\",\n      web::get().to(get_apub_post_context),\n    )\n    .route(\"/comment/{comment_id}\", web::get().to(get_apub_comment))\n    .route(\n      \"/comment/{comment_id}/context\",\n      web::get().to(get_apub_comment_context),\n    )\n    .route(\"/activities/{type_}/{id}\", web::get().to(get_activity));\n\n  cfg.service(\n    web::scope(\"\")\n      .guard(InboxRequestGuard)\n      .route(\"/inbox\", web::post().to(shared_inbox)),\n  );\n}\n\n/// Without this, things like webfinger or RSS feeds stop working, as all requests seem to get\n/// routed into the inbox service (because it covers the root path). So we filter out anything that\n/// definitely can't be an inbox request (based on Accept header and request method).\nstruct InboxRequestGuard;\n\nimpl Guard for InboxRequestGuard {\n  fn check(&self, ctx: &GuardContext) -> bool {\n    if ctx.head().method != Method::POST {\n      return false;\n    }\n    if let Some(val) = ctx.head().headers.get(header::CONTENT_TYPE) {\n      return val.as_bytes().starts_with(b\"application/\");\n    }\n    false\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/http/site.rs",
    "content": "use crate::protocol::collections::url_collection::UrlCollection;\nuse activitypub_federation::{config::Data, traits::Object};\nuse actix_web::HttpResponse;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::objects::instance::ApubSite;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::{FEDERATION_CONTEXT, error::LemmyResult};\n\npub(crate) async fn get_apub_site_http(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {\n  let site: ApubSite = SiteView::read_local(&mut context.pool()).await?.site.into();\n\n  site.http_response(&FEDERATION_CONTEXT, &context).await\n}\n\npub(crate) async fn get_apub_site_outbox(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {\n  let outbox_id = format!(\n    \"{}/site_outbox\",\n    context.settings().get_protocol_and_hostname()\n  );\n  UrlCollection::new_empty_response(outbox_id)\n}\n"
  },
  {
    "path": "crates/apub/apub/src/lib.rs",
    "content": "use activitypub_federation::{config::UrlVerifier, error::Error as ActivityPubError};\nuse async_trait::async_trait;\nuse chrono::{Days, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_apub_objects::utils::functions::{check_apub_id_valid, local_site_data_cached};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::connection::ActualDbPool;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse url::Url;\n\npub mod collections;\npub mod http;\npub mod protocol;\n\n/// Maximum number of outgoing HTTP requests to fetch a single object. Needs to be high enough\n/// to fetch a new community with posts, moderators and featured posts.\npub const FEDERATION_HTTP_FETCH_LIMIT: u32 = 100;\n\n#[derive(Clone)]\npub struct VerifyUrlData(pub ActualDbPool);\n\n#[async_trait]\nimpl UrlVerifier for VerifyUrlData {\n  async fn verify(&self, url: &Url) -> Result<(), ActivityPubError> {\n    use UntranslatedError::*;\n    let local_site_data = local_site_data_cached(&mut (&self.0).into())\n      .await\n      .map_err(|e| ActivityPubError::Other(format!(\"Cant read local site data: {e}\")))?;\n\n    check_apub_id_valid(url, &local_site_data).map_err(|err| match err {\n      LemmyError {\n        error_type: LemmyErrorType::UntranslatedError(Some(FederationDisabled)),\n        ..\n      } => ActivityPubError::Other(\"Federation disabled\".into()),\n      LemmyError {\n        error_type: LemmyErrorType::UntranslatedError(Some(DomainBlocked(domain))),\n        ..\n      } => ActivityPubError::Other(format!(\"Domain {domain:?} is blocked\")),\n      LemmyError {\n        error_type: LemmyErrorType::UntranslatedError(Some(DomainNotInAllowList(domain))),\n        ..\n      } => ActivityPubError::Other(format!(\"Domain {domain:?} is not in allowlist\")),\n      _ => ActivityPubError::Other(\"Failed validating apub id\".into()),\n    })?;\n    Ok(())\n  }\n}\n\n/// Returns true if the local instance was created in the last 24 hours. In this case Lemmy should\n/// fetch less data over federation, because the setup task fetches a lot of communities.\nasync fn is_new_instance(context: &LemmyContext) -> LemmyResult<bool> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n  Ok(local_site.published_at - Days::new(1) < Utc::now())\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/group_featured.rs",
    "content": "use activitypub_federation::kinds::collection::OrderedCollectionType;\nuse lemmy_apub_objects::protocol::page::Page;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct GroupFeatured {\n  pub(crate) r#type: OrderedCollectionType,\n  pub(crate) id: Url,\n  pub(crate) total_items: i64,\n  pub(crate) ordered_items: Vec<Page>,\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/group_followers.rs",
    "content": "use activitypub_federation::kinds::collection::CollectionType;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct GroupFollowers {\n  pub(crate) id: Url,\n  pub(crate) r#type: CollectionType,\n  pub(crate) total_items: i32,\n  pub(crate) items: Vec<()>,\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/group_moderators.rs",
    "content": "use activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::collection::OrderedCollectionType,\n};\nuse lemmy_apub_objects::objects::person::ApubPerson;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct GroupModerators {\n  pub(crate) r#type: OrderedCollectionType,\n  pub(crate) id: Url,\n  pub(crate) ordered_items: Vec<ObjectId<ApubPerson>>,\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/group_outbox.rs",
    "content": "use activitypub_federation::kinds::collection::OrderedCollectionType;\nuse lemmy_apub_activities::protocol::community::announce::AnnounceActivity;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct GroupOutbox {\n  pub(crate) r#type: OrderedCollectionType,\n  pub(crate) id: Url,\n  pub(crate) total_items: i32,\n  pub(crate) ordered_items: Vec<AnnounceActivity>,\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/mod.rs",
    "content": "pub(crate) mod group_featured;\npub(crate) mod group_followers;\npub(crate) mod group_moderators;\npub(crate) mod group_outbox;\npub mod url_collection;\n\n#[cfg(test)]\n#[expect(clippy::as_conversions)]\nmod tests {\n  use crate::protocol::collections::{\n    group_featured::GroupFeatured,\n    group_followers::GroupFollowers,\n    group_moderators::GroupModerators,\n    group_outbox::GroupOutbox,\n    url_collection::UrlCollection,\n  };\n  use lemmy_apub_objects::utils::test::{test_json, test_parse_lemmy_item};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n\n  #[test]\n  fn test_parse_lemmy_collections() -> LemmyResult<()> {\n    test_parse_lemmy_item::<GroupFollowers>(\"assets/lemmy/collections/group_followers.json\")?;\n    let outbox =\n      test_parse_lemmy_item::<GroupOutbox>(\"assets/lemmy/collections/group_outbox.json\")?;\n    assert_eq!(outbox.ordered_items.len(), outbox.total_items as usize);\n    test_parse_lemmy_item::<GroupFeatured>(\"assets/lemmy/collections/group_featured_posts.json\")?;\n    test_parse_lemmy_item::<GroupModerators>(\"assets/lemmy/collections/group_moderators.json\")?;\n    test_parse_lemmy_item::<UrlCollection>(\"assets/lemmy/collections/person_outbox.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_mastodon_collections() -> LemmyResult<()> {\n    test_json::<GroupFeatured>(\"assets/mastodon/collections/featured.json\")?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/collections/url_collection.rs",
    "content": "use activitypub_federation::kinds::collection::OrderedCollectionType;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct UrlCollection {\n  pub(crate) r#type: OrderedCollectionType,\n  pub(crate) id: String,\n  pub(crate) total_items: i32,\n  pub(crate) ordered_items: Vec<Url>,\n}\n"
  },
  {
    "path": "crates/apub/apub/src/protocol/mod.rs",
    "content": "pub(crate) mod collections;\n"
  },
  {
    "path": "crates/apub/objects/Cargo.toml",
    "content": "[package]\nname = \"lemmy_apub_objects\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_apub_objects\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[features]\nfull = []\n\n[lints]\nworkspace = true\n\n[dependencies]\nlemmy_db_views_community_moderator = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_db_views_private_message = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true, features = [\"full\"] }\nactivitypub_federation = { workspace = true }\nlemmy_db_schema_file = { workspace = true }\nchrono = { workspace = true }\nserde_json = { workspace = true }\nserde = { workspace = true }\ntokio = { workspace = true }\ntracing = { workspace = true }\nurl = { workspace = true }\nfutures = { workspace = true }\nfutures-util = { workspace = true }\nitertools = { workspace = true }\nasync-trait = \"0.1.89\"\nanyhow = { workspace = true }\nmoka.workspace = true\nserde_with.workspace = true\nhtml2md = \"0.2.15\"\nhtml2text = { workspace = true }\nstringreader = \"0.1.1\"\nsemver = \"1.0.27\"\neither = \"1.15.0\"\nassert-json-diff = \"2.0.2\"\nlemmy_diesel_utils = { workspace = true }\nregex = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\npretty_assertions = { workspace = true }\n\n[package.metadata.cargo-shear]\nignored = [\"futures-util\"]\n"
  },
  {
    "path": "crates/apub/objects/src/lib.rs",
    "content": "pub mod objects;\npub mod protocol;\npub mod utils;\n"
  },
  {
    "path": "crates/apub/objects/src/objects/comment.rs",
    "content": "use crate::{\n  protocol::note::Note,\n  utils::{\n    functions::{\n      append_attachments_to_comment,\n      check_apub_id_valid_with_strictness,\n      context_url,\n      generate_to,\n      read_from_string_or_source,\n      verify_person_in_community,\n      verify_visibility,\n    },\n    markdown_links::markdown_rewrite_remote_links,\n    mentions::{collect_non_local_mentions, get_comment_parent_creator},\n    protocol::{InCommunity, LanguageTag, Source},\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::object::NoteType,\n  protocol::{\n    values::MediaTypeMarkdownOrHtml,\n    verification::{verify_domains_match, verify_is_remote_object},\n  },\n  traits::Object,\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  utils::{\n    check_comment_depth,\n    check_is_mod_or_admin,\n    get_url_blocklist,\n    process_markdown,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::source::{\n  comment::{Comment, CommentInsertForm, CommentUpdateForm},\n  community::Community,\n  person::Person,\n  post::Post,\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult, UntranslatedError},\n  utils::markdown::markdown_to_html,\n};\nuse std::ops::Deref;\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub struct ApubComment(pub Comment);\n\nimpl Deref for ApubComment {\n  type Target = Comment;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<Comment> for ApubComment {\n  fn from(c: Comment) -> Self {\n    ApubComment(c)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubComment {\n  type DataType = LemmyContext;\n  type Kind = Note;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      Comment::read_from_apub_id(&mut context.pool(), object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    if !self.deleted {\n      let form = CommentUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      };\n      Comment::update(&mut context.pool(), self.id, &form).await?;\n    }\n    Ok(())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.removed || self.deleted\n  }\n\n  async fn into_json(self, context: &Data<Self::DataType>) -> LemmyResult<Note> {\n    let creator_id = self.creator_id;\n    let creator = Person::read(&mut context.pool(), creator_id).await?;\n\n    let post_id = self.post_id;\n    let post = Post::read(&mut context.pool(), post_id).await?;\n    let community_id = post.community_id;\n    let community = Community::read(&mut context.pool(), community_id).await?;\n\n    let in_reply_to = if let Some(comment_id) = self.parent_comment_id() {\n      let parent_comment = Comment::read(&mut context.pool(), comment_id).await?;\n      parent_comment.ap_id.into()\n    } else {\n      post.ap_id.clone().into()\n    };\n    let language = Some(LanguageTag::new_single(self.language_id, &mut context.pool()).await?);\n    // Make this call optional in case the account was deleted.\n    let parent_creator = get_comment_parent_creator(&mut context.pool(), &self)\n      .await\n      .ok();\n    let maa = collect_non_local_mentions(Some(&self.content), parent_creator, context).await?;\n\n    let note = Note {\n      r#type: NoteType::Note,\n      id: self.ap_id.clone().into(),\n      attributed_to: creator.ap_id.into(),\n      to: generate_to(&community)?,\n      cc: maa.ccs,\n      content: markdown_to_html(&self.content),\n      media_type: Some(MediaTypeMarkdownOrHtml::Html),\n      source: Some(Source::new(self.content.clone())),\n      in_reply_to,\n      published: Some(self.published_at),\n      updated: self.updated_at,\n      tag: maa.mentions,\n      distinguished: Some(self.distinguished),\n      language,\n      audience: Some(community.ap_id.into()),\n      attachment: vec![],\n      context: Some(context_url(&self.ap_id)),\n    };\n\n    Ok(note)\n  }\n\n  /// Recursively fetches all parent comments. This can lead to a stack overflow so we need to\n  /// Box::pin all large futures on the heap.\n  async fn verify(\n    note: &Note,\n    expected_domain: &Url,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(note.id.inner(), expected_domain)?;\n    verify_domains_match(note.attributed_to.inner(), note.id.inner())?;\n    let community = Box::pin(note.community(context)).await?;\n    verify_visibility(&note.to, &note.cc, &community)?;\n\n    Box::pin(check_apub_id_valid_with_strictness(\n      note.id.inner(),\n      community.local,\n      context,\n    ))\n    .await?;\n    if let Err(e) = verify_is_remote_object(&note.id, context) {\n      if let Ok(comment) = note.id.dereference_local(context).await {\n        comment.set_not_pending(&mut context.pool()).await?;\n      }\n      return Err(e.into());\n    }\n    Box::pin(verify_person_in_community(\n      &note.attributed_to,\n      &community,\n      context,\n    ))\n    .await?;\n\n    let (post, parent_comment) = Box::pin(note.get_parents(context)).await?;\n    let creator = Box::pin(note.attributed_to.dereference(context)).await?;\n\n    let is_mod_or_admin = check_is_mod_or_admin(&mut context.pool(), creator.id, community.id)\n      .await\n      .is_ok();\n    let locked = post.locked || parent_comment.is_some_and(|c| c.locked);\n    if locked && !is_mod_or_admin {\n      return Err(UntranslatedError::PostIsLocked.into());\n    } else {\n      Ok(())\n    }\n  }\n\n  /// Converts a `Note` to `Comment`.\n  ///\n  /// If the parent community, post and comment(s) are not known locally, these are also fetched.\n  async fn from_json(note: Note, context: &Data<LemmyContext>) -> LemmyResult<ApubComment> {\n    let creator = note.attributed_to.dereference(context).await?;\n    let (post, parent_comment) = note.get_parents(context).await?;\n    if let Some(c) = &parent_comment {\n      check_comment_depth(c)?;\n    }\n\n    let content = read_from_string_or_source(&note.content, &note.media_type, &note.source);\n\n    let slur_regex = slur_regex(context).await?;\n    let url_blocklist = get_url_blocklist(context).await?;\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    let content = append_attachments_to_comment(content, &note.attachment, context).await?;\n    let content =\n      process_markdown(&content, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let content = markdown_rewrite_remote_links(content, context).await;\n    let language_id = Some(\n      LanguageTag::to_language_id_single(note.language.unwrap_or_default(), &mut context.pool())\n        .await?,\n    );\n\n    let mut form = CommentInsertForm {\n      creator_id: creator.id,\n      post_id: post.id,\n      content,\n      removed: None,\n      published_at: note.published,\n      updated_at: note.updated,\n      deleted: Some(false),\n      ap_id: Some(note.id.into()),\n      distinguished: note.distinguished,\n      local: Some(false),\n      language_id,\n      federation_pending: Some(false),\n      locked: None,\n    };\n    form = plugin_hook_before(\"federated_comment_before_receive\", form).await?;\n    let parent_comment_path = parent_comment.map(|t| t.0.path);\n    let timestamp: DateTime<Utc> = note.updated.or(note.published).unwrap_or_else(Utc::now);\n    let comment = Comment::insert_apub(\n      &mut context.pool(),\n      Some(timestamp),\n      &form,\n      parent_comment_path.as_ref(),\n    )\n    .await?;\n    plugin_hook_after(\"federated_comment_after_receive\", &comment);\n    Ok(comment.into())\n  }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use super::*;\n  use crate::{\n    objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson, post::ApubPost},\n    utils::test::{file_to_json_object, parse_lemmy_community, parse_lemmy_person},\n  };\n  use assert_json_diff::assert_json_include;\n  use html2md::parse_html;\n  use lemmy_db_schema::{source::instance::Instance, test_data::TestData};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  async fn prepare_comment_test(\n    url: &Url,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<(ApubPerson, ApubCommunity, ApubPost, ApubSite)> {\n    // use separate counter so this doesn't affect tests\n    let context2 = context.clone();\n    let (person, site) = parse_lemmy_person(&context2).await?;\n    let community = parse_lemmy_community(&context2).await?;\n    let post_json = file_to_json_object(\"../apub/assets/lemmy/objects/page.json\")?;\n    ApubPost::verify(&post_json, url, &context2).await?;\n    let post = ApubPost::from_json(post_json, &context2).await?;\n    Ok((person, community, post, site))\n  }\n\n  #[tokio::test]\n  #[serial]\n  pub(crate) async fn test_parse_lemmy_comment() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/comment/38741\")?;\n    prepare_comment_test(&url, &context).await?;\n\n    let json: Note = file_to_json_object(\"../apub/assets/lemmy/objects/comment.json\")?;\n    ApubComment::verify(&json, &url, &context).await?;\n    let comment = ApubComment::from_json(json.clone(), &context).await?;\n\n    assert_eq!(comment.ap_id, url.into());\n    assert_eq!(comment.content.len(), 14);\n    assert!(!comment.local);\n    assert_eq!(context.request_count(), 0);\n\n    let to_apub = comment.into_json(&context).await?;\n    assert_json_include!(actual: json, expected: to_apub);\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_pleroma_comment() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/comment/38741\")?;\n    prepare_comment_test(&url, &context).await?;\n\n    let pleroma_url =\n      Url::parse(\"https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2\")?;\n    let person_json = file_to_json_object(\"../apub/assets/pleroma/objects/person.json\")?;\n    ApubPerson::verify(&person_json, &pleroma_url, &context).await?;\n    ApubPerson::from_json(person_json, &context).await?;\n    let json = file_to_json_object(\"../apub/assets/pleroma/objects/note.json\")?;\n    ApubComment::verify(&json, &pleroma_url, &context).await?;\n    let comment = ApubComment::from_json(json, &context).await?;\n\n    assert_eq!(comment.ap_id, pleroma_url.into());\n    assert_eq!(comment.content.len(), 10);\n    assert!(!comment.local);\n    assert_eq!(context.request_count(), 1);\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_html_to_markdown_sanitize() {\n    let parsed = parse_html(\"<script></script><b>hello</b>\");\n    assert_eq!(parsed, \"**hello**\");\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/community.rs",
    "content": "use crate::{\n  objects::instance::fetch_instance_actor_for_object,\n  protocol::{group::Group, tags::ApubCommunityTag},\n  utils::{\n    functions::{\n      GetActorType,\n      check_apub_id_valid_with_strictness,\n      community_visibility,\n      read_from_string_or_source_opt,\n    },\n    markdown_links::markdown_rewrite_remote_links_opt,\n    protocol::{AttributedTo, ImageObject, LanguageTag, Source},\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  kinds::actor::GroupType,\n  protocol::{values::MediaTypeHtml, verification::verify_domains_match},\n  traits::{Actor, Object},\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{\n    check_nsfw_allowed,\n    generate_featured_url,\n    generate_moderators_url,\n    generate_outbox_url,\n    process_markdown_opt,\n    proxy_image_link_opt_apub,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::{\n    actor_language::CommunityLanguage,\n    community::{Community, CommunityInsertForm, CommunityUpdateForm},\n    community_tag::CommunityTag,\n  },\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::{ActorType, CommunityVisibility};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult},\n  utils::{markdown::markdown_to_html, slurs::remove_slurs, validation::truncate_summary},\n};\nuse regex::RegexSet;\nuse std::{ops::Deref, sync::OnceLock};\nuse url::Url;\n\n#[expect(clippy::type_complexity)]\npub static FETCH_COMMUNITY_COLLECTIONS: OnceLock<\n  fn(ApubCommunity, Group, Data<LemmyContext>) -> (),\n> = OnceLock::new();\n\n#[derive(Clone, Debug)]\npub struct ApubCommunity(pub Community);\n\nimpl Deref for ApubCommunity {\n  type Target = Community;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<Community> for ApubCommunity {\n  fn from(c: Community) -> Self {\n    ApubCommunity(c)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubCommunity {\n  type DataType = LemmyContext;\n  type Kind = Group;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {\n    Some(self.last_refreshed_at)\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      Community::read_from_apub_id(&mut context.pool(), &object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let form = CommunityUpdateForm {\n      deleted: Some(true),\n      ..Default::default()\n    };\n    Community::update(&mut context.pool(), self.id, &form).await?;\n    Ok(())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.removed || self.deleted\n  }\n\n  async fn into_json(self, data: &Data<Self::DataType>) -> LemmyResult<Group> {\n    let community_id = self.id;\n    let langs = CommunityLanguage::read(&mut data.pool(), community_id).await?;\n    let language = LanguageTag::new_multiple(langs, &mut data.pool()).await?;\n    let community_tags = CommunityTag::read_for_community(&mut data.pool(), community_id).await?;\n    let group = Group {\n      kind: GroupType::Group,\n      id: self.id().clone().into(),\n      preferred_username: self.name.clone(),\n      name: Some(self.title.clone()),\n      summary: self.sidebar.as_ref().map(|d| markdown_to_html(d)),\n      source: self.sidebar.clone().map(Source::new),\n      description: self.summary.clone(),\n      media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html),\n      icon: self.icon.clone().map(ImageObject::new),\n      image: self.banner.clone().map(ImageObject::new),\n      sensitive: Some(self.nsfw),\n      featured: Some(generate_featured_url(&self.ap_id)?.into()),\n      inbox: self.inbox_url.clone().into(),\n      outbox: generate_outbox_url(&self.ap_id)?.into(),\n      followers: self.followers_url.clone().map(Into::into),\n      endpoints: None,\n      public_key: self.public_key(),\n      language,\n      published: Some(self.published_at),\n      updated: self.updated_at,\n      posting_restricted_to_mods: Some(self.posting_restricted_to_mods),\n      attributed_to: Some(AttributedTo::Lemmy(\n        generate_moderators_url(&self.ap_id)?.into(),\n      )),\n      manually_approves_followers: Some(self.visibility == CommunityVisibility::Private),\n      discoverable: Some(self.visibility != CommunityVisibility::Unlisted),\n      tag: community_tags\n        .into_iter()\n        .map(ApubCommunityTag::to_json)\n        .collect(),\n    };\n    Ok(group)\n  }\n\n  async fn verify(\n    group: &Group,\n    expected_domain: &Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    check_apub_id_valid_with_strictness(group.id.inner(), true, context).await?;\n    verify_domains_match(expected_domain, group.id.inner())?;\n\n    // Doesnt call verify_is_remote_object() because the community might be edited by a\n    // remote mod. This is safe as we validate `expected_domain`.\n    Ok(())\n  }\n\n  /// Converts a `Group` to `Community`, inserts it into the database and updates moderators.\n  async fn from_json(group: Group, context: &Data<Self::DataType>) -> LemmyResult<ApubCommunity> {\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n    let instance_id = fetch_instance_actor_for_object(&group.id, context).await?;\n\n    let slur_regex = slur_regex(context).await?;\n    // Use empty regex so that url blocklist doesnt prevent community federation.\n    let url_blocklist = RegexSet::empty();\n\n    let sidebar = read_from_string_or_source_opt(&group.summary, &None, &group.source);\n    let sidebar =\n      process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await;\n\n    let icon =\n      proxy_image_link_opt_apub(group.icon.clone().map(|i| i.url), &local_site, context).await?;\n    let banner =\n      proxy_image_link_opt_apub(group.image.clone().map(|i| i.url), &local_site, context).await?;\n    let visibility = Some(community_visibility(&group));\n\n    let summary = group\n      .description\n      .clone()\n      .as_deref()\n      .map(truncate_summary)\n      .map(|s| remove_slurs(&s, &slur_regex));\n\n    let name = group.preferred_username.clone();\n    let title = remove_slurs(&group.name.clone().unwrap_or(name.clone()), &slur_regex);\n\n    // If NSFW is not allowed, then remove NSFW communities\n    let removed = check_nsfw_allowed(group.sensitive, Some(&local_site))\n      .err()\n      .map(|_| true);\n\n    let form = CommunityInsertForm {\n      published_at: group.published,\n      updated_at: group.updated,\n      deleted: Some(false),\n      nsfw: Some(group.sensitive.unwrap_or(false)),\n      ap_id: Some(group.id.clone().into()),\n      // May be a local community which is updated by remote mod.\n      local: Some(group.id.is_local(context)),\n      last_refreshed_at: Some(Utc::now()),\n      icon,\n      banner,\n      sidebar,\n      removed,\n      summary,\n      followers_url: group.followers.clone().clone().map(Into::into),\n      inbox_url: Some(\n        group\n          .endpoints\n          .clone()\n          .map(|e| e.shared_inbox)\n          .unwrap_or(group.inbox.clone())\n          .into(),\n      ),\n      moderators_url: group\n        .attributed_to\n        .clone()\n        .clone()\n        .and_then(AttributedTo::url),\n      posting_restricted_to_mods: group.posting_restricted_to_mods,\n      featured_url: group.featured.clone().clone().map(Into::into),\n      visibility,\n      ..CommunityInsertForm::new(\n        instance_id,\n        name,\n        title,\n        group.public_key.public_key_pem.clone(),\n      )\n    };\n    let languages =\n      LanguageTag::to_language_id_multiple(group.language.clone(), &mut context.pool()).await?;\n\n    let timestamp = group.updated.or(group.published).unwrap_or_else(Utc::now);\n    let community = Community::insert_apub(&mut context.pool(), timestamp, &form).await?;\n    CommunityLanguage::update(&mut context.pool(), languages, community.id).await?;\n\n    let new_tags = group\n      .tag\n      .iter()\n      .map(|t| t.to_insert_form(community.id))\n      .collect();\n    let existing_tags = CommunityTag::read_for_community(&mut context.pool(), community.id).await?;\n    CommunityTag::update_many(&mut context.pool(), new_tags, existing_tags).await?;\n\n    let community: ApubCommunity = community.into();\n\n    // These collections are not necessary for Lemmy to work, so ignore errors. Reset request count\n    // to avoid fetch errors, as it needs to fetch a lot of extra data.\n    if let Some(fetch_fn) = FETCH_COMMUNITY_COLLECTIONS.get() {\n      fetch_fn(\n        community.clone(),\n        group.clone(),\n        context.reset_request_count(),\n      );\n    }\n\n    Ok(community)\n  }\n}\n\nimpl Actor for ApubCommunity {\n  fn public_key_pem(&self) -> &str {\n    &self.public_key\n  }\n\n  fn private_key_pem(&self) -> Option<String> {\n    self.private_key.clone().map(SensitiveString::into_inner)\n  }\n\n  fn inbox(&self) -> Url {\n    self.inbox_url.clone().into()\n  }\n\n  fn shared_inbox(&self) -> Option<Url> {\n    None\n  }\n}\n\nimpl GetActorType for ApubCommunity {\n  fn actor_type(&self) -> ActorType {\n    ActorType::Community\n  }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use super::*;\n  use crate::utils::test::{parse_lemmy_community, parse_lemmy_instance};\n  use lemmy_db_schema::{source::instance::Instance, test_data::TestData};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_community() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    parse_lemmy_instance(&context).await?;\n    let community = parse_lemmy_community(&context).await?;\n\n    assert_eq!(community.title, \"Ten Forward\");\n    assert!(!community.local);\n\n    // Test the sidebar and description\n    assert_eq!(\n      community.sidebar.as_ref().map(std::string::String::len),\n      Some(63)\n    );\n    assert_eq!(\n      community.summary.as_ref().map(std::string::String::len),\n      Some(29)\n    );\n\n    Instance::delete_all(&mut context.pool()).await?;\n    test_data.delete(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/instance.rs",
    "content": "use crate::{\n  protocol::instance::Instance,\n  utils::{\n    functions::{\n      GetActorType,\n      check_apub_id_valid_with_strictness,\n      read_from_string_or_source_opt,\n    },\n    markdown_links::markdown_rewrite_remote_links_opt,\n    protocol::{ImageObject, LanguageTag, Source},\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::actor::ApplicationType,\n  protocol::{\n    values::MediaTypeHtml,\n    verification::{verify_domains_match, verify_is_remote_object},\n  },\n  traits::{Actor, Object},\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{get_url_blocklist, process_markdown_opt, proxy_image_link_opt_apub, slur_regex},\n};\nuse lemmy_db_schema::source::{\n  actor_language::SiteLanguage,\n  instance::Instance as DbInstance,\n  site::{Site, SiteInsertForm},\n};\nuse lemmy_db_schema_file::{InstanceId, enums::ActorType};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult, UntranslatedError},\n  utils::{markdown::markdown_to_html, slurs::remove_slurs},\n};\nuse std::ops::Deref;\nuse tracing::debug;\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub struct ApubSite(pub Site);\n\nimpl Deref for ApubSite {\n  type Target = Site;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<Site> for ApubSite {\n  fn from(s: Site) -> Self {\n    ApubSite(s)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubSite {\n  type DataType = LemmyContext;\n  type Kind = Instance;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {\n    Some(self.last_refreshed_at)\n  }\n\n  async fn read_from_id(object_id: Url, data: &Data<Self::DataType>) -> LemmyResult<Option<Self>> {\n    Ok(\n      Site::read_from_apub_id(&mut data.pool(), &object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, _data: &Data<Self::DataType>) -> LemmyResult<()> {\n    Err(UntranslatedError::CantDeleteSite.into())\n  }\n\n  async fn into_json(self, data: &Data<Self::DataType>) -> LemmyResult<Self::Kind> {\n    let site_id = self.id;\n    let langs = SiteLanguage::read(&mut data.pool(), site_id).await?;\n    let language = LanguageTag::new_multiple(langs, &mut data.pool()).await?;\n\n    let instance = Instance {\n      kind: ApplicationType::Application,\n      id: self.id().clone().into(),\n      name: self.name.clone(),\n      preferred_username: Some(data.domain().to_string()),\n      content: self.sidebar.as_ref().map(|d| markdown_to_html(d)),\n      source: self.sidebar.clone().map(Source::new),\n      description: self.summary.clone(),\n      media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html),\n      icon: self.icon.clone().map(ImageObject::new),\n      image: self.banner.clone().map(ImageObject::new),\n      inbox: self.inbox_url.clone().into(),\n      outbox: Url::parse(&format!(\"{}site_outbox\", self.ap_id))?,\n      public_key: self.public_key(),\n      language,\n      content_warning: self.content_warning.clone(),\n      published: Some(self.published_at),\n      updated: self.updated_at,\n    };\n    Ok(instance)\n  }\n\n  async fn verify(\n    apub: &Self::Kind,\n    expected_domain: &Url,\n    data: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    check_apub_id_valid_with_strictness(apub.id.inner(), true, data).await?;\n    verify_domains_match(expected_domain, apub.id.inner())?;\n    verify_is_remote_object(&apub.id, data)?;\n\n    Ok(())\n  }\n\n  async fn from_json(apub: Self::Kind, context: &Data<Self::DataType>) -> LemmyResult<Self> {\n    let domain = apub\n      .id\n      .inner()\n      .domain()\n      .ok_or(UntranslatedError::UrlWithoutDomain)?;\n    let instance = DbInstance::read_or_create(&mut context.pool(), domain).await?;\n\n    let slur_regex = slur_regex(context).await?;\n    let url_blocklist = get_url_blocklist(context).await?;\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    let sidebar = read_from_string_or_source_opt(&apub.content, &None, &apub.source);\n    let sidebar =\n      process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await;\n    let summary = apub.description.map(|s| remove_slurs(&s, &slur_regex));\n    let icon = proxy_image_link_opt_apub(apub.icon.map(|i| i.url), &local_site, context).await?;\n    let banner = proxy_image_link_opt_apub(apub.image.map(|i| i.url), &local_site, context).await?;\n\n    let site_form = SiteInsertForm {\n      name: apub.name.clone(),\n      sidebar,\n      published_at: apub.published,\n      updated_at: apub.updated,\n      icon,\n      banner,\n      summary,\n      ap_id: Some(apub.id.clone().into()),\n      last_refreshed_at: Some(Utc::now()),\n      inbox_url: Some(apub.inbox.clone().into()),\n      public_key: Some(apub.public_key.public_key_pem.clone()),\n      private_key: None,\n      instance_id: instance.id,\n      content_warning: apub.content_warning,\n    };\n    let languages =\n      LanguageTag::to_language_id_multiple(apub.language, &mut context.pool()).await?;\n\n    let site = Site::create(&mut context.pool(), &site_form).await?;\n    SiteLanguage::update(&mut context.pool(), languages, &site).await?;\n    Ok(site.into())\n  }\n}\n\nimpl Actor for ApubSite {\n  fn public_key_pem(&self) -> &str {\n    &self.public_key\n  }\n\n  fn private_key_pem(&self) -> Option<String> {\n    self.private_key.clone().map(SensitiveString::into_inner)\n  }\n\n  fn inbox(&self) -> Url {\n    self.inbox_url.clone().into()\n  }\n}\nimpl GetActorType for ApubSite {\n  fn actor_type(&self) -> ActorType {\n    ActorType::Site\n  }\n}\n\n/// Try to fetch the instance actor (to make things like instance rules available).\npub(crate) async fn fetch_instance_actor_for_object<T: Into<Url> + Clone>(\n  object_id: &T,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<InstanceId> {\n  let object_id: Url = object_id.clone().into();\n  let instance_id = Site::instance_ap_id_from_url(object_id);\n  let site = ObjectId::<ApubSite>::from(instance_id.clone())\n    .dereference(context)\n    .await;\n  match site {\n    Ok(s) => Ok(s.instance_id),\n    Err(e) => {\n      // Failed to fetch instance actor, its probably not a lemmy instance\n      debug!(\"Failed to dereference site for {}: {}\", &instance_id, e);\n      let domain = instance_id\n        .domain()\n        .ok_or(UntranslatedError::UrlWithoutDomain)?;\n      Ok(\n        DbInstance::read_or_create(&mut context.pool(), domain)\n          .await?\n          .id,\n      )\n    }\n  }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use super::*;\n  use crate::utils::test::parse_lemmy_instance;\n  use lemmy_db_schema::{source::instance::Instance, test_data::TestData};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_instance() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let site = parse_lemmy_instance(&context).await?;\n\n    assert_eq!(site.name, \"Enterprise\");\n    assert_eq!(\n      site.summary.as_ref().map(std::string::String::len),\n      Some(15)\n    );\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/mod.rs",
    "content": "pub mod comment;\npub mod community;\npub mod instance;\npub mod multi_community;\npub mod multi_community_collection;\npub mod person;\npub mod post;\npub mod private_message;\n\nuse comment::ApubComment;\nuse community::ApubCommunity;\nuse either::Either;\nuse instance::ApubSite;\nuse multi_community::ApubMultiCommunity;\nuse person::ApubPerson;\nuse post::ApubPost;\n\n// TODO: some of these are redundant?\n\npub type PostOrComment = Either<ApubPost, ApubComment>;\n\npub type SearchableObjects = Either<Either<PostOrComment, UserOrCommunity>, ApubMultiCommunity>;\n\npub type ReportableObjects = Either<PostOrComment, ApubCommunity>;\n\npub type UserOrCommunity = Either<ApubPerson, ApubCommunity>;\n\npub type SiteOrMultiOrCommunityOrUser =\n  Either<Either<ApubSite, ApubMultiCommunity>, UserOrCommunity>;\n\npub type CommunityOrMulti = Either<ApubCommunity, ApubMultiCommunity>;\n\npub type UserOrCommunityOrMulti = Either<ApubPerson, CommunityOrMulti>;\n"
  },
  {
    "path": "crates/apub/objects/src/objects/multi_community.rs",
    "content": "use crate::{\n  objects::ApubSite,\n  protocol::multi_community::Feed,\n  utils::{\n    functions::{\n      GetActorType,\n      check_apub_id_valid_with_strictness,\n      read_from_string_or_source_opt,\n    },\n    markdown_links::markdown_rewrite_remote_links_opt,\n    protocol::Source,\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::{\n    values::MediaTypeHtml,\n    verification::{verify_domains_match, verify_is_remote_object},\n  },\n  traits::{Actor, Object},\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{process_markdown_opt, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::{\n    multi_community::{MultiCommunity, MultiCommunityInsertForm},\n    person::Person,\n  },\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::ActorType;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyErrorType, LemmyResult},\n  utils::{\n    markdown::markdown_to_html,\n    slurs::remove_slurs,\n    validation::{\n      is_valid_body_field,\n      is_valid_display_name,\n      summary_length_check,\n      truncate_summary,\n    },\n  },\n};\nuse regex::RegexSet;\nuse std::ops::Deref;\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub struct ApubMultiCommunity(MultiCommunity);\n\nimpl Deref for ApubMultiCommunity {\n  type Target = MultiCommunity;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<MultiCommunity> for ApubMultiCommunity {\n  fn from(m: MultiCommunity) -> Self {\n    ApubMultiCommunity(m)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubMultiCommunity {\n  type DataType = LemmyContext;\n  type Kind = Feed;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {\n    Some(self.last_refreshed_at)\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      MultiCommunity::read_from_apub_id(&mut context.pool(), &object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, _context: &Data<Self::DataType>) -> LemmyResult<()> {\n    Err(LemmyErrorType::NotFound.into())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.deleted\n  }\n\n  async fn into_json(self, context: &Data<Self::DataType>) -> LemmyResult<Self::Kind> {\n    let site_view = SiteView::read_local(&mut context.pool()).await?;\n    let site = ApubSite(site_view.site.clone());\n    let creator = Person::read(&mut context.pool(), self.creator_id).await?;\n    Ok(Feed {\n      r#type: Default::default(),\n      id: self.ap_id.clone().into(),\n      inbox: site_view.site.inbox_url.into(),\n      // reusing pubkey from site instead of generating new one\n      public_key: site.public_key(),\n      following: self.following_url.clone().into(),\n      preferred_username: self.name.clone(),\n      name: self.title.clone(),\n      summary: self.sidebar.as_ref().map(|d| markdown_to_html(d)),\n      source: self.sidebar.clone().map(Source::new),\n      description: self.summary.clone(),\n      media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html),\n      attributed_to: creator.ap_id.into(),\n    })\n  }\n\n  async fn verify(\n    json: &Self::Kind,\n    expected_domain: &Url,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    check_apub_id_valid_with_strictness(json.id.inner(), true, context).await?;\n    verify_domains_match(expected_domain, json.id.inner())?;\n    verify_is_remote_object(&json.id, context)?;\n\n    Ok(())\n  }\n\n  async fn from_json(json: Self::Kind, context: &Data<LemmyContext>) -> LemmyResult<Self> {\n    let creator = json.attributed_to.dereference(context).await?;\n    let slur_regex = slur_regex(context).await?;\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    // Use empty regex so that url blocklist doesnt prevent community federation.\n    let url_blocklist = RegexSet::empty();\n\n    let sidebar = read_from_string_or_source_opt(&json.summary, &None, &json.source);\n    let sidebar =\n      process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await;\n    if let Some(sidebar) = &sidebar {\n      is_valid_body_field(sidebar, false)?;\n    }\n\n    let summary = json\n      .description\n      .clone()\n      .as_deref()\n      .map(truncate_summary)\n      .map(|s| remove_slurs(&s, &slur_regex));\n    if let Some(summary) = &summary {\n      summary_length_check(summary)?;\n    }\n\n    let name = json.preferred_username.clone();\n    let title = json.name.map(|t| remove_slurs(&t, &slur_regex));\n    if let Some(title) = &title {\n      is_valid_display_name(title)?;\n    }\n\n    let form = MultiCommunityInsertForm {\n      creator_id: creator.id,\n      instance_id: creator.instance_id,\n      name,\n      ap_id: Some(json.id.into()),\n      local: Some(false),\n      title,\n      summary,\n      sidebar,\n      public_key: json.public_key.public_key_pem,\n      private_key: None,\n      inbox_url: Some(json.inbox.into()),\n      following_url: Some(json.following.clone().into()),\n      last_refreshed_at: Some(Utc::now()),\n    };\n\n    let multi = MultiCommunity::upsert(&mut context.pool(), &form)\n      .await?\n      .into();\n    json.following.dereference(&multi, context).await?;\n    Ok(multi)\n  }\n}\n\nimpl Actor for ApubMultiCommunity {\n  fn public_key_pem(&self) -> &str {\n    &self.public_key\n  }\n\n  fn private_key_pem(&self) -> Option<String> {\n    self.private_key.clone().map(SensitiveString::into_inner)\n  }\n\n  fn inbox(&self) -> Url {\n    self.inbox_url.clone().into()\n  }\n\n  fn shared_inbox(&self) -> Option<Url> {\n    None\n  }\n}\n\nimpl GetActorType for ApubMultiCommunity {\n  fn actor_type(&self) -> ActorType {\n    ActorType::MultiCommunity\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/multi_community_collection.rs",
    "content": "use super::multi_community::ApubMultiCommunity;\nuse crate::protocol::multi_community::FeedCollection;\nuse activitypub_federation::{\n  config::Data,\n  protocol::verification::verify_domains_match,\n  traits::Collection,\n};\nuse futures::future::join_all;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n};\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{\n    community::{CommunityActions, CommunityFollowerForm},\n    multi_community::MultiCommunity,\n  },\n  traits::Followable,\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyError, LemmyResult};\nuse tracing::info;\nuse url::Url;\n\npub struct ApubFeedCollection;\n\n#[async_trait::async_trait]\nimpl Collection for ApubFeedCollection {\n  type DataType = LemmyContext;\n  type Kind = FeedCollection;\n  type Owner = ApubMultiCommunity;\n  type Error = LemmyError;\n\n  async fn read_local(\n    owner: &Self::Owner,\n    context: &Data<Self::DataType>,\n  ) -> Result<Self::Kind, Self::Error> {\n    let entries = MultiCommunity::read_community_ap_ids(&mut context.pool(), &owner.name).await?;\n    Ok(Self::Kind {\n      r#type: Default::default(),\n      id: owner.following_url.clone().into(),\n      total_items: entries.len().try_into()?,\n      items: entries.into_iter().map(Into::into).collect(),\n    })\n  }\n\n  async fn verify(\n    json: &Self::Kind,\n    expected_domain: &Url,\n    _context: &Data<LemmyContext>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(expected_domain, &json.id.clone().into())?;\n    Ok(())\n  }\n\n  async fn from_json(\n    json: Self::Kind,\n    owner: &Self::Owner,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<Self> {\n    let communities = join_all(\n      json\n        .items\n        .into_iter()\n        .map(|ap_id| async move { Ok(ap_id.dereference(context).await?.id) }),\n    )\n    .await\n    .into_iter()\n    .flat_map(|c: LemmyResult<CommunityId>| match c {\n      Ok(c) => Some(c),\n      Err(e) => {\n        info!(\"Failed to fetch multi-community item: {e}\");\n        None\n      }\n    })\n    .collect();\n\n    let (remote_added, remote_removed, has_local_followers) =\n      MultiCommunity::update_entries(&mut context.pool(), owner.id, &communities).await?;\n\n    // Have multi-comm follower bot follow all communities which were added to multi-comm,\n    // and unfollow those that were removed.\n    // If the multi-comm has no local followers its ignored.\n    // TODO: This means there will be posts missing in multi-comm without local followers.\n    if has_local_followers {\n      let system_account = SiteView::read_system_account(&mut context.pool()).await?;\n      for community in remote_added {\n        let form = CommunityFollowerForm::new(\n          community.id,\n          system_account.id,\n          CommunityFollowerState::Pending,\n        );\n        CommunityActions::follow(&mut context.pool(), &form).await?;\n        ActivityChannel::submit_activity(\n          SendActivityData::FollowCommunity(community.clone(), system_account.clone(), true),\n          context,\n        )?;\n      }\n      for community in remote_removed {\n        CommunityActions::unfollow(&mut context.pool(), system_account.id, community.id).await?;\n        ActivityChannel::submit_activity(\n          SendActivityData::FollowCommunity(community.clone(), system_account.clone(), false),\n          context,\n        )?;\n      }\n    }\n\n    Ok(ApubFeedCollection)\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/person.rs",
    "content": "use crate::{\n  objects::instance::fetch_instance_actor_for_object,\n  protocol::person::{Person, UserTypes},\n  utils::{\n    functions::{\n      GetActorType,\n      check_apub_id_valid_with_strictness,\n      read_from_string_or_source_opt,\n    },\n    markdown_links::markdown_rewrite_remote_links_opt,\n    protocol::{ImageObject, Source},\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::verification::{verify_domains_match, verify_is_remote_object},\n  traits::{Actor, Object},\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{\n    generate_outbox_url,\n    get_url_blocklist,\n    process_markdown_opt,\n    proxy_image_link_opt_apub,\n    slur_regex,\n  },\n};\nuse lemmy_db_schema::{\n  source::person::{Person as DbPerson, PersonInsertForm, PersonUpdateForm},\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::ActorType;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult},\n  utils::{markdown::markdown_to_html, slurs::remove_slurs},\n};\nuse std::ops::Deref;\nuse url::Url;\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct ApubPerson(pub DbPerson);\n\nimpl Deref for ApubPerson {\n  type Target = DbPerson;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<DbPerson> for ApubPerson {\n  fn from(p: DbPerson) -> Self {\n    ApubPerson(p)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubPerson {\n  type DataType = LemmyContext;\n  type Kind = Person;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {\n    Some(self.last_refreshed_at)\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      DbPerson::read_from_apub_id(&mut context.pool(), &object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    let form = PersonUpdateForm {\n      deleted: Some(true),\n      ..Default::default()\n    };\n    DbPerson::update(&mut context.pool(), self.id, &form).await?;\n    Ok(())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.deleted\n  }\n\n  async fn into_json(self, _context: &Data<Self::DataType>) -> LemmyResult<Person> {\n    let kind = if self.bot_account {\n      UserTypes::Service\n    } else {\n      UserTypes::Person\n    };\n\n    let person = Person {\n      kind,\n      id: self.ap_id.clone().into(),\n      preferred_username: self.name.clone(),\n      name: self.display_name.clone(),\n      summary: self.bio.as_ref().map(|b| markdown_to_html(b)),\n      source: self.bio.clone().map(Source::new),\n      icon: self.avatar.clone().map(ImageObject::new),\n      image: self.banner.clone().map(ImageObject::new),\n      matrix_user_id: self.matrix_user_id.clone(),\n      published: Some(self.published_at),\n      outbox: generate_outbox_url(&self.ap_id)?.into(),\n      endpoints: None,\n      public_key: self.public_key(),\n      updated: self.updated_at,\n      inbox: self.inbox_url.clone().into(),\n    };\n    Ok(person)\n  }\n\n  async fn verify(\n    person: &Person,\n    expected_domain: &Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(person.id.inner(), expected_domain)?;\n    verify_is_remote_object(&person.id, context)?;\n    check_apub_id_valid_with_strictness(person.id.inner(), false, context).await?;\n\n    Ok(())\n  }\n\n  async fn from_json(person: Person, context: &Data<Self::DataType>) -> LemmyResult<ApubPerson> {\n    let instance_id = fetch_instance_actor_for_object(&person.id, context).await?;\n\n    let slur_regex = slur_regex(context).await?;\n    let url_blocklist = get_url_blocklist(context).await?;\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    let bio = read_from_string_or_source_opt(&person.summary, &None, &person.source);\n    let bio = process_markdown_opt(&bio, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let bio = markdown_rewrite_remote_links_opt(bio, context).await;\n    let avatar =\n      proxy_image_link_opt_apub(person.icon.map(|i| i.url), &local_site, context).await?;\n    let banner =\n      proxy_image_link_opt_apub(person.image.map(|i| i.url), &local_site, context).await?;\n    let display_name = person.name.map(|s| remove_slurs(&s, &slur_regex));\n\n    let person_form = PersonInsertForm {\n      name: person.preferred_username,\n      display_name,\n      deleted: Some(false),\n      avatar,\n      banner,\n      published_at: person.published,\n      updated_at: person.updated,\n      ap_id: Some(person.id.into()),\n      bio,\n      local: Some(false),\n      bot_account: Some(person.kind == UserTypes::Service),\n      private_key: None,\n      public_key: person.public_key.public_key_pem,\n      last_refreshed_at: Some(Utc::now()),\n      inbox_url: Some(\n        person\n          .endpoints\n          .map(|e| e.shared_inbox)\n          .unwrap_or(person.inbox)\n          .into(),\n      ),\n      matrix_user_id: person.matrix_user_id,\n      instance_id,\n    };\n    let person = DbPerson::upsert(&mut context.pool(), &person_form).await?;\n\n    Ok(person.into())\n  }\n}\n\nimpl Actor for ApubPerson {\n  fn public_key_pem(&self) -> &str {\n    &self.public_key\n  }\n\n  fn private_key_pem(&self) -> Option<String> {\n    self.private_key.clone().map(SensitiveString::into_inner)\n  }\n\n  fn inbox(&self) -> Url {\n    self.inbox_url.clone().into()\n  }\n\n  fn shared_inbox(&self) -> Option<Url> {\n    None\n  }\n}\n\nimpl GetActorType for ApubPerson {\n  fn actor_type(&self) -> ActorType {\n    ActorType::Person\n  }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use super::*;\n  use crate::{\n    objects::instance::ApubSite,\n    utils::test::{file_to_json_object, parse_lemmy_person},\n  };\n  use activitypub_federation::fetch::object_id::ObjectId;\n  use lemmy_db_schema::{source::instance::Instance, test_data::TestData};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_person() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let (person, _) = parse_lemmy_person(&context).await?;\n\n    assert_eq!(person.display_name, Some(\"Jean-Luc Picard\".to_string()));\n    assert!(!person.local);\n    assert_eq!(person.bio.as_ref().map(std::string::String::len), Some(39));\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_pleroma_person() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n\n    // create and parse a fake pleroma instance actor, to avoid network request during test\n    let mut json: crate::protocol::instance::Instance =\n      file_to_json_object(\"../apub/assets/lemmy/objects/instance.json\")?;\n    json.id = ObjectId::parse(\"https://queer.hacktivis.me/\")?;\n    let url = Url::parse(\"https://queer.hacktivis.me/users/lanodan\")?;\n    ApubSite::verify(&json, &url, &context).await?;\n    ApubSite::from_json(json, &context).await?;\n\n    let json = file_to_json_object(\"../apub/assets/pleroma/objects/person.json\")?;\n    ApubPerson::verify(&json, &url, &context).await?;\n    let person = ApubPerson::from_json(json, &context).await?;\n\n    assert_eq!(person.ap_id, url.into());\n    assert_eq!(person.name, \"lanodan\");\n    assert!(!person.local);\n    assert_eq!(context.request_count(), 0);\n    assert_eq!(person.bio.as_ref().map(std::string::String::len), Some(812));\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/post.rs",
    "content": "use crate::{\n  protocol::{\n    page::{Attachment, Page, PageType},\n    tags::{ApubCommunityTag, ApubTag, Hashtag, HashtagType},\n  },\n  utils::{\n    functions::{\n      check_apub_id_valid_with_strictness,\n      context_url,\n      generate_to,\n      read_from_string_or_source_opt,\n      verify_person_in_community,\n      verify_visibility,\n    },\n    markdown_links::{markdown_rewrite_remote_links_opt, to_local_url},\n    mentions::collect_non_local_mentions,\n    protocol::{AttributedTo, ImageObject, InCommunity, LanguageTag, Source},\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::{\n    values::MediaTypeMarkdownOrHtml,\n    verification::{verify_domains_match, verify_is_remote_object},\n  },\n  traits::Object,\n};\nuse anyhow::anyhow;\nuse chrono::Utc;\nuse html2text::{from_read_with_decorator, render::TrivialDecorator};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  request::generate_post_link_metadata,\n  utils::{\n    check_nsfw_allowed,\n    get_url_blocklist,\n    process_markdown_opt,\n    slur_regex,\n    update_post_tags,\n  },\n};\nuse lemmy_db_schema::source::{\n  community::Community,\n  community_tag::CommunityTag,\n  local_site::LocalSite,\n  person::Person,\n  post::{Post, PostInsertForm, PostUpdateForm},\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult},\n  spawn_try_task,\n  utils::{\n    markdown::markdown_to_html,\n    slurs::remove_slurs,\n    validation::{is_url_blocked, is_valid_url},\n  },\n};\nuse std::{collections::HashSet, ops::Deref};\nuse stringreader::StringReader;\nuse url::Url;\n\nconst MAX_TITLE_LENGTH: usize = 200;\n\n#[derive(Clone, Debug, PartialEq)]\npub struct ApubPost(pub Post);\n\nimpl Deref for ApubPost {\n  type Target = Post;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<Post> for ApubPost {\n  fn from(p: Post) -> Self {\n    ApubPost(p)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubPost {\n  type DataType = LemmyContext;\n  type Kind = Page;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      Post::read_from_apub_id(&mut context.pool(), object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, context: &Data<Self::DataType>) -> LemmyResult<()> {\n    if !self.deleted {\n      let form = PostUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      };\n      Post::update(&mut context.pool(), self.id, &form).await?;\n    }\n    Ok(())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.removed || self.deleted\n  }\n\n  // Turn a Lemmy post into an ActivityPub page that can be sent out over the network.\n\n  async fn into_json(self, context: &Data<Self::DataType>) -> LemmyResult<Page> {\n    let creator_id = self.creator_id;\n    let creator = Person::read(&mut context.pool(), creator_id).await?;\n    let community_id = self.community_id;\n    let community = Community::read(&mut context.pool(), community_id).await?;\n    let language = Some(LanguageTag::new_single(self.language_id, &mut context.pool()).await?);\n\n    let attachment = self\n      .url\n      .clone()\n      .map(|url| {\n        Attachment::new(\n          url.into(),\n          self.url_content_type.clone(),\n          self.alt_text.clone(),\n        )\n      })\n      .into_iter()\n      .collect();\n\n    // Add tags defined by community and applied to this post\n    let mut tags: Vec<ApubTag> = CommunityTag::read_for_post(&mut context.pool(), self.id)\n      .await?\n      .into_iter()\n      .map(|tag| ApubTag::CommunityTag(ApubCommunityTag::to_json(tag)))\n      .collect();\n\n    // Add automatic hashtag based on community name\n    let hashtag = Hashtag {\n      href: self.ap_id.clone().into(),\n      name: format!(\"#{}\", &community.name),\n      kind: HashtagType::Hashtag,\n    };\n    tags.push(ApubTag::Hashtag(hashtag));\n\n    let maa = collect_non_local_mentions(self.body.as_deref(), None, context).await?;\n    tags.extend(maa.mentions);\n\n    let page = Page {\n      kind: PageType::Page,\n      id: self.ap_id.clone().into(),\n      attributed_to: AttributedTo::Lemmy(creator.ap_id.into()),\n      to: generate_to(&community)?,\n      cc: maa.ccs,\n      name: Some(self.name.clone()),\n      content: self.body.as_ref().map(|b| markdown_to_html(b)),\n      media_type: Some(MediaTypeMarkdownOrHtml::Html),\n      source: self.body.clone().map(Source::new),\n      attachment,\n      image: self.thumbnail_url.clone().map(ImageObject::new),\n      sensitive: Some(self.nsfw),\n      language,\n      published: Some(self.published_at),\n      updated: self.updated_at,\n      audience: Some(community.ap_id.into()),\n      in_reply_to: None,\n      tag: tags,\n      context: Some(context_url(&self.ap_id)),\n    };\n    Ok(page)\n  }\n\n  async fn verify(\n    page: &Page,\n    expected_domain: &Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(page.id.inner(), expected_domain)?;\n    let community = page.community(context).await?;\n\n    // Doesnt call verify_is_remote_object() because the community might be edited by a\n    // remote mod. This is safe as we validate `expected_domain`.\n\n    check_apub_id_valid_with_strictness(page.id.inner(), community.local, context).await?;\n    verify_person_in_community(&page.creator()?, &community, context).await?;\n\n    verify_domains_match(page.creator()?.inner(), page.id.inner())?;\n    verify_visibility(&page.to, &page.cc, &community)?;\n\n    if let Err(e) = verify_is_remote_object(&page.id, context) {\n      if let Ok(post) = page.id.dereference_local(context).await {\n        post.set_not_pending(&mut context.pool()).await?;\n      }\n      return Err(e.into());\n    }\n    Ok(())\n  }\n\n  async fn from_json(page: Page, context: &Data<Self::DataType>) -> LemmyResult<ApubPost> {\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n    let creator = page.creator()?.dereference(context).await?;\n    let community = page.community(context).await?;\n\n    let slur_regex = slur_regex(context).await?;\n\n    // Prevent posts from non-mod users in local, restricted community. If its a remote community\n    // then its possible that the restricted setting was enabled recently, so existing user posts\n    // should still be fetched.\n    if community.local && community.posting_restricted_to_mods {\n      CommunityModeratorView::check_is_community_moderator(\n        &mut context.pool(),\n        community.id,\n        creator.id,\n      )\n      .await?;\n    }\n    let mut name = page\n      .name\n      .clone()\n      .or_else(|| {\n        // Posts coming from Mastodon or similar platforms don't have a title. Instead we take the\n        // first line of the content and convert it from HTML to plaintext. We also remove mentions\n        // of the community name.\n        let c = page\n          .content\n          .as_deref()\n          .map(StringReader::new)\n          .map(|c| from_read_with_decorator(c, MAX_TITLE_LENGTH, TrivialDecorator::new()))?;\n        c.unwrap_or_default().lines().next().map(|s| {\n          s.replace(&format!(\"@{}\", community.name), \"\")\n            .trim()\n            .to_string()\n        })\n      })\n      .map(|s| remove_slurs(&s, &slur_regex))\n      .ok_or_else(|| anyhow!(\"Object must have name or content\"))?;\n\n    if name.chars().count() > MAX_TITLE_LENGTH {\n      name = name.chars().take(MAX_TITLE_LENGTH).collect();\n    }\n\n    let first_attachment = page.attachment.first();\n    let url = if let Some(attachment) = first_attachment.cloned() {\n      Some(attachment.url())\n    } else if page.kind == PageType::Video {\n      // we cant display videos directly, so insert a link to external video page\n      Some(page.id.inner().clone())\n    } else {\n      None\n    };\n\n    let url_blocklist = get_url_blocklist(context).await?;\n\n    let url = if let Some(url) = url {\n      is_url_blocked(&url, &url_blocklist)?;\n      is_valid_url(&url)?;\n      if page.kind != PageType::Video {\n        to_local_url(url.as_str(), context).await.or(Some(url))\n      } else {\n        Some(url)\n      }\n    } else {\n      None\n    };\n\n    let alt_text = first_attachment.cloned().and_then(Attachment::alt_text);\n\n    let body = read_from_string_or_source_opt(&page.content, &page.media_type, &page.source);\n    let body =\n      process_markdown_opt(&body, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let body = markdown_rewrite_remote_links_opt(body, context).await;\n    let language_id = Some(\n      LanguageTag::to_language_id_single(\n        page.language.clone().unwrap_or_default(),\n        &mut context.pool(),\n      )\n      .await?,\n    );\n\n    let orig_post = Post::read_from_apub_id(&mut context.pool(), page.id.clone().into()).await;\n    let mut form = PostInsertForm {\n      url: url.map(Into::into),\n      body,\n      alt_text,\n      published_at: page.published,\n      updated_at: page.updated,\n      deleted: Some(false),\n      nsfw: post_nsfw(&page, &community, Some(&local_site), context).await?,\n      ap_id: Some(page.id.clone().into()),\n      // May be a local post which is updated by remote mod.\n      local: Some(page.id.is_local(context)),\n      language_id,\n      ..PostInsertForm::new(name, creator.id, community.id)\n    };\n    form = plugin_hook_before(\"federated_post_before_receive\", form).await?;\n\n    let timestamp = page.updated.or(page.published).unwrap_or_else(Utc::now);\n    let post = Post::insert_apub(&mut context.pool(), timestamp, &form).await?;\n    plugin_hook_after(\"federated_post_after_receive\", &post);\n\n    update_apub_post_tags(&page, &post, context).await?;\n\n    let post_ = post.clone();\n    let context_ = context.clone();\n\n    // Avoid regenerating metadata if the post already existed with the same url\n    let no_generate_metadata = orig_post.ok().flatten().is_some_and(|p| p.url == post.url);\n    if !no_generate_metadata {\n      // Generates a post thumbnail in background task, because some sites can be very slow to\n      // respond.\n      spawn_try_task(\n        async move { generate_post_link_metadata(post_, None, |_| None, context_).await },\n      );\n    }\n\n    Ok(post.into())\n  }\n}\n\npub async fn update_apub_post_tags(\n  page: &Page,\n  post: &Post,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let post_tag_ap_ids = page\n    .tag\n    .iter()\n    .filter_map(ApubTag::community_tag_id)\n    .collect::<HashSet<_>>();\n  let community_tags =\n    CommunityTag::read_for_community(&mut context.pool(), post.community_id).await?;\n  let post_tags = community_tags\n    .into_iter()\n    .filter(|t| post_tag_ap_ids.contains(&*t.ap_id.0))\n    .map(|t| t.id)\n    .collect::<Vec<_>>();\n  update_post_tags(post, &post_tags, context).await?;\n  Ok(())\n}\n\npub async fn post_nsfw(\n  page: &Page,\n  community: &Community,\n  local_site: Option<&LocalSite>,\n  context: &LemmyContext,\n) -> LemmyResult<Option<bool>> {\n  // Ensure that all posts in NSFW communities are marked as NSFW\n  let nsfw = if community.nsfw {\n    Some(true)\n  } else {\n    page.sensitive\n  };\n\n  // If NSFW is not allowed, reject NSFW posts and delete existing\n  // posts that get updated to be NSFW\n  let block_for_nsfw = check_nsfw_allowed(nsfw, local_site);\n  if let Err(e) = block_for_nsfw {\n    // TODO: Remove locally generated thumbnail if one exists, depends on\n    //       https://github.com/LemmyNet/lemmy/issues/5564 to be implemented to be able to\n    //       safely do this.\n    Post::delete_from_apub_id(&mut context.pool(), page.id.inner().clone()).await?;\n    return Err(e);\n  }\n  Ok(nsfw)\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::{\n    objects::ApubPerson,\n    utils::test::{file_to_json_object, parse_lemmy_community, parse_lemmy_person},\n  };\n  use lemmy_db_schema::{source::instance::Instance, test_data::TestData};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_post() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    parse_lemmy_person(&context).await?;\n    parse_lemmy_community(&context).await?;\n\n    let json = file_to_json_object(\"../apub/assets/lemmy/objects/page.json\")?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/post/55143\")?;\n    ApubPost::verify(&json, &url, &context).await?;\n    let post = ApubPost::from_json(json, &context).await?;\n\n    assert_eq!(post.ap_id, url.into());\n    assert_eq!(post.name, \"Post title\");\n    assert!(post.body.is_some());\n    assert_eq!(post.body.as_ref().map(std::string::String::len), Some(45));\n    assert!(!post.locked);\n    assert!(!post.featured_community);\n    assert_eq!(context.request_count(), 0);\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_convert_mastodon_post_title() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    parse_lemmy_community(&context).await?;\n\n    let json = file_to_json_object(\"../apub/assets/mastodon/objects/person.json\")?;\n    ApubPerson::from_json(json, &context).await?;\n\n    let json = file_to_json_object(\"../apub/assets/mastodon/objects/page.json\")?;\n    let post = ApubPost::from_json(json, &context).await?;\n\n    assert_eq!(post.name, \"Variable never resetting at refresh\");\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/objects/private_message.rs",
    "content": "use crate::{\n  protocol::private_message::{PrivateMessage, PrivateMessageType},\n  utils::{\n    functions::{check_apub_id_valid_with_strictness, read_from_string_or_source},\n    markdown_links::markdown_rewrite_remote_links,\n    protocol::Source,\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  protocol::{\n    values::MediaTypeHtml,\n    verification::{verify_domains_match, verify_is_remote_object},\n  },\n  traits::Object,\n};\nuse chrono::Utc;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  notify::notify_private_message,\n  plugins::{plugin_hook_after, plugin_hook_before},\n  utils::{check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex},\n};\nuse lemmy_db_schema::{\n  source::{\n    instance::{Instance, InstanceActions},\n    person::{Person, PersonActions},\n    private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageInsertForm},\n  },\n  traits::Blockable,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  error::{LemmyError, LemmyErrorType, LemmyResult},\n  utils::markdown::markdown_to_html,\n};\nuse semver::{Version, VersionReq};\nuse std::ops::Deref;\nuse url::Url;\n\n#[derive(Clone, Debug)]\npub struct ApubPrivateMessage(pub DbPrivateMessage);\n\nimpl Deref for ApubPrivateMessage {\n  type Target = DbPrivateMessage;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<DbPrivateMessage> for ApubPrivateMessage {\n  fn from(pm: DbPrivateMessage) -> Self {\n    ApubPrivateMessage(pm)\n  }\n}\n\n#[async_trait::async_trait]\nimpl Object for ApubPrivateMessage {\n  type DataType = LemmyContext;\n  type Kind = PrivateMessage;\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    self.ap_id.inner()\n  }\n\n  async fn read_from_id(\n    object_id: Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<Option<Self>> {\n    Ok(\n      DbPrivateMessage::read_from_apub_id(&mut context.pool(), object_id.into())\n        .await?\n        .map(Into::into),\n    )\n  }\n\n  async fn delete(&self, _context: &Data<Self::DataType>) -> LemmyResult<()> {\n    // do nothing, because pm can't be fetched over http\n    Err(LemmyErrorType::NotFound.into())\n  }\n\n  fn is_deleted(&self) -> bool {\n    self.removed || self.deleted\n  }\n\n  async fn into_json(self, context: &Data<Self::DataType>) -> LemmyResult<PrivateMessage> {\n    let creator_id = self.creator_id;\n    let creator = Person::read(&mut context.pool(), creator_id).await?;\n\n    let recipient_id = self.recipient_id;\n    let recipient = Person::read(&mut context.pool(), recipient_id).await?;\n\n    let instance = Instance::read(&mut context.pool(), recipient.instance_id).await?;\n    let mut kind = PrivateMessageType::Note;\n\n    // Deprecated: For Lemmy versions before 0.20, send private messages with old type\n    if let (Some(software), Some(version)) = (instance.software, &instance.version) {\n      let req = VersionReq::parse(\"<0.20\")?;\n      if software == \"lemmy\" && req.matches(&Version::parse(version)?) {\n        kind = PrivateMessageType::ChatMessage\n      }\n    }\n\n    let note = PrivateMessage {\n      kind,\n      id: self.ap_id.clone().into(),\n      attributed_to: creator.ap_id.into(),\n      to: [recipient.ap_id.into()],\n      content: markdown_to_html(&self.content),\n      media_type: Some(MediaTypeHtml::Html),\n      source: Some(Source::new(self.content.clone())),\n      published: Some(self.published_at),\n      updated: self.updated_at,\n    };\n    Ok(note)\n  }\n\n  async fn verify(\n    note: &PrivateMessage,\n    expected_domain: &Url,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<()> {\n    verify_domains_match(note.id.inner(), expected_domain)?;\n    verify_domains_match(note.attributed_to.inner(), note.id.inner())?;\n    verify_is_remote_object(&note.id, context)?;\n\n    check_apub_id_valid_with_strictness(note.id.inner(), false, context).await?;\n    let person = note.attributed_to.dereference(context).await?;\n    InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n    Ok(())\n  }\n\n  async fn from_json(\n    note: PrivateMessage,\n    context: &Data<Self::DataType>,\n  ) -> LemmyResult<ApubPrivateMessage> {\n    let creator = note.attributed_to.dereference(context).await?;\n    let recipient = note.to[0].dereference(context).await?;\n    PersonActions::read_block(&mut context.pool(), recipient.id, creator.id).await?;\n\n    // Check that they can receive private messages\n    if let Ok(recipient_local_user) =\n      LocalUserView::read_person(&mut context.pool(), recipient.id).await\n    {\n      check_private_messages_enabled(&recipient_local_user)?;\n    }\n    let slur_regex = slur_regex(context).await?;\n    let url_blocklist = get_url_blocklist(context).await?;\n    let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n    let content = read_from_string_or_source(&note.content, &None, &note.source);\n    let content =\n      process_markdown(&content, &slur_regex, &url_blocklist, &local_site, context).await?;\n    let content = markdown_rewrite_remote_links(content, context).await;\n\n    let mut form = PrivateMessageInsertForm {\n      creator_id: creator.id,\n      recipient_id: recipient.id,\n      content,\n      published_at: note.published,\n      updated_at: note.updated,\n      deleted: Some(false),\n      ap_id: Some(note.id.into()),\n      local: Some(false),\n    };\n    form = plugin_hook_before(\"federated_private_message_before_receive\", form).await?;\n    let timestamp = note.updated.or(note.published).unwrap_or_else(Utc::now);\n    let pm = DbPrivateMessage::insert_apub(&mut context.pool(), timestamp, &form).await?;\n    plugin_hook_after(\"federated_private_message_after_receive\", &pm);\n    let view = PrivateMessageView::read(&mut context.pool(), pm.id, None).await?;\n    notify_private_message(&view, pm.updated_at.is_none(), context);\n    Ok(pm.into())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::{\n    objects::{instance::ApubSite, person::ApubPerson},\n    utils::test::{file_to_json_object, parse_lemmy_instance},\n  };\n  use assert_json_diff::assert_json_include;\n  use lemmy_db_schema::test_data::TestData;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  async fn prepare_comment_test(\n    url: &Url,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<(ApubPerson, ApubPerson, ApubSite)> {\n    let context2 = context.clone();\n    let lemmy_person = file_to_json_object(\"../apub/assets/lemmy/objects/person.json\")?;\n    let site = parse_lemmy_instance(&context2).await?;\n    ApubPerson::verify(&lemmy_person, url, &context2).await?;\n    let person1 = ApubPerson::from_json(lemmy_person, &context2).await?;\n    let pleroma_person = file_to_json_object(\"../apub/assets/pleroma/objects/person.json\")?;\n    let pleroma_url = Url::parse(\"https://queer.hacktivis.me/users/lanodan\")?;\n    ApubPerson::verify(&pleroma_person, &pleroma_url, &context2).await?;\n    let person2 = ApubPerson::from_json(pleroma_person, &context2).await?;\n    Ok((person1, person2, site))\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_lemmy_pm() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/private_message/1621\")?;\n    prepare_comment_test(&url, &context).await?;\n    let json: PrivateMessage =\n      file_to_json_object(\"../apub/assets/lemmy/objects/private_message.json\")?;\n    ApubPrivateMessage::verify(&json, &url, &context).await?;\n    let pm = ApubPrivateMessage::from_json(json.clone(), &context).await?;\n\n    assert_eq!(pm.ap_id.clone(), url.into());\n    assert_eq!(pm.content.len(), 20);\n    assert_eq!(context.request_count(), 0);\n\n    let to_apub = pm.into_json(&context).await?;\n    assert_json_include!(actual: json, expected: to_apub);\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_parse_pleroma_pm() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let test_data = TestData::create(&mut context.pool()).await?;\n    let url = Url::parse(\"https://enterprise.lemmy.ml/private_message/1621\")?;\n    prepare_comment_test(&url, &context).await?;\n    let pleroma_url = Url::parse(\"https://queer.hacktivis.me/objects/2\")?;\n    let json = file_to_json_object(\"../apub/assets/pleroma/objects/chat_message.json\")?;\n    ApubPrivateMessage::verify(&json, &pleroma_url, &context).await?;\n    let pm = ApubPrivateMessage::from_json(json, &context).await?;\n\n    assert_eq!(pm.ap_id, pleroma_url.into());\n    assert_eq!(pm.content.len(), 3);\n    assert_eq!(context.request_count(), 0);\n\n    test_data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/group.rs",
    "content": "use crate::{\n  objects::community::ApubCommunity,\n  protocol::tags::ApubCommunityTag,\n  utils::protocol::{AttributedTo, Endpoints, ImageObject, LanguageTag, Source},\n};\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::actor::GroupType,\n  protocol::{\n    helpers::{deserialize_last, deserialize_skip_error},\n    public_key::PublicKey,\n    values::MediaTypeHtml,\n  },\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse std::fmt::Debug;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct Group {\n  #[serde(rename = \"type\")]\n  pub(crate) kind: GroupType,\n  pub id: ObjectId<ApubCommunity>,\n  /// username, set at account creation and usually fixed after that\n  pub preferred_username: String,\n  pub inbox: Url,\n  pub followers: Option<Url>,\n  pub public_key: PublicKey,\n  /// title / display name\n  pub name: Option<String>,\n  // short description\n  pub(crate) description: Option<String>,\n  /// sidebar\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub source: Option<Source>,\n  pub(crate) media_type: Option<MediaTypeHtml>,\n  // sidebar\n  pub summary: Option<String>,\n  #[serde(deserialize_with = \"deserialize_last\", default)]\n  pub icon: Option<ImageObject>,\n  /// banner\n  #[serde(deserialize_with = \"deserialize_last\", default)]\n  pub image: Option<ImageObject>,\n  // lemmy extension\n  pub sensitive: Option<bool>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub attributed_to: Option<AttributedTo>,\n  // lemmy extension\n  pub posting_restricted_to_mods: Option<bool>,\n  pub outbox: Url,\n  pub endpoints: Option<Endpoints>,\n  pub featured: Option<Url>,\n  #[serde(default)]\n  pub(crate) language: Vec<LanguageTag>,\n  /// True if this is a private community\n  pub(crate) manually_approves_followers: Option<bool>,\n  pub published: Option<DateTime<Utc>>,\n  pub updated: Option<DateTime<Utc>>,\n  /// https://docs.joinmastodon.org/spec/activitypub/#discoverable\n  pub(crate) discoverable: Option<bool>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) tag: Vec<ApubCommunityTag>,\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/instance.rs",
    "content": "use crate::{\n  objects::instance::ApubSite,\n  utils::protocol::{ImageObject, LanguageTag, Source},\n};\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  kinds::actor::ApplicationType,\n  protocol::{helpers::deserialize_skip_error, public_key::PublicKey, values::MediaTypeHtml},\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Instance {\n  #[serde(rename = \"type\")]\n  pub(crate) kind: ApplicationType,\n  pub(crate) id: ObjectId<ApubSite>,\n  /// site name\n  pub(crate) name: String,\n  /// instance domain, necessary for mastodon authorized fetch\n  pub(crate) preferred_username: Option<String>,\n  pub(crate) inbox: Url,\n  /// mandatory field in activitypub, lemmy currently serves an empty outbox\n  pub(crate) outbox: Url,\n  pub(crate) public_key: PublicKey,\n\n  // sidebar\n  pub(crate) content: Option<String>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) source: Option<Source>,\n  pub(crate) media_type: Option<MediaTypeHtml>,\n  // short description\n  pub(crate) description: Option<String>,\n  /// instance icon\n  pub(crate) icon: Option<ImageObject>,\n  /// instance banner\n  pub(crate) image: Option<ImageObject>,\n  #[serde(default)]\n  pub(crate) language: Vec<LanguageTag>,\n  /// nonstandard field\n  pub(crate) content_warning: Option<String>,\n  pub(crate) published: Option<DateTime<Utc>>,\n  pub(crate) updated: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/mod.rs",
    "content": "pub mod group;\npub mod instance;\npub mod multi_community;\npub mod note;\npub mod page;\npub mod person;\npub mod private_message;\npub mod tags;\n\n#[cfg(test)]\nmod tests {\n  use super::{\n    group::Group,\n    instance::Instance,\n    note::Note,\n    page::Page,\n    person::Person,\n    private_message::PrivateMessage,\n  };\n  use crate::utils::test::{test_json, test_parse_lemmy_item};\n  use activitypub_federation::protocol::tombstone::Tombstone;\n  use lemmy_utils::error::LemmyResult;\n\n  #[test]\n  fn test_parse_objects_lemmy() -> LemmyResult<()> {\n    test_parse_lemmy_item::<Instance>(\"../apub/assets/lemmy/objects/instance.json\")?;\n    test_parse_lemmy_item::<Group>(\"../apub/assets/lemmy/objects/group.json\")?;\n    test_parse_lemmy_item::<Person>(\"../apub/assets/lemmy/objects/person.json\")?;\n    test_parse_lemmy_item::<Page>(\"../apub/assets/lemmy/objects/page.json\")?;\n    test_parse_lemmy_item::<Note>(\"../apub/assets/lemmy/objects/comment.json\")?;\n    test_parse_lemmy_item::<PrivateMessage>(\"../apub/assets/lemmy/objects/private_message.json\")?;\n    test_parse_lemmy_item::<Tombstone>(\"../apub/assets/lemmy/objects/tombstone.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_objects_pleroma() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/pleroma/objects/person.json\")?;\n    test_json::<Note>(\"../apub/assets/pleroma/objects/note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_objects_smithereen() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/smithereen/objects/person.json\")?;\n    test_json::<Note>(\"../apub/assets/smithereen/objects/note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_objects_mastodon() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/mastodon/objects/person.json\")?;\n    test_json::<Note>(\"../apub/assets/mastodon/objects/note_1.json\")?;\n    test_json::<Note>(\"../apub/assets/mastodon/objects/note_2.json\")?;\n    test_json::<Page>(\"../apub/assets/mastodon/objects/page.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_objects_lotide() -> LemmyResult<()> {\n    test_json::<Group>(\"../apub/assets/lotide/objects/group.json\")?;\n    test_json::<Person>(\"../apub/assets/lotide/objects/person.json\")?;\n    test_json::<Note>(\"../apub/assets/lotide/objects/note.json\")?;\n    test_json::<Page>(\"../apub/assets/lotide/objects/page.json\")?;\n    test_json::<Tombstone>(\"../apub/assets/lotide/objects/tombstone.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_friendica() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/friendica/objects/person_1.json\")?;\n    test_json::<Person>(\"../apub/assets/friendica/objects/person_2.json\")?;\n    test_json::<Page>(\"../apub/assets/friendica/objects/page_1.json\")?;\n    test_json::<Page>(\"../apub/assets/friendica/objects/page_2.json\")?;\n    test_json::<Note>(\"../apub/assets/friendica/objects/note_1.json\")?;\n    test_json::<Note>(\"../apub/assets/friendica/objects/note_2.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_gnusocial() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/gnusocial/objects/person.json\")?;\n    test_json::<Group>(\"../apub/assets/gnusocial/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/gnusocial/objects/page.json\")?;\n    test_json::<Note>(\"../apub/assets/gnusocial/objects/note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_peertube() -> LemmyResult<()> {\n    test_json::<Person>(\"../apub/assets/peertube/objects/person.json\")?;\n    test_json::<Group>(\"../apub/assets/peertube/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/peertube/objects/video.json\")?;\n    test_json::<Note>(\"../apub/assets/peertube/objects/note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_mobilizon() -> LemmyResult<()> {\n    test_json::<Group>(\"../apub/assets/mobilizon/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/mobilizon/objects/event.json\")?;\n    test_json::<Person>(\"../apub/assets/mobilizon/objects/person.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_discourse() -> LemmyResult<()> {\n    test_json::<Group>(\"../apub/assets/discourse/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/discourse/objects/page.json\")?;\n    test_json::<Person>(\"../apub/assets/discourse/objects/person.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_nodebb() -> LemmyResult<()> {\n    test_json::<Group>(\"../apub/assets/nodebb/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/nodebb/objects/page.json\")?;\n    test_json::<Person>(\"../apub/assets/nodebb/objects/person.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_wordpress() -> LemmyResult<()> {\n    test_json::<Group>(\"../apub/assets/wordpress/objects/group.json\")?;\n    test_json::<Page>(\"../apub/assets/wordpress/objects/page.json\")?;\n    test_json::<Person>(\"../apub/assets/wordpress/objects/person.json\")?;\n    test_json::<Note>(\"../apub/assets/wordpress/objects/note.json\")?;\n    Ok(())\n  }\n\n  #[test]\n  fn test_parse_object_mbin() -> LemmyResult<()> {\n    test_json::<Instance>(\"../apub/assets/mbin/objects/instance.json\")?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/multi_community.rs",
    "content": "use crate::{\n  objects::{\n    community::ApubCommunity,\n    multi_community::ApubMultiCommunity,\n    multi_community_collection::ApubFeedCollection,\n    person::ApubPerson,\n  },\n  utils::protocol::Source,\n};\nuse activitypub_federation::{\n  fetch::{collection_id::CollectionId, object_id::ObjectId},\n  kinds::collection::CollectionType,\n  protocol::{helpers::deserialize_skip_error, public_key::PublicKey, values::MediaTypeHtml},\n};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Feed {\n  pub r#type: FeedType,\n  pub id: ObjectId<ApubMultiCommunity>,\n  pub inbox: Url,\n  pub public_key: PublicKey,\n  pub following: CollectionId<ApubFeedCollection>,\n  /// username, set at account creation and usually fixed after that\n  pub preferred_username: String,\n  /// title\n  pub name: Option<String>,\n  /// short description\n  pub(crate) description: Option<String>,\n  /// sidebar\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub source: Option<Source>,\n  pub(crate) media_type: Option<MediaTypeHtml>,\n  // sidebar\n  pub summary: Option<String>,\n  pub attributed_to: ObjectId<ApubPerson>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]\npub enum FeedType {\n  #[default]\n  Feed,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct FeedCollection {\n  pub r#type: CollectionType,\n  pub id: CollectionId<ApubFeedCollection>,\n  pub total_items: i32,\n  pub items: Vec<ObjectId<ApubCommunity>>,\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/note.rs",
    "content": "use crate::{\n  objects::{\n    PostOrComment,\n    comment::ApubComment,\n    community::ApubCommunity,\n    person::ApubPerson,\n    post::ApubPost,\n  },\n  protocol::{page::Attachment, tags::ApubTag},\n  utils::protocol::{InCommunity, LanguageTag, Source},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::object::NoteType,\n  protocol::{\n    helpers::{deserialize_one_or_many, deserialize_skip_error},\n    values::MediaTypeMarkdownOrHtml,\n  },\n};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::{community::Community, post::Post};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::{\n  MAX_COMMENT_DEPTH_LIMIT,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Note {\n  pub(crate) r#type: NoteType,\n  pub id: ObjectId<ApubComment>,\n  pub attributed_to: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\")]\n  pub(crate) to: Vec<Url>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\", default)]\n  pub cc: Vec<Url>,\n  pub(crate) content: String,\n  pub(crate) in_reply_to: ObjectId<PostOrComment>,\n\n  pub(crate) media_type: Option<MediaTypeMarkdownOrHtml>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) source: Option<Source>,\n  pub(crate) published: Option<DateTime<Utc>>,\n  pub(crate) updated: Option<DateTime<Utc>>,\n  #[serde(default)]\n  pub tag: Vec<ApubTag>,\n  // lemmy extension\n  pub distinguished: Option<bool>,\n  pub(crate) language: Option<LanguageTag>,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n  #[serde(default)]\n  pub(crate) attachment: Vec<Attachment>,\n  pub(crate) context: Option<String>,\n}\n\nimpl Note {\n  pub async fn get_parents(\n    &self,\n    context: &Data<LemmyContext>,\n  ) -> LemmyResult<(ApubPost, Option<ApubComment>)> {\n    // We use recursion here to fetch the entire comment chain up to the top-level parent. This is\n    // necessary because we need to know the post and parent comment in order to insert a new\n    // comment. However it can also lead to too much resource consumption when fetching many\n    // comments recursively. To avoid this we check the request count against max comment depth.\n    //\n    // A separate task is spawned for the recursive call. Otherwise, when the async executor polls\n    // the task this is in, the poll function's call stack would grow with the level of recursion,\n    // so a stack overflow would be possible.\n    //\n    // The stack overflow prevention relies on the total laziness that the async keyword provides\n    // (https://rust-lang.github.io/rfcs/2394-async_await.html#async-functions). This means you need\n    // to be careful if you want to change `Note::get_parents` and `CreateOrUpdateNote::verify` from\n    // `async fn foo(...) -> T` to `fn foo(...) -> impl Future<Output = T>`. Between each level of\n    // recursion, there must be the beginning of at least one `async` block or `async fn`,\n    // otherwise there might be multiple levels of recursion before the first poll.\n    if context.request_count() > MAX_COMMENT_DEPTH_LIMIT.try_into()? {\n      return Err(LemmyErrorType::MaxCommentDepthReached.into());\n    }\n    let parent = tokio::spawn({\n      let in_reply_to = self.in_reply_to.clone();\n      let context = context.clone();\n      // This is wrapped in an async block only to satisfy the borrow checker. This wrapping is not\n      // needed for the stack overflow prevention.\n      async move { in_reply_to.dereference(&context).await }\n    })\n    .await??;\n    match parent {\n      PostOrComment::Left(p) => Ok((p.clone(), None)),\n      PostOrComment::Right(c) => {\n        let post_id = c.post_id;\n        let post = Post::read(&mut context.pool(), post_id).await?;\n        Ok((post.into(), Some(c.clone())))\n      }\n    }\n  }\n}\n\nimpl InCommunity for Note {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let (post, _) = self.get_parents(context).await?;\n    let community = Community::read(&mut context.pool(), post.community_id).await?;\n    Ok(community.into())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/page.rs",
    "content": "use crate::{\n  objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},\n  protocol::tags::ApubTag,\n  utils::protocol::{\n    AttributedTo,\n    ImageObject,\n    InCommunity,\n    LanguageTag,\n    PersonOrGroupType,\n    Source,\n  },\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::{\n    link::LinkType,\n    object::{DocumentType, ImageType},\n  },\n  protocol::{\n    helpers::{deserialize_one_or_many, deserialize_skip_error},\n    values::MediaTypeMarkdownOrHtml,\n  },\n  traits::{Activity, Object},\n};\nuse chrono::{DateTime, Utc};\nuse itertools::Itertools;\nuse lemmy_api_utils::{context::LemmyContext, utils::proxy_image_link};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError};\nuse serde::{Deserialize, Deserializer, Serialize, de::Error};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]\npub enum PageType {\n  Page,\n  Article,\n  Note,\n  Video,\n  Event,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Page {\n  #[serde(rename = \"type\")]\n  pub(crate) kind: PageType,\n  pub id: ObjectId<ApubPost>,\n  pub(crate) attributed_to: AttributedTo,\n  #[serde(deserialize_with = \"deserialize_one_or_many\", default)]\n  pub(crate) to: Vec<Url>,\n  // If there is inReplyTo field this is actually a comment and must not be parsed\n  #[serde(deserialize_with = \"deserialize_not_present\", default)]\n  pub(crate) in_reply_to: Option<String>,\n\n  pub(crate) name: Option<String>,\n  #[serde(deserialize_with = \"deserialize_one_or_many\", default)]\n  pub(crate) cc: Vec<Url>,\n  pub(crate) content: Option<String>,\n  pub(crate) media_type: Option<MediaTypeMarkdownOrHtml>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) source: Option<Source>,\n  /// most software uses array type for attachment field, so we do the same. nevertheless, we only\n  /// use the first item\n  #[serde(default)]\n  pub(crate) attachment: Vec<Attachment>,\n  pub(crate) image: Option<ImageObject>,\n  pub(crate) sensitive: Option<bool>,\n  pub(crate) published: Option<DateTime<Utc>>,\n  pub(crate) updated: Option<DateTime<Utc>>,\n  pub(crate) language: Option<LanguageTag>,\n  pub(crate) audience: Option<ObjectId<ApubCommunity>>,\n  /// Contains hashtags and post tags.\n  /// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub tag: Vec<ApubTag>,\n  pub(crate) context: Option<String>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Link {\n  href: Url,\n  media_type: Option<String>,\n  r#type: LinkType,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Image {\n  #[serde(rename = \"type\")]\n  kind: ImageType,\n  url: Url,\n  /// Used for alt_text\n  name: Option<String>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Document {\n  #[serde(rename = \"type\")]\n  kind: DocumentType,\n  url: Url,\n  media_type: Option<String>,\n  /// Used for alt_text\n  name: Option<String>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\npub enum Attachment {\n  Link(Link),\n  Image(Image),\n  Document(Document),\n}\n\nimpl Attachment {\n  pub(crate) fn url(self) -> Url {\n    match self {\n      // url as sent by Lemmy (new)\n      Attachment::Link(l) => l.href,\n      // image sent by lotide\n      Attachment::Image(i) => i.url,\n      // sent by mobilizon\n      Attachment::Document(d) => d.url,\n    }\n  }\n\n  pub(crate) fn alt_text(self) -> Option<String> {\n    match self {\n      Attachment::Image(i) => i.name,\n      Attachment::Document(d) => d.name,\n      _ => None,\n    }\n  }\n\n  pub(crate) async fn as_markdown(&self, context: &Data<LemmyContext>) -> LemmyResult<String> {\n    let (url, name, media_type) = match self {\n      Attachment::Image(i) => (i.url.clone(), i.name.clone(), Some(String::from(\"image\"))),\n      Attachment::Document(d) => (d.url.clone(), d.name.clone(), d.media_type.clone()),\n      Attachment::Link(l) => (l.href.clone(), None, l.media_type.clone()),\n    };\n\n    let is_image =\n      media_type.is_some_and(|media| media.starts_with(\"video\") || media.starts_with(\"image\"));\n    // Markdown images can't have linebreaks in them, so to prevent creating\n    // broken image embeds, replace them with spaces\n    let name = name.map(|n| n.split_whitespace().collect::<Vec<_>>().join(\" \"));\n\n    if is_image {\n      let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n      let url = proxy_image_link(url, &local_site, false, context).await?;\n      Ok(format!(\"![{}]({url})\", name.unwrap_or_default()))\n    } else {\n      Ok(format!(\"[{url}]({url})\"))\n    }\n  }\n}\n\nimpl Page {\n  pub fn creator(&self) -> LemmyResult<ObjectId<ApubPerson>> {\n    match &self.attributed_to {\n      AttributedTo::Lemmy(l) => Ok(l.creator()),\n      AttributedTo::Peertube(p) => p\n        .iter()\n        .find(|a| a.kind == PersonOrGroupType::Person)\n        .map(|a| ObjectId::<ApubPerson>::from(a.id.clone().into_inner()))\n        .ok_or_else(|| UntranslatedError::PageDoesNotSpecifyCreator.into()),\n    }\n  }\n}\n\nimpl Attachment {\n  /// Creates new attachment for a given link and mime type.\n  pub(crate) fn new(url: Url, media_type: Option<String>, alt_text: Option<String>) -> Attachment {\n    let is_image = media_type.clone().unwrap_or_default().starts_with(\"image\");\n    if is_image {\n      Attachment::Image(Image {\n        kind: Default::default(),\n        url,\n        name: alt_text,\n      })\n    } else {\n      Attachment::Link(Link {\n        href: url,\n        media_type,\n        r#type: Default::default(),\n      })\n    }\n  }\n}\n\n// Used for community outbox, so that it can be compatible with Pleroma/Mastodon.\n#[async_trait::async_trait]\nimpl Activity for Page {\n  type DataType = LemmyContext;\n  type Error = LemmyError;\n  fn id(&self) -> &Url {\n    self.id.inner()\n  }\n\n  fn actor(&self) -> &Url {\n    debug_assert!(false);\n    self.id.inner()\n  }\n  async fn verify(&self, data: &Data<Self::DataType>) -> LemmyResult<()> {\n    ApubPost::verify(self, self.id.inner(), data).await\n  }\n  async fn receive(self, data: &Data<Self::DataType>) -> LemmyResult<()> {\n    ApubPost::from_json(self, data).await?;\n    Ok(())\n  }\n}\n\nimpl InCommunity for Page {\n  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n    if let Some(audience) = &self.audience {\n      return audience.dereference(context).await;\n    }\n    let community = match &self.attributed_to {\n      AttributedTo::Lemmy(_) => {\n        let mut iter = self.to.iter().merge(self.cc.iter());\n        loop {\n          if let Some(cid) = iter.next() {\n            // to and cc fields can also contain this value to indicate a public object.\n            // Skip it to avoid unnecessary http requests.\n            if cid.as_str() == \"https://www.w3.org/ns/activitystreams#Public\" {\n              continue;\n            }\n            let cid = ObjectId::<ApubCommunity>::from(cid.clone());\n            if let Ok(c) = cid.dereference(context).await {\n              break c;\n            }\n          } else {\n            return Err(LemmyErrorType::NotFound.into());\n          }\n        }\n      }\n      AttributedTo::Peertube(p) => {\n        p.iter()\n          .find(|a| a.kind == PersonOrGroupType::Group)\n          .map(|a| ObjectId::<ApubCommunity>::from(a.id.clone().into_inner()))\n          .ok_or(LemmyErrorType::NotFound)?\n          .dereference(context)\n          .await?\n      }\n    };\n\n    Ok(community)\n  }\n}\n\n/// Only allows deserialization if the field is missing or null. If it is present, throws an error.\nfn deserialize_not_present<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>\nwhere\n  D: Deserializer<'de>,\n{\n  let result: Option<String> = Deserialize::deserialize(deserializer)?;\n  match result {\n    None => Ok(None),\n    Some(_) => Err(D::Error::custom(\"Post must not have inReplyTo property\")),\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::{protocol::page::Page, utils::test::test_parse_lemmy_item};\n\n  #[test]\n  fn test_not_parsing_note_as_page() {\n    assert!(test_parse_lemmy_item::<Page>(\"assets/lemmy/objects/note.json\").is_err());\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/person.rs",
    "content": "use crate::{\n  objects::person::ApubPerson,\n  utils::protocol::{Endpoints, ImageObject, Source},\n};\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  protocol::{\n    helpers::{deserialize_last, deserialize_skip_error},\n    public_key::PublicKey,\n  },\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]\npub enum UserTypes {\n  Person,\n  Service,\n  Organization,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Person {\n  #[serde(rename = \"type\")]\n  pub(crate) kind: UserTypes,\n  pub(crate) id: ObjectId<ApubPerson>,\n  /// username, set at account creation and usually fixed after that\n  pub(crate) preferred_username: String,\n  pub(crate) inbox: Url,\n  /// mandatory field in activitypub, lemmy currently serves an empty outbox\n  pub(crate) outbox: Url,\n  pub(crate) public_key: PublicKey,\n  /// displayname\n  pub(crate) name: Option<String>,\n  /// bio\n  pub(crate) summary: Option<String>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) source: Option<Source>,\n  /// user avatar\n  #[serde(deserialize_with = \"deserialize_last\", default)]\n  pub(crate) icon: Option<ImageObject>,\n  /// user banner\n  #[serde(deserialize_with = \"deserialize_last\", default)]\n  pub(crate) image: Option<ImageObject>,\n  pub(crate) matrix_user_id: Option<String>,\n  pub(crate) endpoints: Option<Endpoints>,\n  pub(crate) published: Option<DateTime<Utc>>,\n  pub(crate) updated: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/private_message.rs",
    "content": "use crate::{\n  objects::{person::ApubPerson, private_message::ApubPrivateMessage},\n  utils::protocol::Source,\n};\nuse activitypub_federation::{\n  fetch::object_id::ObjectId,\n  protocol::{\n    helpers::{deserialize_one, deserialize_skip_error},\n    values::MediaTypeHtml,\n  },\n};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PrivateMessage {\n  #[serde(rename = \"type\")]\n  pub(crate) kind: PrivateMessageType,\n  pub id: ObjectId<ApubPrivateMessage>,\n  pub attributed_to: ObjectId<ApubPerson>,\n  #[serde(deserialize_with = \"deserialize_one\")]\n  pub to: [ObjectId<ApubPerson>; 1],\n  pub(crate) content: String,\n\n  pub(crate) media_type: Option<MediaTypeHtml>,\n  #[serde(deserialize_with = \"deserialize_skip_error\", default)]\n  pub(crate) source: Option<Source>,\n  pub(crate) published: Option<DateTime<Utc>>,\n  pub(crate) updated: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub enum PrivateMessageType {\n  /// Deprecated, for compatibility with Lemmy 0.19 and earlier\n  /// https://docs.pleroma.social/backend/development/ap_extensions/#chatmessages\n  ChatMessage,\n  Note,\n}\n"
  },
  {
    "path": "crates/apub/objects/src/protocol/tags.rs",
    "content": "use crate::objects::person::ApubPerson;\nuse activitypub_federation::{fetch::object_id::ObjectId, kinds::link::MentionType};\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::community_tag::{CommunityTag, CommunityTagInsertForm},\n};\nuse lemmy_db_schema_file::enums::TagColor;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse url::Url;\n\n/// Possible values in the `tag` field of a federated post or comment. Note that we don't support\n/// hashtags or community tags in comments, but its easier to use the same struct for both\n/// (anyway unsupported values are ignored).\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\npub enum ApubTag {\n  Hashtag(Hashtag),\n  CommunityTag(ApubCommunityTag),\n  Mention(Mention),\n  Unknown(Value),\n}\n\nimpl ApubTag {\n  pub(crate) fn community_tag_id(&self) -> Option<&Url> {\n    match self {\n      ApubTag::CommunityTag(t) => Some(&t.id),\n      _ => None,\n    }\n  }\n  pub fn mention_id(&self) -> Option<&ObjectId<ApubPerson>> {\n    match self {\n      ApubTag::Mention(m) => Some(&m.href),\n      _ => None,\n    }\n  }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Mention {\n  pub href: ObjectId<ApubPerson>,\n  pub(crate) name: Option<String>,\n  #[serde(rename = \"type\")]\n  pub kind: MentionType,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Hashtag {\n  pub(crate) href: Url,\n  pub(crate) name: String,\n  #[serde(rename = \"type\")]\n  pub(crate) kind: HashtagType,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub enum HashtagType {\n  Hashtag,\n}\n\n/// The [ActivityStreams vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag)\n/// defines that any object can have a list of tags associated with it.\n/// Tags in AS can be of any type, so we define our own types.\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]\nenum CommunityTagType {\n  #[default]\n  CommunityPostTag,\n}\n\n/// A tag that a community owns, that is added to a post.\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct ApubCommunityTag {\n  #[serde(rename = \"type\")]\n  kind: CommunityTagType,\n  pub id: Url,\n  pub name: Option<String>,\n  pub preferred_username: String,\n  pub content: Option<String>,\n  pub color: Option<TagColor>,\n}\n\nimpl ApubCommunityTag {\n  pub fn to_json(tag: CommunityTag) -> Self {\n    ApubCommunityTag {\n      kind: Default::default(),\n      id: tag.ap_id.into(),\n      name: tag.display_name,\n      preferred_username: tag.name,\n      content: tag.summary,\n      color: Some(tag.color),\n    }\n  }\n\n  pub fn to_insert_form(&self, community_id: CommunityId) -> CommunityTagInsertForm {\n    CommunityTagInsertForm {\n      ap_id: self.id.clone().into(),\n      name: self.preferred_username.clone(),\n      display_name: self.name.clone(),\n      summary: self.content.clone(),\n      community_id,\n      deleted: Some(false),\n      color: self.color,\n    }\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/utils/functions.rs",
    "content": "use super::protocol::Source;\nuse crate::{\n  objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson},\n  protocol::{group::Group, page::Attachment},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::public,\n  protocol::values::MediaTypeMarkdownOrHtml,\n};\nuse either::Either;\nuse html2md::parse_html;\nuse lemmy_api_utils::{context::LemmyContext, utils::check_is_mod_or_admin};\nuse lemmy_db_schema::source::{\n  community::Community,\n  instance::{Instance, InstanceActions},\n  local_site::LocalSite,\n};\nuse lemmy_db_schema_file::enums::{ActorType, CommunityVisibility};\nuse lemmy_db_views_community_moderator::CommunityPersonBanView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::connection::DbPool;\nuse lemmy_utils::{\n  CACHE_DURATION_FEDERATION,\n  CacheLock,\n  error::{LemmyError, LemmyResult, UntranslatedError},\n};\nuse moka::future::Cache;\nuse std::sync::{Arc, LazyLock};\nuse url::Url;\n\npub fn read_from_string_or_source(\n  content: &str,\n  media_type: &Option<MediaTypeMarkdownOrHtml>,\n  source: &Option<Source>,\n) -> String {\n  if let Some(s) = source {\n    // markdown sent by lemmy in source field\n    s.content.clone()\n  } else if media_type == &Some(MediaTypeMarkdownOrHtml::Markdown) {\n    // markdown sent by peertube in content field\n    content.to_string()\n  } else {\n    // otherwise, convert content html to markdown\n    parse_html(content)\n  }\n}\n\npub fn read_from_string_or_source_opt(\n  content: &Option<String>,\n  media_type: &Option<MediaTypeMarkdownOrHtml>,\n  source: &Option<Source>,\n) -> Option<String> {\n  content\n    .as_ref()\n    .map(|content| read_from_string_or_source(content, media_type, source))\n}\n\n#[derive(Clone)]\npub struct LocalSiteData {\n  local_site: Option<LocalSite>,\n  allowed_instances: Vec<Instance>,\n  blocked_instances: Vec<Instance>,\n}\n\npub async fn local_site_data_cached(pool: &mut DbPool<'_>) -> LemmyResult<Arc<LocalSiteData>> {\n  // All incoming and outgoing federation actions read the blocklist/allowlist and slur filters\n  // multiple times. This causes a huge number of database reads if we hit the db directly. So we\n  // cache these values for a short time, which will already make a huge difference and ensures that\n  // changes take effect quickly.\n  static CACHE: CacheLock<Arc<LocalSiteData>> = LazyLock::new(|| {\n    Cache::builder()\n      .max_capacity(1)\n      .time_to_live(CACHE_DURATION_FEDERATION)\n      .build()\n  });\n  Ok(\n    Box::pin(CACHE\n      .try_get_with((), async {\n        let (local_site, allowed_instances, blocked_instances) =\n          lemmy_diesel_utils::try_join_with_pool!(pool => (\n            // LocalSite may be missing\n            |pool| async {\n              Ok(SiteView::read_local(pool).await.ok().map(|s| s.local_site))\n            },\n            Instance::allowlist,\n            Instance::blocklist\n          ))?;\n\n        Ok::<_, LemmyError>(Arc::new(LocalSiteData {\n          local_site,\n          allowed_instances,\n          blocked_instances,\n        }))\n      }))\n      .await.map_err(|e| anyhow::anyhow!(\"err getting activity: {e:?}\"))?\n  )\n}\n\npub async fn check_apub_id_valid_with_strictness(\n  apub_id: &Url,\n  is_strict: bool,\n  context: &LemmyContext,\n) -> LemmyResult<()> {\n  let domain = apub_id\n    .domain()\n    .ok_or(UntranslatedError::UrlWithoutDomain)?\n    .to_string();\n  let local_instance = context.settings().get_hostname_without_port()?;\n  if domain == local_instance {\n    return Ok(());\n  }\n\n  let local_site_data = local_site_data_cached(&mut context.pool()).await?;\n  check_apub_id_valid(apub_id, &local_site_data)?;\n\n  // Only check allowlist if this is a community, and there are instances in the allowlist\n  if is_strict && !local_site_data.allowed_instances.is_empty() {\n    // need to allow this explicitly because apub receive might contain objects from our local\n    // instance.\n    let mut allowed_and_local = local_site_data\n      .allowed_instances\n      .iter()\n      .map(|i| i.domain.clone())\n      .collect::<Vec<String>>();\n    let local_instance = context.settings().get_hostname_without_port()?;\n    allowed_and_local.push(local_instance);\n\n    let domain = apub_id\n      .domain()\n      .ok_or(UntranslatedError::UrlWithoutDomain)?\n      .to_string();\n    if !allowed_and_local.contains(&domain) {\n      return Err(UntranslatedError::FederationDisabledByStrictAllowList.into());\n    }\n  }\n  Ok(())\n}\n\n/// Checks if the ID is allowed for sending or receiving.\n///\n/// In particular, it checks for:\n/// - federation being enabled (if its disabled, only local URLs are allowed)\n/// - the correct scheme (either http or https)\n/// - URL being in the allowlist (if it is active)\n/// - URL not being in the blocklist (if it is active)\npub fn check_apub_id_valid(apub_id: &Url, local_site_data: &LocalSiteData) -> LemmyResult<()> {\n  let domain = apub_id\n    .domain()\n    .ok_or(UntranslatedError::UrlWithoutDomain)?\n    .to_string();\n\n  if !local_site_data\n    .local_site\n    .as_ref()\n    .map(|l| l.federation_enabled)\n    .unwrap_or(true)\n  {\n    return Err(UntranslatedError::FederationDisabled.into());\n  }\n\n  if local_site_data\n    .blocked_instances\n    .iter()\n    .any(|i| domain.to_lowercase().eq(&i.domain.to_lowercase()))\n  {\n    return Err(UntranslatedError::DomainBlocked(domain.clone()).into());\n  }\n\n  // Only check this if there are instances in the allowlist\n  if !local_site_data.allowed_instances.is_empty()\n    && !local_site_data\n      .allowed_instances\n      .iter()\n      .any(|i| domain.to_lowercase().eq(&i.domain.to_lowercase()))\n  {\n    return Err(UntranslatedError::DomainNotInAllowList(domain).into());\n  }\n\n  Ok(())\n}\n\npub trait GetActorType {\n  fn actor_type(&self) -> ActorType;\n}\n\nimpl<L: GetActorType, R: GetActorType> GetActorType for either::Either<L, R> {\n  fn actor_type(&self) -> ActorType {\n    match self {\n      Either::Right(r) => r.actor_type(),\n      Either::Left(l) => l.actor_type(),\n    }\n  }\n}\n\n/// Marks object as public only if the community is public\npub fn generate_to(community: &Community) -> LemmyResult<Vec<Url>> {\n  let ap_id = community.ap_id.clone().into();\n  if community.visibility == CommunityVisibility::Public {\n    Ok(vec![ap_id, public()])\n  } else {\n    Ok(vec![\n      ap_id.clone(),\n      Url::parse(&format!(\"{}/followers\", ap_id))?,\n    ])\n  }\n}\n\n/// Fetches the person and community to verify their type, then checks if person is banned from site\n/// or community.\npub async fn verify_person_in_community(\n  person_id: &ObjectId<ApubPerson>,\n  community: &ApubCommunity,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let person = person_id.dereference(context).await?;\n  InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n  CommunityPersonBanView::check(&mut context.pool(), person.id, community.id).await\n}\n\n/// Fetches the person and community or site to verify their type, then checks if person is banned\n/// from local site or community.\npub async fn verify_person_in_site_or_community(\n  person_id: &ObjectId<ApubPerson>,\n  site_or_community: &Either<ApubSite, ApubCommunity>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  let person = person_id.dereference(context).await?;\n  InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?;\n  if let Either::Right(community) = site_or_community {\n    let person_id = person.id;\n    let community_id = community.id;\n    CommunityPersonBanView::check(&mut context.pool(), person_id, community_id).await?;\n  }\n  Ok(())\n}\n\npub fn verify_is_public(to: &[Url], cc: &[Url]) -> LemmyResult<()> {\n  if ![to, cc].iter().any(|set| set.contains(&public())) {\n    Err(UntranslatedError::ObjectIsNotPublic.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Returns an error if object visibility doesnt match community visibility\n/// (ie content in private community must also be private).\npub fn verify_visibility(to: &[Url], cc: &[Url], community: &ApubCommunity) -> LemmyResult<()> {\n  use CommunityVisibility::*;\n  let object_is_public = [to, cc].iter().any(|set| set.contains(&public()));\n  match community.visibility {\n    Public | Unlisted if !object_is_public => Err(UntranslatedError::ObjectIsNotPublic.into()),\n    Private if object_is_public => Err(UntranslatedError::ObjectIsNotPrivate.into()),\n    _ => Ok(()),\n  }\n}\n\npub async fn append_attachments_to_comment(\n  content: String,\n  attachments: &[Attachment],\n  context: &Data<LemmyContext>,\n) -> LemmyResult<String> {\n  let mut content = content;\n  // Don't modify comments with no attachments\n  if !attachments.is_empty() {\n    content += \"\\n\";\n    for attachment in attachments {\n      content = content + \"\\n\" + &attachment.as_markdown(context).await?;\n    }\n  }\n\n  Ok(content)\n}\n\npub fn community_visibility(group: &Group) -> CommunityVisibility {\n  if group.manually_approves_followers.unwrap_or_default() {\n    CommunityVisibility::Private\n  } else if !group.discoverable.unwrap_or(true) {\n    CommunityVisibility::Unlisted\n  } else {\n    CommunityVisibility::Public\n  }\n}\n\n/// Format context url for a post or comment. Returns plain string for simplicity.\npub fn context_url(id: &Url) -> String {\n  format!(\"{id}/context\")\n}\n\n/// Verify that mod action in community was performed by a moderator. Also checks that the moderator\n/// doesn't have a community ban.\n///\n/// Do not call together with `verify_person_in_community()`.\n/// Moderators with site ban are allowed, see https://github.com/LemmyNet/lemmy/issues/4409\n///\n/// * `mod_id` - Activitypub ID of the mod or admin who performed the action\n/// * `object_id` - Activitypub ID of the actor or object that is being moderated\n/// * `community` - The community inside which moderation is happening\npub async fn verify_mod_action(\n  mod_id: &ObjectId<ApubPerson>,\n  community: &Community,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  // mod action comes from the same instance as the community, so it was presumably done\n  // by an instance admin.\n  // TODO: federate instance admin status and check it here\n  if mod_id.inner().domain() == community.ap_id.domain() {\n    return Ok(());\n  }\n\n  let mod_ = mod_id.dereference(context).await?;\n  check_is_mod_or_admin(&mut context.pool(), mod_.id, community.id).await?;\n  CommunityPersonBanView::check(&mut context.pool(), mod_.id, community.id).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/objects/src/utils/markdown_links.rs",
    "content": "use crate::objects::SearchableObjects;\nuse activitypub_federation::{config::Data, fetch::object_id::ObjectId};\nuse either::Either::*;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::traits::ApubActor;\nuse lemmy_utils::utils::markdown::image_links::{markdown_find_links, markdown_handle_title};\nuse url::Url;\n\npub async fn markdown_rewrite_remote_links_opt(\n  src: Option<String>,\n  context: &Data<LemmyContext>,\n) -> Option<String> {\n  match src {\n    Some(t) => Some(markdown_rewrite_remote_links(t, context).await),\n    None => None,\n  }\n}\n\n/// Goes through all remote markdown links and attempts to resolve them as Activitypub objects.\n/// If successful, the link is rewritten to a local link, so it can be viewed without leaving the\n/// local instance.\n///\n/// As it relies on ObjectId::dereference, it can only be used for incoming federated objects, not\n/// for the API.\npub async fn markdown_rewrite_remote_links(\n  mut src: String,\n  context: &Data<LemmyContext>,\n) -> String {\n  let links_offsets = markdown_find_links(&src);\n\n  // Go through the collected links in reverse order\n  for (start, end) in links_offsets.into_iter().rev() {\n    let (url, extra) = markdown_handle_title(&src, start, end);\n\n    if let Some(local_url) = to_local_url(url, context).await {\n      let mut local_url = local_url.to_string();\n      // restore title\n      if let Some(extra) = extra {\n        local_url.push(' ');\n        local_url.push_str(extra);\n      }\n      src.replace_range(start..end, local_url.as_str());\n    }\n  }\n\n  src\n}\n\npub(crate) async fn to_local_url(url: &str, context: &Data<LemmyContext>) -> Option<Url> {\n  let local_domain = &context.settings().get_protocol_and_hostname();\n  let object_id = ObjectId::<SearchableObjects>::parse(url).ok()?;\n  let object_domain = object_id.inner().domain();\n  if object_domain == Some(local_domain) {\n    return None;\n  }\n  let dereferenced = object_id.dereference_local(context).await.ok()?;\n  match dereferenced {\n    Left(Left(Left(post))) => post.local_url(context.settings()),\n    Left(Left(Right(comment))) => comment.local_url(context.settings()),\n    Left(Right(Left(user))) => user.actor_url(context.settings()),\n    Left(Right(Right(community))) => community.actor_url(context.settings()),\n    Right(multi) => multi.actor_url(context.settings()),\n  }\n  .ok()\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use lemmy_db_schema::{\n    source::{\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      post::{Post, PostInsertForm},\n    },\n    test_data::TestData,\n  };\n  use lemmy_db_views_local_user::LocalUserView;\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[serial]\n  #[tokio::test]\n  async fn test_markdown_rewrite_remote_links() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let data = TestData::create(&mut context.pool()).await?;\n    let community = Community::create(\n      &mut context.pool(),\n      &CommunityInsertForm::new(\n        data.instance.id,\n        \"my_community\".to_string(),\n        \"My Community\".to_string(),\n        \"pubkey\".to_string(),\n      ),\n    )\n    .await?;\n    let user =\n      LocalUserView::create_test_user(&mut context.pool(), \"garda\", \"garda bio\", false).await?;\n\n    // insert a remote post which is already fetched\n    let post_form = PostInsertForm {\n      ap_id: Some(Url::parse(\"https://example.com/post/123\")?.into()),\n      ..PostInsertForm::new(\"My post\".to_string(), user.person.id, community.id)\n    };\n    let post = Post::create(&mut context.pool(), &post_form).await?;\n    let markdown_local_post_url = format!(\"[link](https://lemmy-alpha/post/{})\", post.id);\n\n    let tests: Vec<_> = vec![\n      (\n        \"rewrite remote post link\",\n        format!(\"[link]({})\", post.ap_id),\n        markdown_local_post_url.as_ref(),\n      ),\n      (\n        \"rewrite community link\",\n        format!(\"[link]({})\", community.ap_id),\n        \"[link](https://lemmy-alpha/c/my_community@changeme.invalid)\",\n      ),\n      (\n        \"dont rewrite local post link\",\n        \"[link](https://lemmy-alpha/post/2)\".to_string(),\n        \"[link](https://lemmy-alpha/post/2)\",\n      ),\n      (\n        \"dont rewrite local community link\",\n        \"[link](https://lemmy-alpha/c/test)\".to_string(),\n        \"[link](https://lemmy-alpha/c/test)\",\n      ),\n      (\n        \"dont rewrite non-fediverse link\",\n        \"[link](https://example.com/)\".to_string(),\n        \"[link](https://example.com/)\",\n      ),\n      (\n        \"dont rewrite invalid url\",\n        \"[link](example-com)\".to_string(),\n        \"[link](example-com)\",\n      ),\n    ];\n\n    let context = LemmyContext::init_test_context().await;\n    for (msg, input, expected) in &tests {\n      let result = markdown_rewrite_remote_links(input.clone(), &context).await;\n\n      assert_eq!(\n        &result, expected,\n        \"Testing {}, with original input '{}'\",\n        msg, input\n      );\n    }\n\n    data.delete(&mut context.pool()).await?;\n    Instance::delete_all(&mut context.pool()).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/utils/mentions.rs",
    "content": "use crate::{\n  objects::person::ApubPerson,\n  protocol::tags::{ApubTag, Mention},\n};\nuse activitypub_federation::{\n  config::Data,\n  fetch::webfinger::webfinger_resolve_actor,\n  kinds::link::MentionType,\n  traits::Object,\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::{comment::Comment, person::Person, post::Post};\nuse lemmy_diesel_utils::{connection::DbPool, traits::Crud};\nuse lemmy_utils::{\n  error::{LemmyResult, UntranslatedError},\n  utils::mention::scrape_text_for_mentions,\n};\nuse url::Url;\n\npub(crate) struct MentionsAndAddresses {\n  pub ccs: Vec<Url>,\n  pub mentions: Vec<ApubTag>,\n}\n\n/// This takes a markdown text, and builds a list of to_addresses, inboxes,\n/// and mention tags, so they know where to be sent to.\n/// Addresses are the persons / addresses that go in the cc field.\npub(crate) async fn collect_non_local_mentions(\n  content: Option<&str>,\n  parent_creator: Option<ApubPerson>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<MentionsAndAddresses> {\n  let mut addressed_ccs: Vec<Url> = vec![];\n  let mut mentions = vec![];\n  if let Some(parent_creator) = parent_creator {\n    addressed_ccs.push(parent_creator.id().clone());\n    mentions.push(Mention {\n      href: parent_creator.id().clone().into(),\n      name: Some(format!(\n        \"@{}@{}\",\n        &parent_creator.name,\n        &parent_creator\n          .id()\n          .domain()\n          .ok_or(UntranslatedError::UrlWithoutDomain)?\n      )),\n      kind: MentionType::Mention,\n    });\n  }\n\n  // Get the person IDs for any mentions\n  let scraped = content\n    .map(scrape_text_for_mentions)\n    .into_iter()\n    .flatten()\n    // Filter only the non-local ones\n    .filter(|m| !m.is_local(&context.settings().hostname));\n\n  for mention in scraped {\n    let identifier = format!(\"{}@{}\", mention.name, mention.domain);\n    let person = webfinger_resolve_actor::<LemmyContext, ApubPerson>(&identifier, context).await;\n    if let Ok(person) = person {\n      addressed_ccs.push(person.ap_id.to_string().parse()?);\n\n      let mention_tag = Mention {\n        href: person.id().clone().into(),\n        name: Some(mention.full_name()),\n        kind: MentionType::Mention,\n      };\n      mentions.push(mention_tag);\n    }\n  }\n\n  Ok(MentionsAndAddresses {\n    ccs: addressed_ccs,\n    mentions: mentions.into_iter().map(ApubTag::Mention).collect(),\n  })\n}\n\n/// Returns the apub ID of the person this comment is responding to. Meaning, in case this is a\n/// top-level comment, the creator of the post, otherwise the creator of the parent comment.\npub(crate) async fn get_comment_parent_creator(\n  pool: &mut DbPool<'_>,\n  comment: &Comment,\n) -> LemmyResult<ApubPerson> {\n  let parent_creator_id = if let Some(parent_comment_id) = comment.parent_comment_id() {\n    let parent_comment = Comment::read(pool, parent_comment_id).await?;\n    parent_comment.creator_id\n  } else {\n    let parent_post_id = comment.post_id;\n    let parent_post = Post::read(pool, parent_post_id).await?;\n    parent_post.creator_id\n  };\n  Ok(Person::read(pool, parent_creator_id).await?.into())\n}\n"
  },
  {
    "path": "crates/apub/objects/src/utils/mod.rs",
    "content": "pub mod functions;\npub mod markdown_links;\npub mod mentions;\npub mod protocol;\npub mod test;\n"
  },
  {
    "path": "crates/apub/objects/src/utils/protocol.rs",
    "content": "use crate::objects::{UserOrCommunity, community::ApubCommunity, person::ApubPerson};\nuse activitypub_federation::{\n  config::Data,\n  fetch::object_id::ObjectId,\n  kinds::object::ImageType,\n  protocol::{tombstone::Tombstone, values::MediaTypeMarkdown},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  impls::actor_language::UNDETERMINED_ID,\n  newtypes::LanguageId,\n  source::language::Language,\n};\nuse lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl};\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Deserialize, Serialize};\nuse std::{future::Future, ops::Deref};\nuse url::Url;\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct Source {\n  pub(crate) content: String,\n  pub(crate) media_type: MediaTypeMarkdown,\n}\n\nimpl Source {\n  pub(crate) fn new(content: String) -> Self {\n    Source {\n      content,\n      media_type: MediaTypeMarkdown::Markdown,\n    }\n  }\n}\n\npub trait InCommunity {\n  fn community(\n    &self,\n    context: &Data<LemmyContext>,\n  ) -> impl Future<Output = LemmyResult<ApubCommunity>> + Send;\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct ImageObject {\n  #[serde(rename = \"type\")]\n  kind: ImageType,\n  pub url: Url,\n}\n\nimpl ImageObject {\n  pub(crate) fn new(url: DbUrl) -> Self {\n    ImageObject {\n      kind: ImageType::Image,\n      url: url.into(),\n    }\n  }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(untagged)]\npub enum AttributedTo {\n  Lemmy(PersonOrGroupModerators),\n  Peertube(Vec<AttributedToPeertube>),\n}\n\n#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]\npub enum PersonOrGroupType {\n  Person,\n  Group,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct AttributedToPeertube {\n  #[serde(rename = \"type\")]\n  pub kind: PersonOrGroupType,\n  pub id: ObjectId<UserOrCommunity>,\n}\n\nimpl AttributedTo {\n  pub fn url(self) -> Option<DbUrl> {\n    match self {\n      AttributedTo::Lemmy(l) => Some(l.moderators().into()),\n      AttributedTo::Peertube(_) => None,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]\npub struct PersonOrGroupModerators(Url);\n\nimpl Deref for PersonOrGroupModerators {\n  type Target = Url;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<DbUrl> for PersonOrGroupModerators {\n  fn from(value: DbUrl) -> Self {\n    PersonOrGroupModerators(value.into())\n  }\n}\n\nimpl PersonOrGroupModerators {\n  pub(crate) fn creator(&self) -> ObjectId<ApubPerson> {\n    self.deref().clone().into()\n  }\n\n  pub fn moderators(&self) -> Url {\n    self.deref().clone()\n  }\n}\n\n/// As specified in https://schema.org/Language\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct LanguageTag {\n  pub(crate) identifier: String,\n  pub(crate) name: String,\n}\n\nimpl Default for LanguageTag {\n  fn default() -> Self {\n    LanguageTag {\n      identifier: \"und\".to_string(),\n      name: \"Undetermined\".to_string(),\n    }\n  }\n}\n\nimpl LanguageTag {\n  pub(crate) async fn new_single(\n    lang: LanguageId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<LanguageTag> {\n    let lang = Language::read_from_id(pool, lang).await?;\n\n    // undetermined\n    if lang.id == UNDETERMINED_ID {\n      Ok(LanguageTag::default())\n    } else {\n      Ok(LanguageTag {\n        identifier: lang.code,\n        name: lang.name,\n      })\n    }\n  }\n\n  pub(crate) async fn new_multiple(\n    lang_ids: Vec<LanguageId>,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<LanguageTag>> {\n    let mut langs = Vec::<Language>::new();\n\n    for l in lang_ids {\n      langs.push(Language::read_from_id(pool, l).await?);\n    }\n\n    let langs = langs\n      .into_iter()\n      .map(|l| LanguageTag {\n        identifier: l.code,\n        name: l.name,\n      })\n      .collect();\n    Ok(langs)\n  }\n\n  pub(crate) async fn to_language_id_single(\n    lang: Self,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<LanguageId> {\n    Language::read_id_from_code(pool, &lang.identifier).await\n  }\n\n  pub(crate) async fn to_language_id_multiple(\n    langs: Vec<Self>,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<LanguageId>> {\n    let mut language_ids = Vec::new();\n\n    for l in langs {\n      let id = l.identifier;\n      language_ids.push(Language::read_id_from_code(pool, &id).await?);\n    }\n\n    Ok(language_ids.into_iter().collect())\n  }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct Endpoints {\n  pub shared_inbox: Url,\n}\n\npub trait Id {\n  fn id(&self) -> &Url;\n}\n\nimpl Id for Tombstone {\n  fn id(&self) -> &Url {\n    &self.id\n  }\n}\n"
  },
  {
    "path": "crates/apub/objects/src/utils/test.rs",
    "content": "use crate::{\n  objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson},\n  protocol::{group::Group, instance::Instance},\n};\nuse activitypub_federation::{config::Data, protocol::context::WithContext, traits::Object};\nuse assert_json_diff::assert_json_include;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_utils::error::LemmyResult;\nuse serde::{Serialize, de::DeserializeOwned};\nuse std::{collections::HashMap, fs::File, io::BufReader};\nuse url::Url;\n\npub fn file_to_json_object<T: DeserializeOwned>(path: &str) -> LemmyResult<T> {\n  let file = File::open(path)?;\n  let reader = BufReader::new(file);\n  Ok(serde_json::from_reader(reader)?)\n}\n\npub fn test_json<T: DeserializeOwned>(path: &str) -> LemmyResult<WithContext<T>> {\n  file_to_json_object::<WithContext<T>>(path)\n}\n\n/// Check that json deserialize -> serialize -> deserialize gives identical file as initial one.\n/// Ensures that there are no breaking changes in sent data.\npub fn test_parse_lemmy_item<T: Serialize + DeserializeOwned + std::fmt::Debug>(\n  path: &str,\n) -> LemmyResult<T> {\n  // parse file as T\n  let parsed = file_to_json_object::<T>(path)?;\n\n  // parse file into hashmap, which ensures that every field is included\n  let raw = file_to_json_object::<HashMap<String, serde_json::Value>>(path)?;\n  // assert that all fields are identical, otherwise print diff\n  assert_json_include!(actual: &parsed, expected: raw);\n  Ok(parsed)\n}\n\npub(crate) async fn parse_lemmy_instance(context: &Data<LemmyContext>) -> LemmyResult<ApubSite> {\n  let json: Instance = file_to_json_object(\"../apub/assets/lemmy/objects/instance.json\")?;\n  let id = Url::parse(\"https://enterprise.lemmy.ml/\")?;\n  ApubSite::verify(&json, &id, context).await?;\n  let site = ApubSite::from_json(json, context).await?;\n  assert_eq!(context.request_count(), 0);\n  Ok(site)\n}\n\npub async fn parse_lemmy_person(\n  context: &Data<LemmyContext>,\n) -> LemmyResult<(ApubPerson, ApubSite)> {\n  let site = parse_lemmy_instance(context).await?;\n  let json = file_to_json_object(\"../apub/assets/lemmy/objects/person.json\")?;\n  let url = Url::parse(\"https://enterprise.lemmy.ml/u/picard\")?;\n  ApubPerson::verify(&json, &url, context).await?;\n  let person = ApubPerson::from_json(json, context).await?;\n  assert_eq!(context.request_count(), 0);\n  Ok((person, site))\n}\n\npub async fn parse_lemmy_community(context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {\n  // use separate counter so this doesn't affect tests\n  let context2 = context.clone();\n  let mut json: Group = file_to_json_object(\"../apub/assets/lemmy/objects/group.json\")?;\n  // change these links so they dont fetch over the network\n  json.attributed_to = None;\n  json.outbox = Url::parse(\"https://enterprise.lemmy.ml/c/tenforward/not_outbox\")?;\n  json.followers = Some(Url::parse(\n    \"https://enterprise.lemmy.ml/c/tenforward/not_followers\",\n  )?);\n\n  let url = Url::parse(\"https://enterprise.lemmy.ml/c/tenforward\")?;\n  ApubCommunity::verify(&json, &url, &context2).await?;\n  let community = ApubCommunity::from_json(json, &context2).await?;\n  Ok(community)\n}\n"
  },
  {
    "path": "crates/apub/send/Cargo.toml",
    "content": "[package]\nname = \"lemmy_apub_send\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_db_views_community_follower/full\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_api_utils/full\",\n]\n\n[dependencies]\nlemmy_db_views_community_follower = { workspace = true }\nlemmy_api_utils.workspace = true\nlemmy_apub_objects.workspace = true\nlemmy_db_schema = { workspace = true }\nlemmy_utils.workspace = true\nlemmy_db_schema_file = { workspace = true }\neither.workspace = true\n\nactivitypub_federation.workspace = true\nanyhow.workspace = true\nasync-trait.workspace = true\nfutures.workspace = true\nchrono.workspace = true\ndiesel = { workspace = true }\ndiesel-async = { workspace = true }\nreqwest.workspace = true\nserde_json.workspace = true\ntokio = { workspace = true, features = [\"full\"] }\nserde.workspace = true\ntracing.workspace = true\nmoka.workspace = true\ntokio-util = \"0.7.18\"\nlemmy_diesel_utils = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\nurl.workspace = true\nactix-web.workspace = true\ntracing-test = \"0.2.6\"\nuuid.workspace = true\ntest-context = \"0.5.5\"\nmockall = \"0.14.0\"\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/apub/send/src/inboxes.rs",
    "content": "use crate::util::LEMMY_TEST_FAST_FEDERATION;\nuse chrono::{DateTime, TimeZone, Utc};\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{activity::SentActivity, site::Site},\n};\nuse lemmy_db_schema_file::InstanceId;\nuse lemmy_db_views_community_follower::CommunityFollowerView;\nuse lemmy_diesel_utils::{\n  connection::{ActualDbPool, DbPool},\n  dburl::DbUrl,\n};\nuse lemmy_utils::error::LemmyResult;\nuse reqwest::Url;\nuse std::{\n  collections::{HashMap, HashSet},\n  sync::LazyLock,\n};\n\n/// interval with which new additions to community_followers are queried.\n///\n/// The first time some user on an instance follows a specific remote community (or, more precisely:\n/// the first time a (followed_community_id, follower_inbox_url) tuple appears), this delay limits\n/// the maximum time until the follow actually results in activities from that community id being\n/// sent to that inbox url. This delay currently needs to not be too small because the DB load is\n/// currently fairly high because of the current structure of storing inboxes for every person, not\n/// having a separate list of shared_inboxes, and the architecture of having every instance queue be\n/// fully separate. (see https://github.com/LemmyNet/lemmy/issues/3958)\n#[expect(clippy::expect_used)]\nstatic FOLLOW_ADDITIONS_RECHECK_DELAY: LazyLock<chrono::TimeDelta> = LazyLock::new(|| {\n  if *LEMMY_TEST_FAST_FEDERATION {\n    chrono::TimeDelta::try_seconds(1).expect(\"TimeDelta out of bounds\")\n  } else {\n    chrono::TimeDelta::try_minutes(2).expect(\"TimeDelta out of bounds\")\n  }\n});\n/// The same as FOLLOW_ADDITIONS_RECHECK_DELAY, but triggering when the last person on an instance\n/// unfollows a specific remote community. This is expected to happen pretty rarely and updating it\n/// in a timely manner is not too important.\n#[expect(clippy::expect_used)]\nstatic FOLLOW_REMOVALS_RECHECK_DELAY: LazyLock<chrono::TimeDelta> =\n  LazyLock::new(|| chrono::TimeDelta::try_hours(1).expect(\"TimeDelta out of bounds\"));\n\npub trait DataSource: Send + Sync {\n  async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult<Site>;\n  async fn get_instance_followed_community_inboxes(\n    &self,\n    instance_id: InstanceId,\n    last_fetch: DateTime<Utc>,\n  ) -> LemmyResult<Vec<(CommunityId, DbUrl)>>;\n}\npub struct DbDataSource {\n  pool: ActualDbPool,\n}\n\nimpl DbDataSource {\n  pub fn new(pool: ActualDbPool) -> Self {\n    Self { pool }\n  }\n}\n\nimpl DataSource for DbDataSource {\n  async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult<Site> {\n    Site::read_from_instance_id(&mut DbPool::Pool(&self.pool), instance_id).await\n  }\n\n  async fn get_instance_followed_community_inboxes(\n    &self,\n    instance_id: InstanceId,\n    last_fetch: DateTime<Utc>,\n  ) -> LemmyResult<Vec<(CommunityId, DbUrl)>> {\n    CommunityFollowerView::get_instance_followed_community_inboxes(\n      &mut DbPool::Pool(&self.pool),\n      instance_id,\n      last_fetch,\n    )\n    .await\n  }\n}\n\npub(crate) struct CommunityInboxCollector<T: DataSource> {\n  // load site lazily because if an instance is first seen due to being on allowlist,\n  // the corresponding row in `site` may not exist yet since that is only added once\n  // `fetch_instance_actor_for_object` is called.\n  // (this should be unlikely to be relevant outside of the federation tests)\n  site_loaded: bool,\n  site: Option<Site>,\n  followed_communities: HashMap<CommunityId, HashSet<Url>>,\n  last_full_communities_fetch: DateTime<Utc>,\n  last_incremental_communities_fetch: DateTime<Utc>,\n  instance_id: InstanceId,\n  domain: String,\n  pub(crate) data_source: T,\n}\n\npub type RealCommunityInboxCollector = CommunityInboxCollector<DbDataSource>;\n\nimpl<T: DataSource> CommunityInboxCollector<T> {\n  pub fn new_real(\n    pool: ActualDbPool,\n    instance_id: InstanceId,\n    domain: String,\n  ) -> RealCommunityInboxCollector {\n    CommunityInboxCollector::new(DbDataSource::new(pool), instance_id, domain)\n  }\n  pub fn new(\n    data_source: T,\n    instance_id: InstanceId,\n    domain: String,\n  ) -> CommunityInboxCollector<T> {\n    CommunityInboxCollector {\n      data_source,\n      site_loaded: false,\n      site: None,\n      followed_communities: HashMap::new(),\n      last_full_communities_fetch: Utc.timestamp_nanos(0),\n      last_incremental_communities_fetch: Utc.timestamp_nanos(0),\n      instance_id,\n      domain,\n    }\n  }\n  /// get inbox urls of sending the given activity to the given instance\n  /// most often this will return 0 values (if instance doesn't care about the activity)\n  /// or 1 value (the shared inbox)\n  /// > 1 values only happens for non-lemmy software\n  pub async fn get_inbox_urls(&mut self, activity: &SentActivity) -> LemmyResult<Vec<Url>> {\n    let mut inbox_urls: HashSet<Url> = HashSet::new();\n\n    if activity.send_all_instances {\n      if !self.site_loaded {\n        self.site = self\n          .data_source\n          .read_site_from_instance_id(self.instance_id)\n          .await\n          .ok();\n        self.site_loaded = true;\n      }\n      if let Some(site) = &self.site {\n        // Nutomic: Most non-lemmy software wont have a site row. That means it cant handle these\n        // activities. So handling it like this is fine.\n        inbox_urls.insert(site.inbox_url.inner().clone());\n      }\n    }\n    if let Some(t) = &activity.send_community_followers_of\n      && let Some(urls) = self.followed_communities.get(t)\n    {\n      inbox_urls.extend(urls.iter().cloned());\n    }\n    inbox_urls.extend(\n      activity\n        .send_inboxes\n        .iter()\n        .filter_map(std::option::Option::as_ref)\n        // a similar filter also happens within the activitypub-federation crate. but that filter\n        // happens much later - by doing it here, we can ensure that in the happy case, this\n        // function returns 0 urls which means the system doesn't have to create a tokio\n        // task for sending at all (since that task has a fair amount of overhead)\n        .filter(|&u| u.domain() == Some(&self.domain))\n        .map(|u| u.inner().clone()),\n    );\n    tracing::trace!(\n      \"get_inbox_urls: {:?}, send_inboxes: {:?}\",\n      inbox_urls,\n      activity.send_inboxes\n    );\n    Ok(inbox_urls.into_iter().collect())\n  }\n\n  pub async fn update_communities(&mut self) -> LemmyResult<()> {\n    if (Utc::now() - self.last_full_communities_fetch) > *FOLLOW_REMOVALS_RECHECK_DELAY {\n      tracing::debug!(\"{}: fetching full list of communities\", self.domain);\n      // process removals every hour\n      (self.followed_communities, self.last_full_communities_fetch) = self\n        .get_communities(self.instance_id, Utc.timestamp_nanos(0))\n        .await?;\n      self.last_incremental_communities_fetch = self.last_full_communities_fetch;\n    }\n    if (Utc::now() - self.last_incremental_communities_fetch) > *FOLLOW_ADDITIONS_RECHECK_DELAY {\n      // process additions every minute\n      let (news, time) = self\n        .get_communities(self.instance_id, self.last_incremental_communities_fetch)\n        .await?;\n      if !news.is_empty() {\n        tracing::debug!(\n          \"{}: fetched {} incremental new followed communities\",\n          self.domain,\n          news.len()\n        );\n      }\n      self.followed_communities.extend(news);\n      self.last_incremental_communities_fetch = time;\n    }\n    Ok(())\n  }\n\n  /// get a list of local communities with the remote inboxes on the given instance that cares about\n  /// them\n  async fn get_communities(\n    &mut self,\n    instance_id: InstanceId,\n    last_fetch: DateTime<Utc>,\n  ) -> LemmyResult<(HashMap<CommunityId, HashSet<Url>>, DateTime<Utc>)> {\n    // update to time before fetch to ensure overlap. subtract some time to ensure overlap even if\n    // published date is not exact\n    let new_last_fetch = Utc::now() - *FOLLOW_ADDITIONS_RECHECK_DELAY / 2;\n\n    let inboxes = self\n      .data_source\n      .get_instance_followed_community_inboxes(instance_id, last_fetch)\n      .await?;\n\n    let map: HashMap<CommunityId, HashSet<Url>> =\n      inboxes.into_iter().fold(HashMap::new(), |mut map, (c, u)| {\n        map.entry(c).or_default().insert(u.into());\n        map\n      });\n\n    Ok((map, new_last_fetch))\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use super::*;\n  use lemmy_db_schema::{\n    newtypes::{ActivityId, CommunityId, SiteId},\n    source::activity::SentActivity,\n  };\n  use lemmy_db_schema_file::{InstanceId, enums::ActorType};\n  use lemmy_utils::error::LemmyResult;\n  use mockall::mock;\n  use serde_json::json;\n\n  mock! {\n      DataSource {}\n      impl DataSource for DataSource {\n          async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult<Site>;\n          async fn get_instance_followed_community_inboxes(\n              &self,\n              instance_id: InstanceId,\n              last_fetch: DateTime<Utc>,\n          ) -> LemmyResult<Vec<(CommunityId, DbUrl)>>;\n      }\n  }\n\n  fn setup_collector() -> CommunityInboxCollector<MockDataSource> {\n    let mock_data_source = MockDataSource::new();\n    let instance_id = InstanceId(1);\n    let domain = \"example.com\".to_string();\n    CommunityInboxCollector::new(mock_data_source, instance_id, domain)\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_empty() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![],\n      send_community_followers_of: None,\n      send_all_instances: false,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert!(result.is_empty());\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_send_all_instances() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    let site_inbox = Url::parse(\"https://example.com/inbox\")?;\n    let site = Site {\n      id: SiteId(1),\n      name: \"Test Site\".to_string(),\n      sidebar: None,\n      published_at: Utc::now(),\n      updated_at: None,\n      icon: None,\n      banner: None,\n      summary: None,\n      ap_id: Url::parse(\"https://example.com/site\")?.into(),\n      last_refreshed_at: Utc::now(),\n      inbox_url: site_inbox.clone().into(),\n      private_key: None,\n      public_key: \"test_key\".to_string(),\n      instance_id: InstanceId(1),\n      content_warning: None,\n    };\n\n    collector\n      .data_source\n      .expect_read_site_from_instance_id()\n      .return_once(move |_| Ok(site));\n\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![],\n      send_community_followers_of: None,\n      send_all_instances: true,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert_eq!(result.len(), 1);\n    assert_eq!(result[0], site_inbox);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_community_followers() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    let community_id = CommunityId(1);\n    let url1 = \"https://follower1.example.com/inbox\";\n    let url2 = \"https://follower2.example.com/inbox\";\n\n    collector\n      .data_source\n      .expect_get_instance_followed_community_inboxes()\n      .return_once(move |_, _| {\n        Ok(vec![\n          (community_id, Url::parse(url1)?.into()),\n          (community_id, Url::parse(url2)?.into()),\n        ])\n      });\n\n    collector.update_communities().await?;\n\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![],\n      send_community_followers_of: Some(community_id),\n      send_all_instances: false,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert_eq!(result.len(), 2);\n    assert!(result.contains(&Url::parse(url1)?));\n    assert!(result.contains(&Url::parse(url2)?));\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_send_inboxes() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    collector.domain = \"example.com\".to_string();\n    let inbox_user_1 = Url::parse(\"https://example.com/user1/inbox\")?;\n    let inbox_user_2 = Url::parse(\"https://example.com/user2/inbox\")?;\n    let other_domain_inbox = Url::parse(\"https://other-domain.com/user3/inbox\")?;\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![\n        Some(inbox_user_1.clone().into()),\n        Some(inbox_user_2.clone().into()),\n        Some(other_domain_inbox.clone().into()),\n      ],\n      send_community_followers_of: None,\n      send_all_instances: false,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert_eq!(result.len(), 2);\n    assert!(result.contains(&inbox_user_1));\n    assert!(result.contains(&inbox_user_2));\n    assert!(!result.contains(&other_domain_inbox));\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_combined() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    collector.domain = \"example.com\".to_string();\n    let community_id = CommunityId(1);\n\n    let site_inbox = Url::parse(\"https://example.com/site_inbox\")?;\n    let site = Site {\n      id: SiteId(1),\n      name: \"Test Site\".to_string(),\n      sidebar: None,\n      published_at: Utc::now(),\n      updated_at: None,\n      icon: None,\n      banner: None,\n      summary: None,\n      ap_id: Url::parse(\"https://example.com/site\")?.into(),\n      last_refreshed_at: Utc::now(),\n      inbox_url: site_inbox.clone().into(),\n      private_key: None,\n      public_key: \"test_key\".to_string(),\n      instance_id: InstanceId(1),\n      content_warning: None,\n    };\n\n    collector\n      .data_source\n      .expect_read_site_from_instance_id()\n      .return_once(move |_| Ok(site));\n\n    let subdomain_inbox = \"https://follower.example.com/inbox\";\n    collector\n      .data_source\n      .expect_get_instance_followed_community_inboxes()\n      .return_once(move |_, _| Ok(vec![(community_id, Url::parse(subdomain_inbox)?.into())]));\n\n    collector.update_communities().await?;\n    let user1_inbox = Url::parse(\"https://example.com/user1/inbox\")?;\n    let user2_inbox = Url::parse(\"https://other-domain.com/user2/inbox\")?;\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![\n        Some(user1_inbox.clone().into()),\n        Some(user2_inbox.clone().into()),\n      ],\n      send_community_followers_of: Some(community_id),\n      send_all_instances: true,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert_eq!(result.len(), 3);\n    assert!(result.contains(&site_inbox));\n    assert!(result.contains(&Url::parse(subdomain_inbox)?));\n    assert!(result.contains(&user1_inbox));\n    assert!(!result.contains(&user2_inbox));\n\n    Ok(())\n  }\n\n  #[expect(clippy::expect_used)]\n  #[tokio::test]\n  async fn test_update_communities() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    let community_id1 = CommunityId(1);\n    let community_id2 = CommunityId(2);\n    let community_id3 = CommunityId(3);\n\n    let user1_inbox_str = \"https://follower1.example.com/inbox\";\n    let user1_inbox = Url::parse(user1_inbox_str)?;\n    let user2_inbox_str = \"https://follower2.example.com/inbox\";\n    let user2_inbox = Url::parse(user2_inbox_str)?;\n    let user3_inbox_str = \"https://follower3.example.com/inbox\";\n    let user3_inbox = Url::parse(user3_inbox_str)?;\n\n    collector\n      .data_source\n      .expect_get_instance_followed_community_inboxes()\n      .times(2)\n      .returning(move |_, last_fetch| {\n        if last_fetch == Utc.timestamp_nanos(0) {\n          Ok(vec![\n            (community_id1, Url::parse(user1_inbox_str)?.into()),\n            (community_id2, Url::parse(user2_inbox_str)?.into()),\n          ])\n        } else {\n          Ok(vec![(community_id3, Url::parse(user3_inbox_str)?.into())])\n        }\n      });\n\n    // First update\n    collector.update_communities().await?;\n    assert_eq!(collector.followed_communities.len(), 2);\n    assert!(collector.followed_communities[&community_id1].contains(&user1_inbox));\n    assert!(collector.followed_communities[&community_id2].contains(&user2_inbox));\n\n    // Simulate time passing\n    collector.last_full_communities_fetch =\n      Utc::now() - chrono::TimeDelta::try_minutes(3).expect(\"TimeDelta out of bounds\");\n    collector.last_incremental_communities_fetch =\n      Utc::now() - chrono::TimeDelta::try_minutes(3).expect(\"TimeDelta out of bounds\");\n\n    // Second update (incremental)\n    collector.update_communities().await?;\n    assert_eq!(collector.followed_communities.len(), 3);\n    assert!(collector.followed_communities[&community_id1].contains(&user1_inbox));\n    assert!(collector.followed_communities[&community_id3].contains(&user3_inbox));\n    assert!(collector.followed_communities[&community_id2].contains(&user2_inbox));\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_get_inbox_urls_no_duplicates() -> LemmyResult<()> {\n    let mut collector = setup_collector();\n    collector.domain = \"example.com\".to_string();\n    let community_id = CommunityId(1);\n    let site_inbox = Url::parse(\"https://example.com/site_inbox\")?;\n    let site_inbox_clone = site_inbox.clone();\n    let site = Site {\n      id: SiteId(1),\n      name: \"Test Site\".to_string(),\n      sidebar: None,\n      published_at: Utc::now(),\n      updated_at: None,\n      icon: None,\n      banner: None,\n      summary: None,\n      ap_id: Url::parse(\"https://example.com/site\")?.into(),\n      last_refreshed_at: Utc::now(),\n      inbox_url: site_inbox.clone().into(),\n      private_key: None,\n      public_key: \"test_key\".to_string(),\n      instance_id: InstanceId(1),\n      content_warning: None,\n    };\n\n    collector\n      .data_source\n      .expect_read_site_from_instance_id()\n      .return_once(move |_| Ok(site));\n\n    collector\n      .data_source\n      .expect_get_instance_followed_community_inboxes()\n      .return_once(move |_, _| Ok(vec![(community_id, site_inbox_clone.into())]));\n\n    collector.update_communities().await?;\n\n    let activity = SentActivity {\n      id: ActivityId(1),\n      ap_id: Url::parse(\"https://example.com/activities/1\")?.into(),\n      data: json!({}),\n      sensitive: false,\n      published_at: Utc::now(),\n      send_inboxes: vec![Some(site_inbox.into())],\n      send_community_followers_of: Some(community_id),\n      send_all_instances: true,\n      actor_type: ActorType::Person,\n      actor_apub_id: None,\n    };\n\n    let result = collector.get_inbox_urls(&activity).await?;\n    assert_eq!(result.len(), 1);\n    assert!(result.contains(&Url::parse(\"https://example.com/site_inbox\")?));\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/send/src/lib.rs",
    "content": "use crate::{util::CancellableTask, worker::InstanceWorker};\nuse activitypub_federation::config::FederationConfig;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::instance::Instance;\nuse lemmy_db_schema_file::InstanceId;\nuse lemmy_utils::{error::LemmyResult, settings::structs::FederationWorkerConfig};\nuse stats::receive_print_stats;\nuse std::{collections::HashMap, time::Duration};\nuse tokio::{\n  sync::mpsc::{UnboundedSender, unbounded_channel},\n  task::JoinHandle,\n  time::sleep,\n};\nuse tokio_util::sync::CancellationToken;\nuse tracing::info;\nuse util::FederationQueueStateWithDomain;\n\nmod inboxes;\nmod send;\nmod stats;\nmod util;\nmod worker;\n\nstatic WORKER_EXIT_TIMEOUT: Duration = Duration::from_secs(30);\n#[cfg(debug_assertions)]\nstatic INSTANCES_RECHECK_DELAY: Duration = Duration::from_secs(5);\n#[cfg(not(debug_assertions))]\nstatic INSTANCES_RECHECK_DELAY: Duration = Duration::from_secs(60);\n\n#[derive(Clone)]\npub struct Opts {\n  /// how many processes you are starting in total\n  pub process_count: i32,\n  /// the index of this process (1-based: 1 - process_count)\n  pub process_index: i32,\n}\n\npub struct SendManager {\n  opts: Opts,\n  workers: HashMap<InstanceId, CancellableTask>,\n  context: FederationConfig<LemmyContext>,\n  stats_sender: UnboundedSender<FederationQueueStateWithDomain>,\n  exit_print: JoinHandle<()>,\n  federation_worker_config: FederationWorkerConfig,\n}\n\nimpl SendManager {\n  fn new(\n    opts: Opts,\n    context: FederationConfig<LemmyContext>,\n    federation_worker_config: FederationWorkerConfig,\n  ) -> Self {\n    assert!(opts.process_count > 0);\n    assert!(opts.process_index > 0);\n    assert!(opts.process_index <= opts.process_count);\n\n    let (stats_sender, stats_receiver) = unbounded_channel();\n    Self {\n      opts,\n      workers: HashMap::new(),\n      stats_sender,\n      exit_print: tokio::spawn(receive_print_stats(\n        context.inner_pool().clone(),\n        stats_receiver,\n      )),\n      context,\n      federation_worker_config,\n    }\n  }\n\n  pub fn run(\n    opts: Opts,\n    context: FederationConfig<LemmyContext>,\n    config: FederationWorkerConfig,\n  ) -> CancellableTask {\n    CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |cancel| {\n      let opts = opts.clone();\n      let config = config.clone();\n      let context = context.clone();\n      let mut manager = Self::new(opts, context, config);\n      async move {\n        let result = manager.do_loop(cancel).await;\n        // the loop function will only return if there is (a) an internal error (e.g. db connection\n        // failure) or (b) it was cancelled from outside.\n        if let Err(e) = result {\n          // don't let this error bubble up, just log it, so the below cancel function will run\n          // regardless\n          tracing::error!(\"SendManager failed: {e}\");\n        }\n        // cancel all the dependent workers as well to ensure they don't get orphaned and keep\n        // running.\n        manager.cancel().await?;\n        LemmyResult::Ok(())\n        // if the task was not intentionally cancelled, then this whole lambda will be run again by\n        // CancellableTask after this\n      }\n    })\n  }\n\n  async fn do_loop(&mut self, cancel: CancellationToken) -> LemmyResult<()> {\n    let process_index = self.opts.process_index - 1;\n    info!(\n      \"Starting federation workers for process count {} and index {}\",\n      self.opts.process_count, process_index\n    );\n    let local_domain = self.context.settings().get_hostname_without_port()?;\n    let mut pool = self.context.pool();\n    loop {\n      let mut total_count = 0;\n      let mut dead_count = 0;\n      let mut disallowed_count = 0;\n      for (instance, allowed, is_dead) in\n        Instance::read_federated_with_blocked_and_dead(&mut pool).await?\n      {\n        if instance.domain == local_domain {\n          continue;\n        }\n        if instance.id.inner() % self.opts.process_count != process_index {\n          continue;\n        }\n        total_count += 1;\n        if !allowed {\n          disallowed_count += 1;\n        }\n        if is_dead {\n          dead_count += 1;\n        }\n        let should_federate = allowed && !is_dead;\n        if should_federate {\n          if self.workers.contains_key(&instance.id) {\n            // worker already running\n            continue;\n          }\n          // create new worker\n          let context = self.context.clone();\n          let stats_sender = self.stats_sender.clone();\n          let federation_worker_config = self.federation_worker_config.clone();\n\n          self.workers.insert(\n            instance.id,\n            CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |stop| {\n              // if the instance worker ends unexpectedly due to internal/db errors, this lambda is\n              // rerun by cancellabletask.\n              let instance = instance.clone();\n              InstanceWorker::init_and_loop(\n                instance,\n                context.clone(),\n                federation_worker_config.clone(),\n                stop,\n                stats_sender.clone(),\n              )\n            }),\n          );\n        } else if !should_federate\n          && let Some(worker) = self.workers.remove(&instance.id)\n          && let Err(e) = worker.cancel().await\n        {\n          tracing::error!(\"error stopping worker: {e}\");\n        }\n      }\n      let worker_count = self.workers.len();\n      tracing::info!(\n        \"Federating to {worker_count}/{total_count} instances ({dead_count} dead, {disallowed_count} disallowed)\"\n      );\n      tokio::select! {\n        () = sleep(INSTANCES_RECHECK_DELAY) => {},\n        _ = cancel.cancelled() => { return Ok(()) }\n      }\n    }\n  }\n\n  pub async fn cancel(self) -> LemmyResult<()> {\n    drop(self.stats_sender);\n    tracing::warn!(\n      \"Waiting for {} workers ({:.2?} max)\",\n      self.workers.len(),\n      WORKER_EXIT_TIMEOUT\n    );\n    // the cancel futures need to be awaited concurrently for the shutdown processes to be triggered\n    // concurrently\n    futures::future::join_all(\n      self\n        .workers\n        .into_values()\n        .map(util::CancellableTask::cancel),\n    )\n    .await;\n    self.exit_print.await?;\n    Ok(())\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::unwrap_used)]\n#[expect(clippy::indexing_slicing)]\nmod test {\n\n  use super::*;\n  use activitypub_federation::config::Data;\n  use chrono::DateTime;\n  use lemmy_db_schema::source::{\n    federation_allowlist::{FederationAllowList, FederationAllowListForm},\n    federation_blocklist::{FederationBlockList, FederationBlockListForm},\n    instance::InstanceForm,\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyError;\n  use serial_test::serial;\n  use std::{\n    collections::HashSet,\n    sync::{Arc, Mutex},\n  };\n  use tokio::spawn;\n\n  struct TestData {\n    send_manager: SendManager,\n    context: Data<LemmyContext>,\n    instances: Vec<Instance>,\n  }\n  impl TestData {\n    async fn init(process_count: i32, process_index: i32) -> LemmyResult<Self> {\n      let context = LemmyContext::init_test_context().await;\n      let opts = Opts {\n        process_count,\n        process_index,\n      };\n      let federation_config = FederationConfig::builder()\n        .domain(\"local.com\")\n        .app_data(context.app_data().clone())\n        .build()\n        .await?;\n      let concurrent_sends_per_instance = std::env::var(\"LEMMY_TEST_FEDERATION_CONCURRENT_SENDS\")\n        .ok()\n        .and_then(|s| s.parse().ok())\n        .unwrap_or(1);\n\n      let federation_worker_config = FederationWorkerConfig {\n        concurrent_sends_per_instance,\n      };\n      let pool = &mut context.pool();\n      let instances = vec![\n        Instance::read_or_create(pool, \"alpha.com\").await?,\n        Instance::read_or_create(pool, \"beta.com\").await?,\n        Instance::read_or_create(pool, \"gamma.com\").await?,\n      ];\n\n      let send_manager = SendManager::new(opts, federation_config, federation_worker_config);\n      Ok(Self {\n        send_manager,\n        context,\n        instances,\n      })\n    }\n\n    async fn run(&mut self) -> LemmyResult<()> {\n      // start it and cancel after workers are running\n      let cancel = CancellationToken::new();\n      let cancel_ = cancel.clone();\n      spawn(async move {\n        sleep(Duration::from_millis(100)).await;\n        cancel_.cancel();\n      });\n      self.send_manager.do_loop(cancel.clone()).await?;\n      Ok(())\n    }\n\n    async fn cleanup(self) -> LemmyResult<()> {\n      self.send_manager.cancel().await?;\n      Instance::delete_all(&mut self.context.pool()).await?;\n      Ok(())\n    }\n  }\n\n  /// Basic test with default params and only active/allowed instances\n  #[tokio::test]\n  #[serial]\n  async fn test_send_manager() -> LemmyResult<()> {\n    let mut data = TestData::init(1, 1).await?;\n\n    data.run().await?;\n    assert_eq!(3, data.send_manager.workers.len());\n    let workers: HashSet<_> = data.send_manager.workers.keys().cloned().collect();\n    let instances: HashSet<_> = data.instances.iter().map(|i| i.id).collect();\n    assert_eq!(instances, workers);\n\n    data.cleanup().await?;\n    Ok(())\n  }\n\n  /// Running with multiple processes should start correct workers\n  #[tokio::test]\n  #[serial]\n  async fn test_send_manager_processes() -> LemmyResult<()> {\n    let active = Arc::new(Mutex::new(vec![]));\n    let execute = |count, index, active: Arc<Mutex<Vec<InstanceId>>>| async move {\n      let mut data = TestData::init(count, index).await?;\n      data.run().await?;\n      assert_eq!(1, data.send_manager.workers.len());\n      for k in data.send_manager.workers.keys() {\n        active.lock().unwrap().push(*k);\n      }\n      data.cleanup().await?;\n      Ok::<(), LemmyError>(())\n    };\n    execute(3, 1, active.clone()).await?;\n    execute(3, 2, active.clone()).await?;\n    execute(3, 3, active.clone()).await?;\n\n    // Should run exactly three workers\n    assert_eq!(3, active.lock().unwrap().len());\n\n    Ok(())\n  }\n\n  /// Use blocklist, should not send to blocked instances\n  #[tokio::test]\n  #[serial]\n  async fn test_send_manager_blocked() -> LemmyResult<()> {\n    let mut data = TestData::init(1, 1).await?;\n\n    let instance_id = data.instances[0].id;\n    let form = PersonInsertForm::new(\"tim\".to_string(), String::new(), instance_id);\n    let person = Person::create(&mut data.context.pool(), &form).await?;\n    let form = FederationBlockListForm::new(instance_id, None);\n    FederationBlockList::block(&mut data.context.pool(), &form).await?;\n    data.run().await?;\n    let workers = &data.send_manager.workers;\n    assert_eq!(2, workers.len());\n    assert!(workers.contains_key(&data.instances[1].id));\n    assert!(workers.contains_key(&data.instances[2].id));\n\n    Person::delete(&mut data.context.pool(), person.id).await?;\n    data.cleanup().await?;\n    Ok(())\n  }\n\n  /// Use allowlist, should only send to allowed instance\n  #[tokio::test]\n  #[serial]\n  async fn test_send_manager_allowed() -> LemmyResult<()> {\n    let mut data = TestData::init(1, 1).await?;\n\n    let instance_id = data.instances[0].id;\n    let form = PersonInsertForm::new(\"tim\".to_string(), String::new(), instance_id);\n    let person = Person::create(&mut data.context.pool(), &form).await?;\n    let form = FederationAllowListForm::new(data.instances[0].id);\n    FederationAllowList::allow(&mut data.context.pool(), &form).await?;\n    data.run().await?;\n    let workers = &data.send_manager.workers;\n    assert_eq!(1, workers.len());\n    assert!(workers.contains_key(&data.instances[0].id));\n\n    Person::delete(&mut data.context.pool(), person.id).await?;\n    data.cleanup().await?;\n    Ok(())\n  }\n\n  /// Mark instance as dead, there should be no worker created for it\n  #[tokio::test]\n  #[serial]\n  async fn test_send_manager_dead() -> LemmyResult<()> {\n    let mut data = TestData::init(1, 1).await?;\n\n    let instance = &data.instances[0];\n    let form = InstanceForm {\n      updated_at: DateTime::from_timestamp(0, 0),\n      ..InstanceForm::new(instance.domain.clone())\n    };\n    Instance::update(&mut data.context.pool(), instance.id, form).await?;\n\n    data.run().await?;\n    let workers = &data.send_manager.workers;\n    assert_eq!(2, workers.len());\n    assert!(workers.contains_key(&data.instances[1].id));\n    assert!(workers.contains_key(&data.instances[2].id));\n\n    data.cleanup().await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/send/src/send.rs",
    "content": "use crate::util::get_actor_cached;\nuse activitypub_federation::{\n  activity_sending::SendActivityTask,\n  config::Data,\n  protocol::context::WithContext,\n  traits::Activity,\n};\nuse anyhow::{Context, Result};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{newtypes::ActivityId, source::activity::SentActivity};\nuse lemmy_utils::{\n  FEDERATION_CONTEXT,\n  error::{LemmyError, LemmyResult},\n  federate_retry_sleep_duration,\n};\nuse reqwest::Url;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::ops::Deref;\nuse tokio::{sync::mpsc::UnboundedSender, time::sleep};\nuse tokio_util::sync::CancellationToken;\n\n#[derive(Debug, Eq)]\npub(crate) struct SendSuccessInfo {\n  pub activity_id: ActivityId,\n  pub published_at: Option<DateTime<Utc>>,\n  // true if the activity was skipped because the target instance is not interested in this\n  // activity\n  pub was_skipped: bool,\n}\nimpl PartialEq for SendSuccessInfo {\n  fn eq(&self, other: &Self) -> bool {\n    self.activity_id == other.activity_id\n  }\n}\n/// order backwards because the binary heap is a max heap, and we need the smallest element to be on\n/// top\nimpl PartialOrd for SendSuccessInfo {\n  fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {\n    Some(self.cmp(other))\n  }\n}\nimpl Ord for SendSuccessInfo {\n  fn cmp(&self, other: &Self) -> std::cmp::Ordering {\n    other.activity_id.cmp(&self.activity_id)\n  }\n}\n\n/// Represents the result of sending an activity.\n///\n/// This enum is used to communicate the outcome of a send operation from a send task\n/// to the main instance worker. It's designed to maintain a clean separation between\n/// the send task and the main thread, allowing the send.rs file to be self-contained\n/// and easier to understand.\n///\n/// The use of a channel for communication (rather than shared atomic variables) was chosen\n/// because:\n/// 1. It keeps the send task cleanly separated with no direct interaction with the main thread.\n/// 2. The failure event needs to be transferred to the main task for database updates anyway.\n/// 3. The main fail_count should only be updated under certain conditions, which are best handled\n///    in the main task.\n/// 4. It maintains consistency in how data is communicated (all via channels rather than a mix of\n///    channels and atomics).\n/// 5. It simplifies concurrency management and makes the flow of data more predictable.\npub(crate) enum SendActivityResult {\n  Success(SendSuccessInfo),\n  Failure { fail_count: i32 },\n}\n/// Represents a task for retrying to send an activity.\n///\n/// This struct encapsulates all the necessary information and resources for attempting\n/// to send an activity to multiple inbox URLs, with built-in retry logic.\npub(crate) struct SendRetryTask<'a> {\n  pub activity: &'a SentActivity,\n  /// The activity data to be sent. Has type `SharedInboxActivities`, but uses `Value` to avoid\n  /// dependency.\n  pub object: &'a Value,\n  /// Must not be empty at this point\n  pub inbox_urls: Vec<Url>,\n  /// Channel to report results back to the main instance worker\n  pub report: &'a mut UnboundedSender<SendActivityResult>,\n  /// The first request will be sent immediately, but subsequent requests will be delayed\n  /// according to the number of previous fails + 1\n  ///\n  /// This is a read-only immutable variable that is passed only one way, from the main\n  /// thread to each send task. It allows the task to determine how long to sleep initially\n  /// if the request fails.\n  pub initial_fail_count: i32,\n  /// For logging purposes\n  pub domain: String,\n  pub context: Data<LemmyContext>,\n  pub stop: CancellationToken,\n}\n\nimpl SendRetryTask<'_> {\n  // this function will return successfully when (a) send succeeded or (b) worker cancelled\n  // and will return an error if an internal error occurred (send errors cause an infinite loop)\n  pub async fn send_retry_loop(self) -> Result<()> {\n    let SendRetryTask {\n      activity,\n      object,\n      inbox_urls,\n      report,\n      initial_fail_count,\n      domain,\n      context,\n      stop,\n    } = self;\n    debug_assert!(!inbox_urls.is_empty());\n\n    let pool = &mut context.pool();\n    let Some(actor_apub_id) = &activity.actor_apub_id else {\n      return Err(anyhow::anyhow!(\"activity is from before lemmy 0.19\"));\n    };\n    let actor = get_actor_cached(pool, activity.actor_type, actor_apub_id)\n      .await\n      .context(\"failed getting actor instance (was it marked deleted / removed?)\")?;\n\n    let object: DummyActivity = serde_json::from_value(object.clone())?;\n    let object = WithContext::new(object, FEDERATION_CONTEXT.deref().clone());\n    let requests = SendActivityTask::prepare(&object, actor.as_ref(), inbox_urls, &context).await?;\n    for task in requests {\n      // usually only one due to shared inbox\n      tracing::debug!(\"sending out {}\", task);\n      let mut fail_count = initial_fail_count;\n      while let Err(e) = task.sign_and_send(&context).await {\n        fail_count += 1;\n        report.send(SendActivityResult::Failure {\n          fail_count,\n          // activity_id: activity.id,\n        })?;\n        let retry_delay = federate_retry_sleep_duration(fail_count);\n        tracing::info!(\n          \"{}: retrying {:?} attempt {} with delay {retry_delay:.2?}. ({e})\",\n          domain,\n          activity.id,\n          fail_count\n        );\n        tokio::select! {\n          () = sleep(retry_delay) => {},\n          () = stop.cancelled() => {\n            // cancel sending without reporting any result.\n            // the InstanceWorker needs to be careful to not hang on receive of that\n            // channel when cancelled (see handle_send_results)\n            return Ok(());\n          }\n        }\n      }\n    }\n    report.send(SendActivityResult::Success(SendSuccessInfo {\n      activity_id: activity.id,\n      published_at: Some(activity.published_at),\n      was_skipped: false,\n    }))?;\n    Ok(())\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\nstruct DummyActivity {\n  id: Url,\n  actor: Url,\n  #[serde(flatten)]\n  other: Value,\n}\n\n#[async_trait::async_trait]\nimpl Activity for DummyActivity {\n  type DataType = LemmyContext;\n\n  type Error = LemmyError;\n\n  fn id(&self) -> &Url {\n    &self.id\n  }\n\n  fn actor(&self) -> &Url {\n    &self.actor\n  }\n\n  async fn verify(&self, _context: &Data<Self::DataType>) -> LemmyResult<()> {\n    Ok(())\n  }\n\n  async fn receive(self, _context: &Data<LemmyContext>) -> LemmyResult<()> {\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/apub/send/src/stats.rs",
    "content": "use crate::util::{FederationQueueStateWithDomain, get_latest_activity_id};\nuse chrono::Local;\nuse lemmy_db_schema::newtypes::ActivityId;\nuse lemmy_db_schema_file::InstanceId;\nuse lemmy_diesel_utils::connection::{ActualDbPool, DbPool};\nuse lemmy_utils::{error::LemmyResult, federate_retry_sleep_duration};\nuse std::{collections::HashMap, time::Duration};\nuse tokio::{sync::mpsc::UnboundedReceiver, time::interval};\nuse tracing::{debug, info, warn};\n\n/// every 60s, print the state for every instance. exits if the receiver is done (all senders\n/// dropped)\npub(crate) async fn receive_print_stats(\n  pool: ActualDbPool,\n  mut receiver: UnboundedReceiver<FederationQueueStateWithDomain>,\n) {\n  let pool = &mut DbPool::Pool(&pool);\n  let mut printerval = interval(Duration::from_secs(60));\n  let mut stats = HashMap::new();\n  loop {\n    tokio::select! {\n      ele = receiver.recv() => {\n        match ele {\n          // update stats for instance\n          Some(ele) => {stats.insert(ele.state.instance_id, ele);},\n          // receiver closed, print stats and exit\n          None => {\n            print_stats(pool, &stats).await;\n            return;\n          }\n        }\n      },\n      _ = printerval.tick() => {\n        print_stats(pool, &stats).await;\n      }\n    }\n  }\n}\n\nasync fn print_stats(\n  pool: &mut DbPool<'_>,\n  stats: &HashMap<InstanceId, FederationQueueStateWithDomain>,\n) {\n  let res = print_stats_with_error(pool, stats).await;\n  if let Err(e) = res {\n    warn!(\"Failed to print stats: {e}\");\n  }\n}\n\nasync fn print_stats_with_error(\n  pool: &mut DbPool<'_>,\n  stats: &HashMap<InstanceId, FederationQueueStateWithDomain>,\n) -> LemmyResult<()> {\n  let last_id = get_latest_activity_id(pool).await?.unwrap_or(ActivityId(0));\n\n  // it's expected that the values are a bit out of date, everything < SAVE_STATE_EVERY should be\n  // considered up to date\n  info!(\"Federation state as of {}:\", Local::now().to_rfc3339());\n  // todo: more stats (act/sec, avg http req duration)\n  let mut ok_count = 0;\n  let mut behind_count = 0;\n  for ele in stats.values() {\n    let stat = &ele.state;\n    let domain = &ele.domain;\n    let behind = last_id.0 - stat.last_successful_id.map(|e| e.0).unwrap_or(0);\n    if stat.fail_count > 0 {\n      info!(\n        \"{domain}: Warning. {behind} behind, {} consecutive fails, current retry delay {:.2?}\",\n        stat.fail_count,\n        federate_retry_sleep_duration(stat.fail_count)\n      );\n    } else if behind > 0 {\n      debug!(\"{}: Ok. {} activities behind\", domain, behind);\n      behind_count += 1;\n    } else {\n      ok_count += 1;\n    }\n  }\n  info!(\"{ok_count} others up to date. {behind_count} instances behind.\");\n  Ok(())\n}\n"
  },
  {
    "path": "crates/apub/send/src/util.rs",
    "content": "use anyhow::{Context, Result, anyhow};\nuse diesel::prelude::*;\nuse diesel_async::RunQueryDsl;\nuse either::Either::*;\nuse lemmy_apub_objects::objects::SiteOrMultiOrCommunityOrUser;\nuse lemmy_db_schema::{\n  newtypes::ActivityId,\n  source::{\n    activity::SentActivity,\n    community::Community,\n    federation_queue_state::FederationQueueState,\n    multi_community::MultiCommunity,\n    person::Person,\n    site::Site,\n  },\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::ActorType;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::LemmyError;\nuse moka::future::Cache;\nuse reqwest::Url;\nuse std::{\n  fmt::Debug,\n  future::Future,\n  pin::Pin,\n  sync::{Arc, LazyLock},\n  time::Duration,\n};\nuse tokio::{task::JoinHandle, time::sleep};\nuse tokio_util::sync::CancellationToken;\n\n/// Decrease the delays of the federation queue.\n/// Should only be used for federation tests since it significantly increases CPU and DB load of the\n/// federation queue. This is intentionally a separate flag from other flags like debug_assertions,\n/// since this is a invasive change we only need rarely.\npub(crate) static LEMMY_TEST_FAST_FEDERATION: LazyLock<bool> = LazyLock::new(|| {\n  std::env::var(\"LEMMY_TEST_FAST_FEDERATION\")\n    .map(|s| !s.is_empty())\n    .unwrap_or(false)\n});\n\n/// Recheck for new federation work every n seconds within each InstanceWorker.\n///\n/// When the queue is processed faster than new activities are added and it reaches the current time\n/// with an empty batch, this is the delay the queue waits before it checks if new activities have\n/// been added to the sent_activities table. This delay is only applied if no federated activity\n/// happens during sending activities of the last batch, which means on high-activity instances it\n/// may never be used. This means that it does not affect the maximum throughput of the queue.\n///\n///\n/// This is thus the interval with which tokio wakes up each of the\n/// InstanceWorkers to check for new work, if the queue previously was empty.\n/// If the delay is too short, the workers (one per federated instance) will wake up too\n/// often and consume a lot of CPU. If the delay is long, then activities on low-traffic instances\n/// will on average take delay/2 seconds to federate.\npub(crate) static WORK_FINISHED_RECHECK_DELAY: LazyLock<Duration> = LazyLock::new(|| {\n  if *LEMMY_TEST_FAST_FEDERATION {\n    Duration::from_millis(100)\n  } else {\n    Duration::from_secs(30)\n  }\n});\n\n/// Cache the latest activity id for a certain duration.\n///\n/// This cache is common to all the instance workers and prevents there from being more than one\n/// call per N seconds between each DB query to find max(activity_id).\npub(crate) static CACHE_DURATION_LATEST_ID: LazyLock<Duration> = LazyLock::new(|| {\n  if *LEMMY_TEST_FAST_FEDERATION {\n    // in test mode, we use the same cache duration as the recheck delay so when recheck happens\n    // data is fresh, accelerating the time the tests take.\n    *WORK_FINISHED_RECHECK_DELAY\n  } else {\n    // in normal mode, we limit the query to one per second\n    Duration::from_secs(1)\n  }\n});\n\n/// A task that will be run in an infinite loop, unless it is cancelled.\n/// If the task exits without being cancelled, an error will be logged and the task will be\n/// restarted.\npub struct CancellableTask {\n  f: Pin<Box<dyn Future<Output = Result<(), anyhow::Error>> + Send + 'static>>,\n}\n\nimpl CancellableTask {\n  /// spawn a task but with graceful shutdown\n  pub fn spawn<F, R>(\n    timeout: Duration,\n    task: impl Fn(CancellationToken) -> F + Send + 'static,\n  ) -> CancellableTask\n  where\n    F: Future<Output = R> + Send + 'static,\n    R: Send + Debug + 'static,\n  {\n    let stop = CancellationToken::new();\n    let stop2 = stop.clone();\n    let task: JoinHandle<()> = tokio::spawn(async move {\n      loop {\n        let res = task(stop2.clone()).await;\n        if stop2.is_cancelled() {\n          return;\n        } else {\n          tracing::warn!(\"task exited, restarting: {res:?}\");\n        }\n      }\n    });\n    let abort = task.abort_handle();\n    CancellableTask {\n      f: Box::pin(async move {\n        stop.cancel();\n        tokio::select! {\n            r = task => {\n              r.context(\"CancellableTask failed to cancel cleanly, returned error\")?;\n              Ok(())\n            },\n            _ = sleep(timeout) => {\n                abort.abort();\n                Err(anyhow!(\"CancellableTask aborted due to shutdown timeout\"))\n            }\n        }\n      }),\n    }\n  }\n\n  /// cancel the cancel signal, wait for timeout for the task to stop gracefully, otherwise abort it\n  pub async fn cancel(self) -> Result<(), anyhow::Error> {\n    self.f.await\n  }\n}\n\n/// assuming apub priv key and ids are immutable, then we don't need to have TTL\n/// TODO: capacity should be configurable maybe based on memory use\npub(crate) async fn get_actor_cached(\n  pool: &mut DbPool<'_>,\n  actor_type: ActorType,\n  actor_apub_id: &Url,\n) -> Result<Arc<SiteOrMultiOrCommunityOrUser>> {\n  static CACHE: LazyLock<Cache<Url, Arc<SiteOrMultiOrCommunityOrUser>>> =\n    LazyLock::new(|| Cache::builder().max_capacity(10000).build());\n  CACHE\n    .try_get_with(actor_apub_id.clone(), async {\n      let url = actor_apub_id.clone().into();\n      let actor = match actor_type {\n        ActorType::Site => Left(Left(\n          Site::read_from_apub_id(pool, &url)\n            .await?\n            .context(\"apub site not found\")?\n            .into(),\n        )),\n        ActorType::Community => Right(Right(\n          Community::read_from_apub_id(pool, &url)\n            .await?\n            .context(\"apub community not found\")?\n            .into(),\n        )),\n        ActorType::Person => Right(Left(\n          Person::read_from_apub_id(pool, &url)\n            .await?\n            .context(\"apub person not found\")?\n            .into(),\n        )),\n        ActorType::MultiCommunity => Left(Right(\n          MultiCommunity::read_from_apub_id(pool, &url)\n            .await?\n            .context(\"apub multi-comm not found\")?\n            .into(),\n        )),\n      };\n      Result::<_, LemmyError>::Ok(Arc::new(actor))\n    })\n    .await\n    .map_err(|e| anyhow::anyhow!(\"err getting actor {actor_type:?} {actor_apub_id}: {e:?}\"))\n}\n\ntype CachedActivityInfo = Option<Arc<SentActivity>>;\n/// activities are immutable so cache does not need to have TTL\n/// May return None if the corresponding id does not exist or is a received activity.\n/// Holes in serials are expected behaviour in postgresql\n/// todo: cache size should probably be configurable / dependent on desired memory usage\npub(crate) async fn get_activity_cached(\n  pool: &mut DbPool<'_>,\n  activity_id: ActivityId,\n) -> Result<CachedActivityInfo> {\n  static ACTIVITIES: LazyLock<Cache<ActivityId, CachedActivityInfo>> =\n    LazyLock::new(|| Cache::builder().max_capacity(10000).build());\n  ACTIVITIES\n    .try_get_with(activity_id, async {\n      Ok(Some(Arc::new(SentActivity::read(pool, activity_id).await?)))\n    })\n    .await\n    .map_err(|e: Arc<LemmyError>| anyhow::anyhow!(\"err getting activity: {e:?}\"))\n}\n\n/// return the most current activity id (with 1 second cache)\npub(crate) async fn get_latest_activity_id(pool: &mut DbPool<'_>) -> Result<Option<ActivityId>> {\n  static CACHE: LazyLock<Cache<(), Option<ActivityId>>> = LazyLock::new(|| {\n    Cache::builder()\n      .time_to_live(*CACHE_DURATION_LATEST_ID)\n      .build()\n  });\n  CACHE\n    .try_get_with((), async {\n      use lemmy_db_schema_file::schema::sent_activity::dsl::{id, sent_activity};\n      let conn = &mut get_conn(pool).await?;\n      let latest_id: Option<ActivityId> = sent_activity\n        .select(diesel::dsl::max(id))\n        .get_result(conn)\n        .await?;\n      anyhow::Result::<_, anyhow::Error>::Ok(latest_id)\n    })\n    .await\n    .map_err(|e| anyhow::anyhow!(\"err getting id: {e:?}\"))\n}\n\n/// the domain name is needed for logging, pass it to the stats printer so it doesn't need to look\n/// up the domain itself\n#[derive(Debug)]\npub(crate) struct FederationQueueStateWithDomain {\n  pub domain: String,\n  pub state: FederationQueueState,\n}\n"
  },
  {
    "path": "crates/apub/send/src/worker.rs",
    "content": "use crate::{\n  inboxes::RealCommunityInboxCollector,\n  send::{SendActivityResult, SendRetryTask, SendSuccessInfo},\n  util::{\n    FederationQueueStateWithDomain,\n    WORK_FINISHED_RECHECK_DELAY,\n    get_activity_cached,\n    get_latest_activity_id,\n  },\n};\nuse activitypub_federation::config::FederationConfig;\nuse anyhow::{Context, Result};\nuse chrono::{DateTime, Days, TimeZone, Utc};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  newtypes::ActivityId,\n  source::{\n    federation_queue_state::FederationQueueState,\n    instance::{Instance, InstanceForm},\n  },\n};\nuse lemmy_diesel_utils::connection::{ActualDbPool, DbPool};\nuse lemmy_utils::{\n  error::LemmyResult,\n  federate_retry_sleep_duration,\n  settings::structs::FederationWorkerConfig,\n};\nuse std::{cmp::max, collections::BinaryHeap, ops::Add, time::Duration};\nuse tokio::{\n  sync::mpsc::{self, UnboundedSender},\n  time::sleep,\n};\nuse tokio_util::sync::CancellationToken;\n\n/// Save state to db after this time has passed since the last state (so if the server crashes or is\n/// SIGKILLed, less than X seconds of activities are resent)\n#[cfg(not(test))]\nstatic SAVE_STATE_EVERY_TIME: Duration = Duration::from_secs(60);\n#[cfg(test)]\n/// in test mode, we want it to save state and send it to print_stats after every send\nstatic SAVE_STATE_EVERY_TIME: Duration = Duration::from_secs(0);\n/// Maximum number of successful sends to allow out of order\nconst MAX_SUCCESSFULS: usize = 1000;\n\n/// in prod mode, try to collect multiple send results at the same time to reduce load\n#[cfg(not(test))]\nconst MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE: usize = 4;\n#[cfg(test)]\nconst MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE: usize = 0;\n\n///\n/// SendManager --(has many)--> InstanceWorker --(has many)--> SendRetryTask\n///      |                            |                               |\n/// -----|------create worker -> loop activities--create task-> send activity\n///      |                            |                             vvvv\n///      |                            |                           fail or success\n///      |                            |           <-report result--   |\n///      |               <---order and aggrate results---             |\n///      |   <---send stats---        |                               |\n/// filter and print stats            |                               |\npub(crate) struct InstanceWorker {\n  instance: Instance,\n  stop: CancellationToken,\n  federation_lib_config: FederationConfig<LemmyContext>,\n  federation_worker_config: FederationWorkerConfig,\n  state: FederationQueueState,\n  last_state_insert: DateTime<Utc>,\n  pool: ActualDbPool,\n  inbox_collector: RealCommunityInboxCollector,\n  // regularily send stats back to the SendManager\n  stats_sender: UnboundedSender<FederationQueueStateWithDomain>,\n  // each HTTP send will report back to this channel concurrently\n  receive_send_result: mpsc::UnboundedReceiver<SendActivityResult>,\n  // this part of the channel is cloned and passed to the SendRetryTasks\n  report_send_result: mpsc::UnboundedSender<SendActivityResult>,\n  // activities that have been successfully sent but\n  // that are not the lowest number and thus can't be written to the database yet\n  successfuls: BinaryHeap<SendSuccessInfo>,\n  // number of activities that currently have a task spawned to send it\n  in_flight: i8,\n}\n\nimpl InstanceWorker {\n  pub(crate) async fn init_and_loop(\n    instance: Instance,\n    config: FederationConfig<LemmyContext>,\n    federation_worker_config: FederationWorkerConfig,\n    stop: CancellationToken,\n    stats_sender: UnboundedSender<FederationQueueStateWithDomain>,\n  ) -> LemmyResult<()> {\n    let pool = config.to_request_data().inner_pool().clone();\n    let state = FederationQueueState::load(&mut DbPool::Pool(&pool), instance.id).await?;\n    let (report_send_result, receive_send_result) =\n      tokio::sync::mpsc::unbounded_channel::<SendActivityResult>();\n    let mut worker = InstanceWorker {\n      inbox_collector: RealCommunityInboxCollector::new_real(\n        pool.clone(),\n        instance.id,\n        instance.domain.clone(),\n      ),\n      federation_worker_config,\n      instance,\n      stop,\n      federation_lib_config: config,\n      stats_sender,\n      state,\n      last_state_insert: Utc.timestamp_nanos(0),\n      pool,\n      receive_send_result,\n      report_send_result,\n      successfuls: BinaryHeap::<SendSuccessInfo>::new(),\n      in_flight: 0,\n    };\n\n    worker.loop_until_stopped().await\n  }\n  /// loop fetch new activities from db and send them to the inboxes of the given instances\n  /// this worker only returns if (a) there is an internal error or (b) the cancellation token is\n  /// cancelled (graceful exit)\n  async fn loop_until_stopped(&mut self) -> LemmyResult<()> {\n    self.initial_fail_sleep().await?;\n    let mut last_sent_id = self.get_last_sent_id().await?;\n\n    while !self.stop.is_cancelled() {\n      // check if we need to wait for a send to finish before sending the next one\n      // we wait if (a) the last request failed, only if a request is already in flight (not at the\n      // start of the loop) or (b) if we have too many successfuls in memory or (c) if we have\n      // too many in flight\n      let need_wait_for_event = (self.in_flight != 0 && self.state.fail_count > 0)\n        || self.successfuls.len() >= MAX_SUCCESSFULS\n        || self.in_flight >= self.federation_worker_config.concurrent_sends_per_instance;\n      if need_wait_for_event || self.receive_send_result.len() > MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE\n      {\n        // if len() > 0 then this does not block and allows us to write to db more often\n        // if len is 0 then this means we wait for something to change our above conditions,\n        // which can only happen by an event sent into the channel\n        self.handle_send_results().await?;\n        // handle_send_results does not guarantee that we are now in a condition where we want to\n        // send a new one, so repeat this check until the if no longer applies\n        continue;\n      }\n\n      // send a new activity if there is one\n      self.inbox_collector.update_communities().await?;\n      let next_id_to_send = ActivityId(last_sent_id.0 + 1);\n      let successfuls_len: i64 = self.successfuls.len().try_into()?;\n      {\n        // sanity check: calculate next id to send based on the last id and the in flight requests\n        let expected_next_id = self.state.last_successful_id.map(|last_successful_id| {\n          last_successful_id.0 + successfuls_len + i64::from(self.in_flight) + 1\n        });\n        // compare to next id based on incrementing\n        if expected_next_id != Some(next_id_to_send.0) {\n          return Err(\n            anyhow::anyhow!(\n              \"{}: next id to send is not as expected: {:?} != {:?}\",\n              self.instance.domain,\n              expected_next_id,\n              next_id_to_send\n            )\n            .into(),\n          );\n        }\n      }\n\n      let newest_id_opt = get_latest_activity_id(&mut self.pool()).await?;\n      let newest_id = newest_id_opt.unwrap_or(ActivityId(0));\n      if next_id_to_send > newest_id {\n        // If next id to send for this instance is higher than the highest sent_activity table id\n        // there may be a problem and activities wont send.\n        // However this can occur normally if there was no outgoing activity for a week\n        // and sent_activity was completely emptied by scheduled task.\n        if newest_id_opt.is_some() && next_id_to_send > ActivityId(newest_id.0 + 1) {\n          tracing::error!(\n            \"{}: next send id {} is higher than latest id {}+1 in table sent_activity (did the db get cleared?)\",\n            self.instance.domain,\n            next_id_to_send.0,\n            newest_id.0\n          );\n        }\n        // no more work to be done, wait before rechecking\n        tokio::select! {\n          () = sleep(*WORK_FINISHED_RECHECK_DELAY) => {},\n          () = self.stop.cancelled() => {\n            tracing::debug!(\"cancelled worker loop while waiting for new work\")\n          }\n        }\n        continue;\n      }\n      self.in_flight += 1;\n      last_sent_id = next_id_to_send;\n      self.spawn_send_if_needed(next_id_to_send).await?;\n    }\n    tracing::debug!(\"cancelled worker loop after send\");\n\n    // final update of state in db on shutdown\n    self.save_and_send_state().await?;\n    Ok(())\n  }\n\n  async fn initial_fail_sleep(&mut self) -> Result<()> {\n    // before starting queue, sleep remaining duration if last request failed\n    if self.state.fail_count > 0 {\n      let last_retry = self\n        .state\n        .last_retry_at\n        .context(\"impossible: if fail count set last retry also set\")?;\n      let elapsed = (Utc::now() - last_retry).to_std()?;\n      let required = federate_retry_sleep_duration(self.state.fail_count);\n      if elapsed >= required {\n        return Ok(());\n      }\n      let remaining = required - elapsed;\n      tracing::debug!(\n        \"{}: fail-sleeping for {:?} before starting queue\",\n        self.instance.domain,\n        remaining\n      );\n      tokio::select! {\n        () = sleep(remaining) => {},\n        () = self.stop.cancelled() => {\n          tracing::debug!(\"cancelled worker loop during initial fail sleep\")\n        }\n      }\n    }\n    Ok(())\n  }\n\n  /// Return the last successfully sent id.\n  /// Sets last_successful_id in database if it's the first time this instance is seen.\n  async fn get_last_sent_id(&mut self) -> Result<ActivityId> {\n    let last = if let Some(last) = self.state.last_successful_id {\n      last\n    } else {\n      let latest_id = get_latest_activity_id(&mut self.pool())\n        .await?\n        .unwrap_or(ActivityId(0));\n      // this is the initial creation (instance first seen) of the federation queue for this\n      // instance\n\n      // skip all past activities:\n      self.state.last_successful_id = Some(latest_id);\n      // save here to ensure it's not read as 0 again later if no activities have happened\n      self.save_and_send_state().await?;\n      latest_id\n    };\n    Ok(last)\n  }\n\n  async fn handle_send_results(&mut self) -> Result<(), anyhow::Error> {\n    let mut force_write = false;\n    let mut events = Vec::new();\n    // Wait for at least one event but if there's multiple handle them all.\n    // We need to listen to the cancel event here as well in order to prevent a hang on shutdown:\n    // If the SendRetryTask gets cancelled, it immediately exits without reporting any state.\n    // So if the worker is waiting for a send result and all SendRetryTask gets cancelled, this recv\n    // could hang indefinitely otherwise. The tasks will also drop their handle of\n    // report_send_result which would cause the recv_many method to return 0 elements, but since\n    // InstanceWorker holds a copy of the send result channel as well, that won't happen.\n    tokio::select! {\n      _ = self.receive_send_result.recv_many(&mut events, 1000) => {},\n      () = self.stop.cancelled() => {\n        tracing::debug!(\"cancelled worker loop while waiting for send results\");\n        return Ok(());\n      }\n    }\n    for event in events {\n      match event {\n        SendActivityResult::Success(s) => {\n          self.in_flight -= 1;\n          if !s.was_skipped {\n            self.state.fail_count = max(0, self.state.fail_count - 1);\n            self.mark_instance_alive().await?;\n          }\n          self.successfuls.push(s);\n        }\n        SendActivityResult::Failure { fail_count, .. } => {\n          if fail_count > self.state.fail_count {\n            // override fail count - if multiple activities are currently sending this value may get\n            // conflicting info but that's fine.\n            // This needs to be this way, all alternatives would be worse. The reason is that if 10\n            // simultaneous requests fail within a 1s period, we don't want the next retry to be\n            // exponentially 2**10 s later. Any amount of failures within a fail-sleep period should\n            // only count as one failure.\n\n            self.state.fail_count = fail_count;\n            self.state.last_retry_at = Some(Utc::now());\n            force_write = true;\n          }\n        }\n      }\n    }\n    self.pop_successfuls_and_write(force_write).await?;\n    Ok(())\n  }\n  async fn mark_instance_alive(&mut self) -> Result<()> {\n    // Activity send successful, mark instance as alive if it hasn't been updated in a while.\n    let updated = self\n      .instance\n      .updated_at\n      .unwrap_or(self.instance.published_at);\n    if updated.add(Days::new(1)) < Utc::now() {\n      self.instance.updated_at = Some(Utc::now());\n\n      let form = InstanceForm {\n        updated_at: Some(Utc::now()),\n        ..InstanceForm::new(self.instance.domain.clone())\n      };\n      Instance::update(&mut self.pool(), self.instance.id, form)\n        .await\n        .map_err(|e| anyhow::anyhow!(e))?;\n    }\n    Ok(())\n  }\n  /// Checks that sequential activities `last_successful_id + 1`, `last_successful_id + 2` etc have\n  /// been sent successfully. In that case updates `last_successful_id` and saves the state to the\n  /// database if the time since the last save is greater than `SAVE_STATE_EVERY_TIME`.\n  async fn pop_successfuls_and_write(&mut self, force_write: bool) -> Result<()> {\n    let Some(mut last_id) = self.state.last_successful_id else {\n      tracing::warn!(\n        \"{} should be impossible: last successful id is None\",\n        self.instance.domain\n      );\n      return Ok(());\n    };\n    tracing::debug!(\n      \"{} last: {:?}, next: {:?}, currently in successfuls: {:?}\",\n      self.instance.domain,\n      last_id,\n      self.successfuls.peek(),\n      self.successfuls.iter()\n    );\n    while self\n      .successfuls\n      .peek()\n      .map(|a| a.activity_id == ActivityId(last_id.0 + 1))\n      .unwrap_or(false)\n    {\n      let next = self\n        .successfuls\n        .pop()\n        .context(\"peek above ensures pop has value\")?;\n      last_id = next.activity_id;\n      self.state.last_successful_id = Some(next.activity_id);\n      self.state.last_successful_published_time_at = next.published_at;\n    }\n\n    let save_state_every = chrono::Duration::from_std(SAVE_STATE_EVERY_TIME)?;\n    if force_write || (Utc::now() - self.last_state_insert) > save_state_every {\n      self.save_and_send_state().await?;\n    }\n    Ok(())\n  }\n\n  /// we collect the relevant inboxes in the main instance worker task, and only spawn the send task\n  /// if we have inboxes to send to this limits CPU usage and reduces overhead for the (many)\n  /// cases where we don't have any inboxes\n  async fn spawn_send_if_needed(&mut self, activity_id: ActivityId) -> LemmyResult<()> {\n    let Ok(Some(ele)) = get_activity_cached(&mut self.pool(), activity_id).await else {\n      tracing::debug!(\"{}: {:?} does not exist\", self.instance.domain, activity_id);\n      self\n        .report_send_result\n        .send(SendActivityResult::Success(SendSuccessInfo {\n          activity_id,\n          published_at: None,\n          was_skipped: true,\n        }))?;\n      return Ok(());\n    };\n    let activity = &ele;\n    let inbox_urls = self.inbox_collector.get_inbox_urls(activity).await?;\n    if inbox_urls.is_empty() {\n      // this is the case when the activity is not relevant to this receiving instance (e.g. no user\n      // subscribed to the relevant community)\n      tracing::debug!(\"{}: {:?} no inboxes\", self.instance.domain, activity.id);\n      self\n        .report_send_result\n        .send(SendActivityResult::Success(SendSuccessInfo {\n          activity_id,\n          // it would be valid here to either return None or Some(activity.published). The published\n          // time is only used for stats pages that track federation delay. None can be a bit\n          // misleading because if you look at / chart the published time for federation from a\n          // large to a small instance that's only subscribed to a few small communities,\n          // then it will show the last published time as a days ago even though\n          // federation is up to date.\n          published_at: Some(activity.published_at),\n          was_skipped: true,\n        }))?;\n      return Ok(());\n    }\n    let initial_fail_count = self.state.fail_count;\n    let data = self.federation_lib_config.to_request_data();\n    let stop = self.stop.clone();\n    let domain = self.instance.domain.clone();\n    let mut report = self.report_send_result.clone();\n    tokio::spawn(async move {\n      let res = SendRetryTask {\n        activity: &ele,\n        object: &ele.data,\n        inbox_urls,\n        report: &mut report,\n        initial_fail_count,\n        domain,\n        context: data,\n        stop,\n      }\n      .send_retry_loop()\n      .await;\n      if let Err(e) = res {\n        tracing::warn!(\n          \"sending {} errored internally, skipping activity: {:?}\",\n          ele.ap_id,\n          e\n        );\n        // An error in this location means there is some deeper internal issue with the activity,\n        // for example the actor can't be loaded or similar. These issues are probably not\n        // solveable by retrying and would cause the federation for this instance to permanently be\n        // stuck in a retry loop. So we log the error and skip the activity (by reporting success to\n        // the worker)\n        report\n          .send(SendActivityResult::Success(SendSuccessInfo {\n            activity_id,\n            published_at: None,\n            was_skipped: true,\n          }))\n          .ok();\n      }\n    });\n    Ok(())\n  }\n\n  async fn save_and_send_state(&mut self) -> Result<()> {\n    tracing::debug!(\"{}: saving and sending state\", self.instance.domain);\n    self.last_state_insert = Utc::now();\n    FederationQueueState::upsert(&mut self.pool(), &self.state)\n      .await\n      .map_err(|e| anyhow::anyhow!(e))?;\n    self.stats_sender.send(FederationQueueStateWithDomain {\n      state: self.state.clone(),\n      domain: self.instance.domain.clone(),\n    })?;\n    Ok(())\n  }\n\n  fn pool(&self) -> DbPool<'_> {\n    DbPool::Pool(&self.pool)\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::unwrap_used)]\n#[expect(clippy::indexing_slicing)]\nmod test {\n\n  use super::*;\n  use activitypub_federation::{\n    http_signatures::generate_actor_keypair,\n    protocol::context::WithContext,\n  };\n  use actix_web::{App, HttpResponse, HttpServer, dev::ServerHandle, web};\n  use futures::future::try_join_all;\n  use lemmy_api_utils::utils::generate_inbox_url;\n  use lemmy_db_schema::source::{\n    activity::{SentActivity, SentActivityForm},\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_db_schema_file::enums::ActorType;\n  use lemmy_diesel_utils::{dburl::DbUrl, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use serde_json::{Value, json};\n  use serial_test::serial;\n  use std::sync::{Arc, RwLock};\n  use test_context::{AsyncTestContext, test_context};\n  use tokio::{\n    spawn,\n    sync::mpsc::{UnboundedReceiver, error::TryRecvError, unbounded_channel},\n  };\n  use tracing_test::traced_test;\n  use url::Url;\n\n  struct Data {\n    context: activitypub_federation::config::Data<LemmyContext>,\n    instance: Instance,\n    person: Person,\n    stats_receiver: UnboundedReceiver<FederationQueueStateWithDomain>,\n    inbox_receiver: UnboundedReceiver<String>,\n    cancel: CancellationToken,\n    cleaned_up: bool,\n    wait_stop_server: ServerHandle,\n    is_concurrent: bool,\n    respond_with_error: Arc<RwLock<bool>>,\n  }\n\n  impl Data {\n    async fn init() -> LemmyResult<Self> {\n      let context = LemmyContext::init_test_federation_config().await;\n      let instance = Instance::read_or_create(&mut context.pool(), \"localhost\").await?;\n\n      let actor_keypair = generate_actor_keypair()?;\n      let ap_id: DbUrl = Url::parse(\"http://local.com/u/alice\")?.into();\n      let person_form = PersonInsertForm {\n        ap_id: Some(ap_id.clone()),\n        private_key: (Some(actor_keypair.private_key)),\n        inbox_url: Some(generate_inbox_url()?),\n        ..PersonInsertForm::new(\"alice\".to_string(), actor_keypair.public_key, instance.id)\n      };\n      let person = Person::create(&mut context.pool(), &person_form).await?;\n\n      let cancel = CancellationToken::new();\n      let (stats_sender, stats_receiver) = unbounded_channel();\n      let (inbox_sender, inbox_receiver) = unbounded_channel();\n\n      // listen for received activities in background\n      let respond_with_error = Arc::new(RwLock::new(false));\n      let wait_stop_server = listen_activities(inbox_sender, respond_with_error.clone())?;\n\n      let concurrent_sends_per_instance = std::env::var(\"LEMMY_TEST_FEDERATION_CONCURRENT_SENDS\")\n        .ok()\n        .and_then(|s| s.parse().ok())\n        .unwrap_or(10);\n\n      let fed_config = FederationWorkerConfig {\n        concurrent_sends_per_instance,\n      };\n      spawn(InstanceWorker::init_and_loop(\n        instance.clone(),\n        context.clone(),\n        fed_config,\n        cancel.clone(),\n        stats_sender,\n      ));\n      // wait for startup\n      sleep(*WORK_FINISHED_RECHECK_DELAY).await;\n\n      Ok(Self {\n        context: context.to_request_data(),\n        instance,\n        person,\n        stats_receiver,\n        inbox_receiver,\n        cancel,\n        wait_stop_server,\n        cleaned_up: false,\n        is_concurrent: concurrent_sends_per_instance > 1,\n        respond_with_error,\n      })\n    }\n\n    async fn cleanup(&mut self) -> LemmyResult<()> {\n      if self.cleaned_up {\n        return Ok(());\n      }\n      self.cleaned_up = true;\n      self.cancel.cancel();\n      sleep(*WORK_FINISHED_RECHECK_DELAY).await;\n      Instance::delete_all(&mut self.context.pool()).await?;\n      Person::delete(&mut self.context.pool(), self.person.id).await?;\n      self.wait_stop_server.stop(true).await;\n      Ok(())\n    }\n  }\n\n  /// In order to guarantee that the webserver is stopped via the cleanup function,\n  /// we implement a test context.\n  impl AsyncTestContext for Data {\n    async fn setup() -> Data {\n      Data::init().await.unwrap()\n    }\n    async fn teardown(mut self) {\n      self.cleanup().await.unwrap()\n    }\n  }\n\n  #[test_context(Data)]\n  #[tokio::test]\n  #[traced_test]\n  #[serial]\n  async fn test_stats(data: &mut Data) -> LemmyResult<()> {\n    tracing::debug!(\"hello world\");\n\n    // first receive at startup\n    let rcv = data.stats_receiver.recv().await.unwrap();\n    tracing::debug!(\"received first stats\");\n    assert_eq!(data.instance.id, rcv.state.instance_id);\n\n    let sent = send_activity(data.person.ap_id.clone(), &data.context, true).await?;\n    tracing::debug!(\"sent activity\");\n    // receive for successfully sent activity\n    let inbox_rcv = data.inbox_receiver.recv().await.unwrap();\n    let parsed_activity = serde_json::from_str::<WithContext<Value>>(&inbox_rcv)?;\n    assert_eq!(&sent.data, parsed_activity.inner());\n    tracing::debug!(\"received activity\");\n\n    let rcv = data.stats_receiver.recv().await.unwrap();\n    assert_eq!(data.instance.id, rcv.state.instance_id);\n    assert_eq!(Some(sent.id), rcv.state.last_successful_id);\n    tracing::debug!(\"received second stats\");\n\n    data.cleanup().await?;\n\n    // it also sends state on shutdown\n    let rcv = data.stats_receiver.try_recv();\n    assert!(rcv.is_ok());\n\n    // nothing further received\n    let rcv = data.stats_receiver.try_recv();\n    assert_eq!(Some(TryRecvError::Disconnected), rcv.err());\n    let inbox_rcv = data.inbox_receiver.try_recv();\n    assert_eq!(Some(TryRecvError::Disconnected), inbox_rcv.err());\n\n    Ok(())\n  }\n\n  #[test_context(Data)]\n  #[tokio::test]\n  #[traced_test]\n  #[serial]\n  async fn test_send_40(data: &mut Data) -> LemmyResult<()> {\n    tracing::debug!(\"hello world\");\n\n    // first receive at startup\n    let rcv = data.stats_receiver.recv().await.unwrap();\n    tracing::debug!(\"received first stats\");\n    assert_eq!(data.instance.id, rcv.state.instance_id);\n    // assert_eq!(Some(ActivityId(0)), rcv.state.last_successful_id);\n    // let last_id_before = rcv.state.last_successful_id.unwrap();\n    let mut sent = vec![];\n    for _ in 0..40 {\n      sent.push(send_activity(data.person.ap_id.clone(), &data.context, false).await?);\n    }\n    sleep(2 * *WORK_FINISHED_RECHECK_DELAY).await;\n    tracing::debug!(\"sent activity\");\n    compare_sent_with_receive(data, sent).await?;\n\n    Ok(())\n  }\n\n  #[test_context(Data)]\n  #[tokio::test]\n  #[traced_test]\n  #[serial]\n  /// this test sends 15 activities, waits and checks they have all been received, then sends 50,\n  /// etc\n  async fn test_send_15_20_30(data: &mut Data) -> LemmyResult<()> {\n    tracing::debug!(\"hello world\");\n\n    // first receive at startup\n    let rcv = data.stats_receiver.recv().await.unwrap();\n    tracing::debug!(\"received first stats\");\n    assert_eq!(data.instance.id, rcv.state.instance_id);\n    // assert_eq!(Some(ActivityId(0)), rcv.state.last_successful_id);\n    // let last_id_before = rcv.state.last_successful_id.unwrap();\n    let counts = vec![15, 20, 35];\n    for count in counts {\n      tracing::debug!(\"sending {} activities\", count);\n      let sent = try_join_all(\n        (0..count).map(|_| send_activity(data.person.ap_id.clone(), &data.context, false)),\n      )\n      .await?;\n      sleep(2 * *WORK_FINISHED_RECHECK_DELAY).await;\n      tracing::debug!(\"sent activity\");\n      compare_sent_with_receive(data, sent).await?;\n    }\n\n    Ok(())\n  }\n\n  #[test_context(Data)]\n  #[tokio::test]\n  #[serial]\n  async fn test_update_instance(data: &mut Data) -> LemmyResult<()> {\n    let form = InstanceForm::new(data.instance.domain.clone());\n    Instance::update(&mut data.context.pool(), data.instance.id, form).await?;\n\n    send_activity(data.person.ap_id.clone(), &data.context, true).await?;\n    data.inbox_receiver.recv().await.unwrap();\n\n    let instance =\n      Instance::read_or_create(&mut data.context.pool(), &data.instance.domain).await?;\n\n    assert!(instance.updated_at.is_some());\n\n    Ok(())\n  }\n\n  #[test_context(Data)]\n  #[tokio::test]\n  #[serial]\n  async fn test_errors(data: &mut Data) -> LemmyResult<()> {\n    let form = InstanceForm::new(data.instance.domain.clone());\n    Instance::update(&mut data.context.pool(), data.instance.id, form).await?;\n\n    // check initial state\n    let rcv = data.stats_receiver.recv().await.unwrap();\n    assert_eq!(0, rcv.state.fail_count);\n    assert_eq!(data.instance.id, rcv.state.instance_id);\n\n    // set receiver to return error for all inbox requests\n    *data.respond_with_error.write().unwrap() = true;\n\n    // send a few activities\n    try_join_all((0..5).map(|_| send_activity(data.person.ap_id.clone(), &data.context, false)))\n      .await?;\n\n    // it immediately performs first retry giving us 2 failures\n    wait_receive(2, &mut data.stats_receiver).await;\n\n    // another automatic retry after short wait\n    wait_receive(3, &mut data.stats_receiver).await;\n\n    // now make sends successful\n    *data.respond_with_error.write().unwrap() = false;\n\n    // fail count goes back to 0\n    wait_receive(0, &mut data.stats_receiver).await;\n\n    Ok(())\n  }\n\n  async fn wait_receive(\n    expected_fail_count: i32,\n    rec: &mut UnboundedReceiver<FederationQueueStateWithDomain>,\n  ) {\n    // loop until we get the latest event\n    for _ in 0..5 {\n      let rcv = rec.recv().await.unwrap();\n      if expected_fail_count == rcv.state.fail_count {\n        return;\n      }\n    }\n    panic!();\n  }\n\n  fn listen_activities(\n    inbox_sender: UnboundedSender<String>,\n    respond_with_error: Arc<RwLock<bool>>,\n  ) -> LemmyResult<ServerHandle> {\n    let run = HttpServer::new(move || {\n      App::new()\n        .app_data(actix_web::web::Data::new(inbox_sender.clone()))\n        .app_data(actix_web::web::Data::new(respond_with_error.clone()))\n        .route(\n          \"/inbox\",\n          web::post().to(\n            move |inbox_sender: actix_web::web::Data<UnboundedSender<String>>,\n                  respond_with_error: actix_web::web::Data<Arc<RwLock<bool>>>,\n                  body: String| async move {\n              tracing::debug!(\"received activity: {:?}\", body);\n              inbox_sender.send(body.clone()).unwrap();\n              if *respond_with_error.read().unwrap() {\n                HttpResponse::new(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)\n              } else {\n                HttpResponse::new(actix_web::http::StatusCode::OK)\n              }\n            },\n          ),\n        )\n    })\n    .bind((\"127.0.0.1\", 8085))?\n    .run();\n    let handle = run.handle();\n    tokio::spawn(async move {\n      run.await.unwrap();\n      /*select! {\n        _ = run => {},\n        _ = cancel.cancelled() => { }\n      }*/\n    });\n    Ok(handle)\n  }\n\n  async fn send_activity(\n    ap_id: DbUrl,\n    context: &LemmyContext,\n    wait: bool,\n  ) -> LemmyResult<SentActivity> {\n    // create outgoing activity\n    let id = format!(\n      \"http://ds9.lemmy.ml/activities/like/{}\",\n      uuid::Uuid::new_v4()\n    );\n    let data = json!({\n      \"actor\": \"http://ds9.lemmy.ml/u/lemmy_alpha\",\n      \"object\": \"http://ds9.lemmy.ml/comment/1\",\n      \"type\": \"Like\",\n      \"id\": id,\n    });\n    let form = SentActivityForm {\n      ap_id: Url::parse(&id)?.into(),\n      data,\n      sensitive: false,\n      send_inboxes: vec![Some(Url::parse(\"http://localhost:8085/inbox\")?.into())],\n      send_all_instances: false,\n      send_community_followers_of: None,\n      actor_type: ActorType::Person,\n      actor_apub_id: ap_id,\n    };\n    let sent = SentActivity::create(&mut context.pool(), form).await?;\n\n    if wait {\n      sleep(*WORK_FINISHED_RECHECK_DELAY * 2).await;\n    }\n\n    Ok(sent)\n  }\n  async fn compare_sent_with_receive(data: &mut Data, mut sent: Vec<SentActivity>) -> Result<()> {\n    assert!(!sent.is_empty());\n    let check_order = !data.is_concurrent; // allow out-of order receiving when running parallel\n    let mut received = Vec::new();\n    for _ in 0..sent.len() {\n      let inbox_rcv = data.inbox_receiver.recv().await.unwrap();\n      let parsed_activity = serde_json::from_str::<WithContext<Value>>(&inbox_rcv)?;\n      received.push(parsed_activity);\n    }\n    if !check_order {\n      // sort by id\n      received.sort_by(|a, b| {\n        a.inner()[\"id\"]\n          .as_str()\n          .unwrap()\n          .cmp(b.inner()[\"id\"].as_str().unwrap())\n      });\n      sent.sort_by(|a, b| {\n        a.data[\"id\"]\n          .as_str()\n          .unwrap()\n          .cmp(b.data[\"id\"].as_str().unwrap())\n      });\n    }\n    // receive for successfully sent activity\n    for i in 0..sent.len() {\n      let sent_activity = &sent[i];\n      let received_activity = received[i].inner();\n      assert_eq!(&sent_activity.data, received_activity);\n      tracing::debug!(\"received activity\");\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_schema\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_db_schema\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils/full\",\n  \"diesel\",\n  \"diesel-derive-newtype\",\n  \"bcrypt\",\n  \"lemmy_utils\",\n  \"serde_json\",\n  \"diesel_ltree\",\n  \"diesel-async\",\n  \"diesel-uplete\",\n  \"tokio\",\n  \"i-love-jesus\",\n  \"moka\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\"]\n\n[dependencies]\nchrono = { workspace = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nurl = { workspace = true }\nstrum = { workspace = true }\nserde_json = { workspace = true, optional = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nbcrypt = { workspace = true, optional = true }\ndiesel = { workspace = true, optional = true }\ndiesel-derive-newtype = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\ndiesel-uplete = { workspace = true, optional = true }\ndiesel_ltree = { workspace = true, optional = true }\nts-rs = { workspace = true, optional = true }\ntokio = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nderive-new.workspace = true\nmoka = { workspace = true, optional = true }\n\n\n[dev-dependencies]\nserial_test = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_schema/src/impls/activity.rs",
    "content": "use crate::{\n  diesel::OptionalExtension,\n  newtypes::ActivityId,\n  source::activity::{ReceivedActivity, SentActivity, SentActivityForm},\n};\nuse diesel::{ExpressionMethods, QueryDsl, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl SentActivity {\n  pub async fn create(pool: &mut DbPool<'_>, form: SentActivityForm) -> LemmyResult<Self> {\n    use lemmy_db_schema_file::schema::sent_activity::dsl::sent_activity;\n    let conn = &mut get_conn(pool).await?;\n    insert_into(sent_activity)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn read_from_apub_id(pool: &mut DbPool<'_>, object_id: &DbUrl) -> LemmyResult<Self> {\n    use lemmy_db_schema_file::schema::sent_activity::dsl::{ap_id, sent_activity};\n    let conn = &mut get_conn(pool).await?;\n    sent_activity\n      .filter(ap_id.eq(object_id))\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n  pub async fn read(pool: &mut DbPool<'_>, object_id: ActivityId) -> LemmyResult<Self> {\n    use lemmy_db_schema_file::schema::sent_activity::dsl::sent_activity;\n    let conn = &mut get_conn(pool).await?;\n    sent_activity\n      .find(object_id)\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl ReceivedActivity {\n  pub async fn create(pool: &mut DbPool<'_>, ap_id_: &DbUrl) -> LemmyResult<()> {\n    use lemmy_db_schema_file::schema::received_activity::dsl::{ap_id, received_activity};\n    let conn = &mut get_conn(pool).await?;\n    let rows_affected = insert_into(received_activity)\n      .values(ap_id.eq(ap_id_))\n      .on_conflict_do_nothing()\n      .execute(conn)\n      .await\n      .optional()?;\n    if rows_affected == Some(1) {\n      // new activity inserted successfully\n      Ok(())\n    } else {\n      Err(LemmyErrorType::CouldntCreate.into())\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use lemmy_db_schema_file::enums::ActorType;\n  use lemmy_diesel_utils::connection::build_db_pool_for_tests;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serde_json::json;\n  use serial_test::serial;\n  use url::Url;\n\n  #[tokio::test]\n  #[serial]\n  async fn receive_activity_duplicate() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let ap_id: DbUrl = Url::parse(\"http://example.com/activity/531\")?.into();\n\n    // inserting activity should only work once\n    ReceivedActivity::create(pool, &ap_id).await?;\n    let second = ReceivedActivity::create(pool, &ap_id).await;\n    assert!(second.is_err());\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn sent_activity_write_read() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let ap_id: DbUrl = Url::parse(\"http://example.com/activity/412\")?.into();\n    let data = json!({\n        \"key1\": \"0xF9BA143B95FF6D82\",\n        \"key2\": \"42\",\n    });\n    let sensitive = false;\n\n    let form = SentActivityForm {\n      ap_id: ap_id.clone(),\n      data: data.clone(),\n      sensitive,\n      actor_apub_id: Url::parse(\"http://example.com/u/exampleuser\")?.into(),\n      actor_type: ActorType::Person,\n      send_all_instances: false,\n      send_community_followers_of: None,\n      send_inboxes: vec![],\n    };\n\n    SentActivity::create(pool, form).await?;\n\n    let res = SentActivity::read_from_apub_id(pool, &ap_id).await?;\n    assert_eq!(res.ap_id, ap_id);\n    assert_eq!(res.data, data);\n    assert_eq!(res.sensitive, sensitive);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/actor_language.rs",
    "content": "use crate::{\n  diesel::JoinOnDsl,\n  newtypes::{CommunityId, LanguageId, LocalUserId, SiteId},\n  source::{\n    actor_language::{\n      CommunityLanguage,\n      CommunityLanguageForm,\n      LocalUserLanguage,\n      LocalUserLanguageForm,\n      SiteLanguage,\n      SiteLanguageForm,\n    },\n    language::Language,\n    site::Site,\n  },\n};\nuse diesel::{\n  ExpressionMethods,\n  QueryDsl,\n  delete,\n  dsl::{count, exists},\n  insert_into,\n  select,\n};\nuse diesel_async::{AsyncPgConnection, RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  schema::{community_language, local_site, local_user_language, site, site_language},\n};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse tokio::sync::OnceCell;\n\npub const UNDETERMINED_ID: LanguageId = LanguageId(0);\n\nimpl LocalUserLanguage {\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    for_local_user_id: LocalUserId,\n  ) -> LemmyResult<Vec<LanguageId>> {\n    let conn = &mut get_conn(pool).await?;\n\n    let langs = local_user_language::table\n      .filter(local_user_language::local_user_id.eq(for_local_user_id))\n      .order(local_user_language::language_id)\n      .select(local_user_language::language_id)\n      .get_results(conn)\n      .await?;\n    convert_read_languages(conn, langs).await\n  }\n\n  /// Update the user's languages.\n  ///\n  /// If no language_id vector is given, it will show all languages\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    language_ids: Vec<LanguageId>,\n    for_local_user_id: LocalUserId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    let lang_ids = convert_update_languages(conn, language_ids).await?;\n\n    // No need to update if languages are unchanged\n    let current = LocalUserLanguage::read(&mut conn.into(), for_local_user_id).await?;\n    if current == lang_ids {\n      return Ok(0);\n    }\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          // Delete old languages, not including new languages\n          delete(local_user_language::table)\n            .filter(local_user_language::local_user_id.eq(for_local_user_id))\n            .filter(local_user_language::language_id.ne_all(&lang_ids))\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n          let forms = lang_ids\n            .iter()\n            .map(|&l| LocalUserLanguageForm {\n              local_user_id: for_local_user_id,\n              language_id: l,\n            })\n            .collect::<Vec<_>>();\n\n          // Insert new languages\n          insert_into(local_user_language::table)\n            .values(forms)\n            .on_conflict((\n              local_user_language::language_id,\n              local_user_language::local_user_id,\n            ))\n            .do_nothing()\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n}\n\nimpl SiteLanguage {\n  pub async fn read_local_raw(pool: &mut DbPool<'_>) -> LemmyResult<Vec<LanguageId>> {\n    let conn = &mut get_conn(pool).await?;\n    site::table\n      .inner_join(local_site::table)\n      .inner_join(site_language::table)\n      .order(site_language::language_id)\n      .select(site_language::language_id)\n      .load(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read(pool: &mut DbPool<'_>, for_site_id: SiteId) -> LemmyResult<Vec<LanguageId>> {\n    let conn = &mut get_conn(pool).await?;\n    let langs = site_language::table\n      .filter(site_language::site_id.eq(for_site_id))\n      .order(site_language::language_id)\n      .select(site_language::language_id)\n      .load(conn)\n      .await?;\n\n    convert_read_languages(conn, langs).await\n  }\n\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    language_ids: Vec<LanguageId>,\n    site: &Site,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let for_site_id = site.id;\n    let instance_id = site.instance_id;\n    let lang_ids = convert_update_languages(conn, language_ids).await?;\n\n    // No need to update if languages are unchanged\n    let current = SiteLanguage::read(&mut conn.into(), site.id).await?;\n    if current == lang_ids {\n      return Ok(());\n    }\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          // Delete old languages, not including new languages\n          delete(site_language::table)\n            .filter(site_language::site_id.eq(for_site_id))\n            .filter(site_language::language_id.ne_all(&lang_ids))\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n          let forms = lang_ids\n            .iter()\n            .map(|&l| SiteLanguageForm {\n              site_id: for_site_id,\n              language_id: l,\n            })\n            .collect::<Vec<_>>();\n\n          // Insert new languages\n          insert_into(site_language::table)\n            .values(forms)\n            .on_conflict((site_language::site_id, site_language::language_id))\n            .do_nothing()\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n          CommunityLanguage::limit_languages(conn, instance_id).await?;\n\n          Ok(())\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n}\n\nimpl CommunityLanguage {\n  /// Returns true if the given language is one of configured languages for given community\n  async fn is_allowed_community_language(\n    pool: &mut DbPool<'_>,\n    for_language_id: LanguageId,\n    for_community_id: CommunityId,\n  ) -> LemmyResult<()> {\n    use lemmy_db_schema_file::schema::community_language::dsl::community_language;\n    let conn = &mut get_conn(pool).await?;\n\n    let is_allowed = select(exists(\n      community_language.find((for_community_id, for_language_id)),\n    ))\n    .get_result(conn)\n    .await?;\n\n    if is_allowed {\n      Ok(())\n    } else {\n      Err(LemmyErrorType::LanguageNotAllowed.into())\n    }\n  }\n\n  /// When site languages are updated, delete all languages of local communities which are not\n  /// also part of site languages. This is because post/comment language is only checked against\n  /// community language, and it shouldnt be possible to post content in languages which are not\n  /// allowed by local site.\n  async fn limit_languages(\n    conn: &mut AsyncPgConnection,\n    for_instance_id: InstanceId,\n  ) -> LemmyResult<()> {\n    use lemmy_db_schema_file::schema::{\n      community::dsl as c,\n      community_language::dsl as cl,\n      site_language::dsl as sl,\n    };\n    let community_languages: Vec<LanguageId> = cl::community_language\n      .left_outer_join(sl::site_language.on(cl::language_id.eq(sl::language_id)))\n      .inner_join(c::community)\n      .filter(c::instance_id.eq(for_instance_id))\n      .filter(sl::language_id.is_null())\n      .select(cl::language_id)\n      .get_results(conn)\n      .await?;\n\n    for c in community_languages {\n      delete(cl::community_language.filter(cl::language_id.eq(c)))\n        .execute(conn)\n        .await?;\n    }\n    Ok(())\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    for_community_id: CommunityId,\n  ) -> LemmyResult<Vec<LanguageId>> {\n    use lemmy_db_schema_file::schema::community_language::dsl::{\n      community_id,\n      community_language,\n      language_id,\n    };\n    let conn = &mut get_conn(pool).await?;\n    let langs = community_language\n      .filter(community_id.eq(for_community_id))\n      .order(language_id)\n      .select(language_id)\n      .get_results(conn)\n      .await?;\n    convert_read_languages(conn, langs).await\n  }\n\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    mut language_ids: Vec<LanguageId>,\n    for_community_id: CommunityId,\n  ) -> LemmyResult<usize> {\n    if language_ids.is_empty() {\n      language_ids = SiteLanguage::read_local_raw(pool).await?;\n    }\n    let conn = &mut get_conn(pool).await?;\n    let lang_ids = convert_update_languages(conn, language_ids).await?;\n\n    // No need to update if languages are unchanged\n    let current = CommunityLanguage::read(&mut conn.into(), for_community_id).await?;\n    if current == lang_ids {\n      return Ok(0);\n    }\n\n    let form = lang_ids\n      .iter()\n      .map(|&language_id| CommunityLanguageForm {\n        community_id: for_community_id,\n        language_id,\n      })\n      .collect::<Vec<_>>();\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          // Delete old languages, not including new languages\n          delete(community_language::table)\n            .filter(community_language::community_id.eq(for_community_id))\n            .filter(community_language::language_id.ne_all(&lang_ids))\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n\n          // Insert new languages\n          insert_into(community_language::table)\n            .values(form)\n            .on_conflict((\n              community_language::community_id,\n              community_language::language_id,\n            ))\n            .do_nothing()\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n}\n\npub async fn validate_post_language(\n  pool: &mut DbPool<'_>,\n  language_id: Option<LanguageId>,\n  community_id: CommunityId,\n) -> LemmyResult<()> {\n  if let Some(language_id) = language_id {\n    CommunityLanguage::is_allowed_community_language(pool, language_id, community_id).await?;\n  }\n  Ok(())\n}\n\n/// If no language is given, set all languages\nasync fn convert_update_languages(\n  conn: &mut AsyncPgConnection,\n  language_ids: Vec<LanguageId>,\n) -> LemmyResult<Vec<LanguageId>> {\n  if language_ids.is_empty() {\n    Ok(\n      Language::read_all(&mut conn.into())\n        .await?\n        .into_iter()\n        .map(|l| l.id)\n        .collect(),\n    )\n  } else {\n    Ok(language_ids)\n  }\n}\n\n/// If all languages are returned, return empty vec instead\n#[expect(clippy::expect_used)]\nasync fn convert_read_languages(\n  conn: &mut AsyncPgConnection,\n  language_ids: Vec<LanguageId>,\n) -> LemmyResult<Vec<LanguageId>> {\n  static ALL_LANGUAGES_COUNT: OnceCell<i64> = OnceCell::const_new();\n  let count: usize = (*ALL_LANGUAGES_COUNT\n    .get_or_init(|| async {\n      use lemmy_db_schema_file::schema::language::dsl::{id, language};\n      let count: i64 = language\n        .select(count(id))\n        .first(conn)\n        .await\n        .expect(\"read number of languages\");\n      count\n    })\n    .await)\n    .try_into()?;\n\n  if language_ids.len() == count {\n    Ok(vec![])\n  } else {\n    Ok(language_ids)\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use super::*;\n  use crate::{\n    source::{\n      community::{Community, CommunityInsertForm},\n      local_site::LocalSite,\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n    },\n    test_data::TestData,\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  async fn test_langs1(pool: &mut DbPool<'_>) -> LemmyResult<Vec<LanguageId>> {\n    Ok(vec![\n      Language::read_id_from_code(pool, \"en\").await?,\n      Language::read_id_from_code(pool, \"fr\").await?,\n      Language::read_id_from_code(pool, \"ru\").await?,\n    ])\n  }\n  async fn test_langs2(pool: &mut DbPool<'_>) -> LemmyResult<Vec<LanguageId>> {\n    Ok(vec![\n      Language::read_id_from_code(pool, \"fi\").await?,\n      Language::read_id_from_code(pool, \"se\").await?,\n    ])\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_convert_update_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // call with empty vec, returns all languages\n    let conn = &mut get_conn(pool).await?;\n    let converted1 = convert_update_languages(conn, vec![]).await?;\n    assert_eq!(184, converted1.len());\n\n    // call with nonempty vec, returns same vec\n    let test_langs = test_langs1(&mut conn.into()).await?;\n    let converted2 = convert_update_languages(conn, test_langs.clone()).await?;\n    assert_eq!(test_langs, converted2);\n\n    Ok(())\n  }\n  #[tokio::test]\n  #[serial]\n  async fn test_convert_read_languages() -> LemmyResult<()> {\n    use lemmy_db_schema_file::schema::language::dsl::{id, language};\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // call with all languages, returns empty vec\n    let conn = &mut get_conn(pool).await?;\n    let all_langs = language.select(id).get_results(conn).await?;\n    let converted1: Vec<LanguageId> = convert_read_languages(conn, all_langs).await?;\n    assert_eq!(0, converted1.len());\n\n    // call with nonempty vec, returns same vec\n    let test_langs = test_langs1(&mut conn.into()).await?;\n    let converted2 = convert_read_languages(conn, test_langs.clone()).await?;\n    assert_eq!(test_langs, converted2);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_site_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let data = TestData::create(pool).await?;\n    let site_languages1 = SiteLanguage::read_local_raw(pool).await?;\n    // site is created with all languages\n    assert_eq!(184, site_languages1.len());\n\n    let test_langs = test_langs1(pool).await?;\n    SiteLanguage::update(pool, test_langs.clone(), &data.site).await?;\n\n    let site_languages2 = SiteLanguage::read_local_raw(pool).await?;\n    // after update, site only has new languages\n    assert_eq!(test_langs, site_languages2);\n\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_user_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let data = TestData::create(pool).await?;\n\n    let person_form = PersonInsertForm::test_form(data.instance.id, \"my test person\");\n    let person = Person::create(pool, &person_form).await?;\n    let local_user_form = LocalUserInsertForm::test_form(person.id);\n\n    let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?;\n    let local_user_langs1 = LocalUserLanguage::read(pool, local_user.id).await?;\n\n    // new user should be initialized with all languages\n    assert_eq!(0, local_user_langs1.len());\n\n    // update user languages\n    let test_langs2 = test_langs2(pool).await?;\n    LocalUserLanguage::update(pool, test_langs2, local_user.id).await?;\n    let local_user_langs2 = LocalUserLanguage::read(pool, local_user.id).await?;\n    assert_eq!(2, local_user_langs2.len());\n\n    Person::delete(pool, person.id).await?;\n    LocalUser::delete(pool, local_user.id).await?;\n    LocalSite::delete(pool).await?;\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_community_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = TestData::create(pool).await?;\n    let test_langs = test_langs1(pool).await?;\n    SiteLanguage::update(pool, test_langs.clone(), &data.site).await?;\n\n    let read_site_langs = SiteLanguage::read(pool, data.site.id).await?;\n    assert_eq!(test_langs, read_site_langs);\n\n    // Test the local ones are the same\n    let read_local_site_langs = SiteLanguage::read_local_raw(pool).await?;\n    assert_eq!(test_langs, read_local_site_langs);\n\n    let community_form = CommunityInsertForm::new(\n      data.instance.id,\n      \"test community\".to_string(),\n      \"test community\".to_string(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n    let community_langs1 = CommunityLanguage::read(pool, community.id).await?;\n\n    // community is initialized with site languages\n    assert_eq!(test_langs, community_langs1);\n\n    let allowed_lang1 =\n      CommunityLanguage::is_allowed_community_language(pool, test_langs[0], community.id).await;\n    assert!(allowed_lang1.is_ok());\n\n    let test_langs2 = test_langs2(pool).await?;\n    let allowed_lang2 =\n      CommunityLanguage::is_allowed_community_language(pool, test_langs2[0], community.id).await;\n    assert!(allowed_lang2.is_err());\n\n    // limit site languages to en, fi. after this, community languages should be updated to\n    // intersection of old languages (en, fr, ru) and (en, fi), which is only fi.\n    SiteLanguage::update(pool, vec![test_langs[0], test_langs2[0]], &data.site).await?;\n    let community_langs2 = CommunityLanguage::read(pool, community.id).await?;\n    assert_eq!(vec![test_langs[0]], community_langs2);\n\n    // update community languages to different ones\n    CommunityLanguage::update(pool, test_langs2.clone(), community.id).await?;\n    let community_langs3 = CommunityLanguage::read(pool, community.id).await?;\n    assert_eq!(test_langs2, community_langs3);\n\n    Community::delete(pool, community.id).await?;\n    LocalSite::delete(pool).await?;\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_validate_post_language() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = TestData::create(pool).await?;\n    let test_langs = test_langs1(pool).await?;\n    let test_langs2 = test_langs2(pool).await?;\n\n    let community_form = CommunityInsertForm::new(\n      data.instance.id,\n      \"test community\".to_string(),\n      \"test community\".to_string(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n    CommunityLanguage::update(pool, test_langs, community.id).await?;\n\n    let person_form = PersonInsertForm::test_form(data.instance.id, \"my test person\");\n    let person = Person::create(pool, &person_form).await?;\n    let local_user_form = LocalUserInsertForm::test_form(person.id);\n    let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?;\n    LocalUserLanguage::update(pool, test_langs2, local_user.id).await?;\n\n    let def1 = validate_post_language(pool, Some(LanguageId(2)), community.id).await;\n    assert_eq!(\n      Some(LemmyErrorType::LanguageNotAllowed),\n      def1.err().map(|e| e.error_type)\n    );\n\n    let ru = Language::read_id_from_code(pool, \"ru\").await?;\n    let test_langs3 = vec![\n      ru,\n      Language::read_id_from_code(pool, \"fi\").await?,\n      Language::read_id_from_code(pool, \"se\").await?,\n      UNDETERMINED_ID,\n    ];\n    LocalUserLanguage::update(pool, test_langs3, local_user.id).await?;\n\n    let def2 = validate_post_language(pool, None, community.id).await;\n    assert!(def2.is_ok());\n\n    Person::delete(pool, person.id).await?;\n    Community::delete(pool, community.id).await?;\n    LocalUser::delete(pool, local_user.id).await?;\n    LocalSite::delete(pool).await?;\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/comment.rs",
    "content": "use crate::{\n  diesel::{DecoratableTarget, OptionalExtension},\n  newtypes::{CommentId, CommunityId, PostId},\n  source::comment::{\n    Comment,\n    CommentActions,\n    CommentInsertForm,\n    CommentLikeForm,\n    CommentSavedForm,\n    CommentUpdateForm,\n  },\n  traits::{Likeable, Saveable},\n  utils::DELETED_REPLACEMENT_TEXT,\n};\nuse chrono::{DateTime, Utc};\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  QueryDsl,\n  dsl::{insert_into, not},\n  expression::SelectableHelper,\n  update,\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_ltree::{Ltree, dsl::LtreeExtensions};\nuse diesel_uplete::{UpleteCount, uplete};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  schema::{comment, comment_actions, community, post},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::{coalesce, hot_rank},\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nimpl Comment {\n  pub async fn permadelete_for_creator(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    diesel::update(comment::table.filter(comment::creator_id.eq(creator_id)))\n      .set((\n        comment::content.eq(DELETED_REPLACEMENT_TEXT),\n        comment::deleted.eq(true),\n        comment::updated_at.eq(Utc::now()),\n      ))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn update_removed_for_creator(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Comment>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(comment::table.filter(comment::creator_id.eq(creator_id)))\n      .set((\n        comment::removed.eq(removed),\n        comment::updated_at.eq(Utc::now()),\n      ))\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  /// Diesel can't update from join unfortunately, so you'll need to loop over these\n  async fn creator_comments_in_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    comment::table\n      .inner_join(post::table)\n      .filter(comment::creator_id.eq(creator_id))\n      .filter(post::community_id.eq(community_id))\n      .select(Self::as_select())\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Diesel can't update from join unfortunately, so you'll need to loop over these\n  async fn creator_comments_in_instance(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    instance_id: InstanceId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let community_join = community::table.on(post::community_id.eq(community::id));\n\n    comment::table\n      .inner_join(post::table)\n      .inner_join(community_join)\n      .filter(comment::creator_id.eq(creator_id))\n      .filter(community::instance_id.eq(instance_id))\n      .select(Self::as_select())\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update_removed_for_creator_and_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let comments = Self::creator_comments_in_community(pool, creator_id, community_id).await?;\n    let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect();\n\n    let conn = &mut get_conn(pool).await?;\n\n    update(comment::table)\n      .filter(comment::id.eq_any(comment_ids))\n      .set((\n        comment::removed.eq(removed),\n        comment::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await?;\n\n    Ok(comments)\n  }\n\n  pub async fn update_removed_for_creator_and_instance(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    instance_id: InstanceId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let comments = Self::creator_comments_in_instance(pool, creator_id, instance_id).await?;\n    let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect();\n    let conn = &mut get_conn(pool).await?;\n\n    update(comment::table)\n      .filter(comment::id.eq_any(comment_ids))\n      .set((\n        comment::removed.eq(removed),\n        comment::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await?;\n    Ok(comments)\n  }\n\n  #[expect(clippy::same_name_method)]\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    comment_form: &CommentInsertForm,\n    parent_path: Option<&Ltree>,\n  ) -> LemmyResult<Comment> {\n    Self::insert_apub(pool, None, comment_form, parent_path).await\n  }\n\n  pub async fn insert_apub(\n    pool: &mut DbPool<'_>,\n    timestamp: Option<DateTime<Utc>>,\n    comment_form: &CommentInsertForm,\n    parent_path: Option<&Ltree>,\n  ) -> LemmyResult<Comment> {\n    let conn = &mut get_conn(pool).await?;\n    let comment_form = (comment_form, parent_path.map(|p| comment::path.eq(p)));\n\n    if let Some(timestamp) = timestamp {\n      insert_into(comment::table)\n        .values(comment_form)\n        .on_conflict(comment::ap_id)\n        .filter_target(coalesce(comment::updated_at, comment::published_at).lt(timestamp))\n        .do_update()\n        .set(comment_form)\n        .get_result::<Self>(conn)\n        .await\n    } else {\n      insert_into(comment::table)\n        .values(comment_form)\n        .get_result::<Self>(conn)\n        .await\n    }\n    .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    comment::table\n      .filter(comment::ap_id.eq(object_id))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub fn parent_comment_id(&self) -> Option<CommentId> {\n    let mut ltree_split: Vec<&str> = self.path.0.split('.').collect();\n    ltree_split.remove(0); // The first is always 0\n    if ltree_split.len() > 1 {\n      let parent_comment_id = ltree_split.get(ltree_split.len() - 2);\n      let p = parent_comment_id?;\n      p.parse::<i32>().map(CommentId).ok()\n    } else {\n      None\n    }\n  }\n  pub async fn update_hot_rank(pool: &mut DbPool<'_>, comment_id: CommentId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    diesel::update(comment::table.find(comment_id))\n      .set(comment::hot_rank.eq(hot_rank(comment::score, comment::published_at)))\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  pub fn local_url(&self, settings: &Settings) -> LemmyResult<Url> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/comment/{}\", self.id))?)\n  }\n\n  /// The comment was created locally and sent back, indicating that the community accepted it\n  pub async fn set_not_pending(&self, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    if self.local && self.federation_pending {\n      let form = CommentUpdateForm {\n        federation_pending: Some(false),\n        ..Default::default()\n      };\n      Comment::update(pool, self.id, &form).await?;\n    }\n    Ok(())\n  }\n\n  /// Updates the locked field for a comment and all its children.\n  pub async fn update_locked_for_comment_and_children(\n    pool: &mut DbPool<'_>,\n    comment_path: &Ltree,\n    locked: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let form = CommentUpdateForm {\n      locked: Some(locked),\n      ..Default::default()\n    };\n    Self::update_comment_and_children(pool, comment_path, &form).await\n  }\n\n  /// Updates the removed field for a comment and all its children.\n  pub async fn update_removed_for_comment_and_children(\n    pool: &mut DbPool<'_>,\n    comment_path: &Ltree,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let form = CommentUpdateForm {\n      removed: Some(removed),\n      ..Default::default()\n    };\n    Self::update_comment_and_children(pool, comment_path, &form).await\n  }\n\n  /// A helper function to update comment and all its children.\n  ///\n  /// Don't expose so as to make sure you aren't overwriting data.\n  async fn update_comment_and_children(\n    pool: &mut DbPool<'_>,\n    comment_path: &Ltree,\n    form: &CommentUpdateForm,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(comment::table)\n      .filter(comment::path.contained_by(comment_path))\n      .set(form)\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  /// Update the remove field for all the comments under a post.\n  pub async fn update_removed_for_post(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(comment::table)\n      .filter(comment::post_id.eq(post_id))\n      .set((\n        comment::removed.eq(removed),\n        comment::updated_at.eq(Utc::now()),\n      ))\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn read_ap_ids_for_post(\n    post_id: PostId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<DbUrl>> {\n    let conn = &mut get_conn(pool).await?;\n    comment::table\n      .filter(comment::post_id.eq(post_id))\n      .filter(not(comment::deleted))\n      .filter(not(comment::removed))\n      .filter(not(comment::federation_pending))\n      .order_by(comment::id)\n      .select(comment::ap_id)\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl Crud for Comment {\n  type InsertForm = CommentInsertForm;\n  type UpdateForm = CommentUpdateForm;\n  type IdType = CommentId;\n\n  /// Use [[Comment::create]]\n  async fn create(_pool: &mut DbPool<'_>, _comment_form: &Self::InsertForm) -> LemmyResult<Self> {\n    Err(UntranslatedError::Unreachable.into())\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    comment_id: CommentId,\n    comment_form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(comment::table.find(comment_id))\n      .set(comment_form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Likeable for CommentActions {\n  type Form = CommentLikeForm;\n  type IdType = CommentId;\n\n  async fn like(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(comment_actions::table)\n      .values(form)\n      .on_conflict((comment_actions::comment_id, comment_actions::person_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn remove_all_likes(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(comment_actions::table.filter(comment_actions::person_id.eq(creator_id)))\n      .set_null(comment_actions::vote_is_upvote)\n      .set_null(comment_actions::voted_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn remove_likes_in_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n  ) -> LemmyResult<UpleteCount> {\n    let comments = Comment::creator_comments_in_community(pool, creator_id, community_id).await?;\n    let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect();\n\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(comment_actions::table.filter(comment_actions::comment_id.eq_any(comment_ids.clone())))\n      .set_null(comment_actions::vote_is_upvote)\n      .set_null(comment_actions::voted_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Saveable for CommentActions {\n  type Form = CommentSavedForm;\n  async fn save(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(comment_actions::table)\n      .values(form)\n      .on_conflict((comment_actions::comment_id, comment_actions::person_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  async fn unsave(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(comment_actions::table.find((form.person_id, form.comment_id)))\n      .set_null(comment_actions::saved_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CommentActions {\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    comment_id: CommentId,\n    person_id: PersonId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    comment_actions::table\n      .find((person_id, comment_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use crate::{\n    newtypes::LanguageId,\n    source::{\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n    },\n    traits::{Likeable, Saveable},\n    utils::RANK_DEFAULT,\n  };\n  use diesel_ltree::Ltree;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n  use url::Url;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"terry\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let expected_comment = Comment {\n      id: inserted_comment.id,\n      content: \"A test comment\".into(),\n      creator_id: inserted_person.id,\n      post_id: inserted_post.id,\n      removed: false,\n      deleted: false,\n      path: Ltree(format!(\"0.{}\", inserted_comment.id)),\n      published_at: inserted_comment.published_at,\n      updated_at: None,\n      ap_id: Url::parse(&format!(\n        \"https://lemmy-alpha/comment/{}\",\n        inserted_comment.id\n      ))?\n      .into(),\n      distinguished: false,\n      local: true,\n      language_id: LanguageId::default(),\n      child_count: 1,\n      controversy_rank: 0.0,\n      downvotes: 0,\n      upvotes: 1,\n      score: 1,\n      hot_rank: RANK_DEFAULT,\n      report_count: 0,\n      unresolved_report_count: 0,\n      federation_pending: false,\n      locked: false,\n    };\n\n    let child_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A child comment\".into(),\n    );\n    let inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    // Comment Like\n    let comment_like_form =\n      CommentLikeForm::new(inserted_comment.id, inserted_person.id, Some(true));\n\n    let inserted_comment_like = CommentActions::like(pool, &comment_like_form).await?;\n    assert_eq!(Some(true), inserted_comment_like.vote_is_upvote);\n\n    // Comment Saved\n    let comment_saved_form = CommentSavedForm::new(inserted_person.id, inserted_comment.id);\n    let inserted_comment_saved = CommentActions::save(pool, &comment_saved_form).await?;\n    assert!(inserted_comment_saved.saved_at.is_some());\n\n    let comment_update_form = CommentUpdateForm {\n      content: Some(\"A test comment\".into()),\n      ..Default::default()\n    };\n\n    let updated_comment = Comment::update(pool, inserted_comment.id, &comment_update_form).await?;\n\n    let read_comment = Comment::read(pool, inserted_comment.id).await?;\n    let form = CommentLikeForm::new(inserted_comment.id, inserted_person.id, None);\n    CommentActions::like(pool, &form).await?;\n    let saved_removed = CommentActions::unsave(pool, &comment_saved_form).await?;\n    let num_deleted = Comment::delete(pool, inserted_comment.id).await?;\n    Comment::delete(pool, inserted_child_comment.id).await?;\n    Post::delete(pool, inserted_post.id).await?;\n    Community::delete(pool, inserted_community.id).await?;\n    Person::delete(pool, inserted_person.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    assert_eq!(expected_comment, read_comment);\n    assert_eq!(expected_comment, updated_comment);\n    assert_eq!(\n      format!(\"0.{}.{}\", expected_comment.id, inserted_child_comment.id),\n      inserted_child_comment.path.0,\n    );\n    assert_eq!(UpleteCount::only_deleted(1), saved_removed);\n    assert_eq!(1, num_deleted);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"thommy_comment_agg\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let another_person = PersonInsertForm::test_form(inserted_instance.id, \"jerry_comment_agg\");\n\n    let another_inserted_person = Person::create(pool, &another_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL_comment_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let child_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let _inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    let comment_like = CommentLikeForm::new(inserted_comment.id, inserted_person.id, Some(true));\n\n    CommentActions::like(pool, &comment_like).await?;\n\n    let comment_aggs_before_delete = Comment::read(pool, inserted_comment.id).await?;\n\n    assert_eq!(1, comment_aggs_before_delete.score);\n    assert_eq!(1, comment_aggs_before_delete.upvotes);\n    assert_eq!(0, comment_aggs_before_delete.downvotes);\n\n    // Add a post dislike from the other person\n    let comment_dislike =\n      CommentLikeForm::new(inserted_comment.id, another_inserted_person.id, Some(false));\n\n    CommentActions::like(pool, &comment_dislike).await?;\n\n    let comment_aggs_after_dislike = Comment::read(pool, inserted_comment.id).await?;\n\n    assert_eq!(0, comment_aggs_after_dislike.score);\n    assert_eq!(1, comment_aggs_after_dislike.upvotes);\n    assert_eq!(1, comment_aggs_after_dislike.downvotes);\n\n    // Remove the first comment like\n    let form = CommentLikeForm::new(inserted_comment.id, inserted_person.id, None);\n    CommentActions::like(pool, &form).await?;\n    let after_like_remove = Comment::read(pool, inserted_comment.id).await?;\n    assert_eq!(-1, after_like_remove.score);\n    assert_eq!(0, after_like_remove.upvotes);\n    assert_eq!(1, after_like_remove.downvotes);\n\n    // Remove the parent post\n    Post::delete(pool, inserted_post.id).await?;\n\n    // Should be none found, since the post was deleted\n    let after_delete = Comment::read(pool, inserted_comment.id).await;\n    assert!(after_delete.is_err());\n\n    // This should delete all the associated rows, and fire triggers\n    Person::delete(pool, another_inserted_person.id).await?;\n    let person_num_deleted = Person::delete(pool, inserted_person.id).await?;\n    assert_eq!(1, person_num_deleted);\n\n    // Delete the community\n    let community_num_deleted = Community::delete(pool, inserted_community.id).await?;\n    assert_eq!(1, community_num_deleted);\n\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_update_children() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"mydomain.tld\").await?;\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"john\");\n    let inserted_person = Person::create(pool, &new_person).await?;\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test\".into(),\n      \"test\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"Post Title\".to_string(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let parent_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"Top level\".to_string(),\n    );\n    let inserted_parent_comment = Comment::create(pool, &parent_comment_form, None).await?;\n\n    let child_comment_form =\n      CommentInsertForm::new(inserted_person.id, inserted_post.id, \"Child\".to_string());\n    let inserted_child_comment = Comment::create(\n      pool,\n      &child_comment_form,\n      Some(&inserted_parent_comment.path),\n    )\n    .await?;\n\n    let grandchild_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"Grandchild\".to_string(),\n    );\n    let _inserted_grandchild_comment = Comment::create(\n      pool,\n      &grandchild_comment_form,\n      Some(&inserted_child_comment.path),\n    )\n    .await?;\n\n    let lock_form = CommentUpdateForm {\n      locked: Some(true),\n      ..Default::default()\n    };\n\n    let updated_comments =\n      Comment::update_comment_and_children(pool, &inserted_parent_comment.path, &lock_form).await?;\n\n    let locked_comments_num = updated_comments.iter().filter(|c| c.locked).count();\n\n    assert_eq!(3, locked_comments_num);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_remove_post_children() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"mydomain.tld\").await?;\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"sharah\");\n    let inserted_person = Person::create(pool, &new_person).await?;\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test\".into(),\n      \"test\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n    let new_post = PostInsertForm::new(\n      \"Post Title\".to_string(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_toplevel1_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"Top level\".to_string(),\n    );\n    let inserted_comment_toplevel1 = Comment::create(pool, &comment_toplevel1_form, None).await?;\n\n    let child_comment_form =\n      CommentInsertForm::new(inserted_person.id, inserted_post.id, \"Child\".to_string());\n    let _inserted_child_comment = Comment::create(\n      pool,\n      &child_comment_form,\n      Some(&inserted_comment_toplevel1.path),\n    )\n    .await?;\n\n    let comment_toplevel2_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"Top level 2\".to_string(),\n    );\n    let _inserted_comment_toplevel2 = Comment::create(pool, &comment_toplevel2_form, None).await?;\n\n    let updated_comments = Comment::update_removed_for_post(pool, inserted_post.id, true).await?;\n\n    let updated_comments_num = updated_comments.iter().filter(|c| c.removed).count();\n\n    assert_eq!(updated_comments_num, 3);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/comment_report.rs",
    "content": "use crate::{\n  newtypes::{CommentId, CommentReportId, PostId},\n  source::comment_report::{CommentReport, CommentReportForm},\n  traits::Reportable,\n};\nuse chrono::Utc;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  QueryDsl,\n  dsl::{insert_into, update},\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_ltree::{Ltree, LtreeExtensions};\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{comment, comment_report},\n};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Reportable for CommentReport {\n  type Form = CommentReportForm;\n  type IdType = CommentReportId;\n  type ObjectIdType = CommentId;\n  /// creates a comment report and returns it\n  ///\n  /// * `conn` - the postgres connection\n  /// * `comment_report_form` - the filled CommentReportForm to insert\n  async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(comment_report::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  /// resolve a comment report\n  ///\n  /// * `conn` - the postgres connection\n  /// * `report_id` - the id of the report to resolve\n  /// * `by_resolver_id` - the id of the user resolving the report\n  async fn update_resolved(\n    pool: &mut DbPool<'_>,\n    report_id_: Self::IdType,\n    by_resolver_id: PersonId,\n    is_resolved: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(comment_report::table.find(report_id_))\n      .set((\n        comment_report::resolved.eq(is_resolved),\n        comment_report::resolver_id.eq(by_resolver_id),\n        comment_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_apub(\n    pool: &mut DbPool<'_>,\n    object_id: Self::ObjectIdType,\n    report_creator_id: PersonId,\n    resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      comment_report::table.filter(\n        comment_report::comment_id\n          .eq(object_id)\n          .and(comment_report::creator_id.eq(report_creator_id)),\n      ),\n    )\n    .set((\n      comment_report::resolved.eq(true),\n      comment_report::resolver_id.eq(resolver_id),\n      comment_report::updated_at.eq(Utc::now()),\n    ))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_all_for_object(\n    pool: &mut DbPool<'_>,\n    comment_id_: CommentId,\n    by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(comment_report::table.filter(comment_report::comment_id.eq(comment_id_)))\n      .set((\n        comment_report::resolved.eq(true),\n        comment_report::resolver_id.eq(by_resolver_id),\n        comment_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CommentReport {\n  pub async fn resolve_all_for_thread(\n    pool: &mut DbPool<'_>,\n    comment_path: &Ltree,\n    by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    let report_alias = diesel::alias!(comment_report as cr);\n    let report_subquery = report_alias\n      .inner_join(comment::table.on(comment::id.eq(report_alias.field(comment_report::comment_id))))\n      .filter(comment::path.contained_by(comment_path));\n    update(comment_report::table.filter(\n      comment_report::id.eq_any(report_subquery.select(report_alias.field(comment_report::id))),\n    ))\n    .set((\n      comment_report::resolved.eq(true),\n      comment_report::resolver_id.eq(by_resolver_id),\n      comment_report::updated_at.eq(Utc::now()),\n    ))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn resolve_all_for_post(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    let report_alias = diesel::alias!(comment_report as cr);\n    let report_subquery = report_alias\n      .inner_join(comment::table.on(comment::id.eq(report_alias.field(comment_report::comment_id))))\n      .filter(comment::post_id.eq(post_id));\n    update(comment_report::table.filter(\n      comment_report::id.eq_any(report_subquery.select(report_alias.field(comment_report::id))),\n    ))\n    .set((\n      comment_report::resolved.eq(true),\n      comment_report::resolver_id.eq(by_resolver_id),\n      comment_report::updated_at.eq(Utc::now()),\n    ))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/community.rs",
    "content": "use crate::{\n  diesel::{DecoratableTarget, JoinOnDsl, OptionalExtension},\n  newtypes::CommunityId,\n  source::{\n    actor_language::CommunityLanguage,\n    community::{\n      Community,\n      CommunityActions,\n      CommunityBlockForm,\n      CommunityFollowerForm,\n      CommunityInsertForm,\n      CommunityModeratorForm,\n      CommunityPersonBanForm,\n      CommunityUpdateForm,\n    },\n    post::Post,\n  },\n  traits::{ApubActor, Bannable, Blockable, Followable},\n  utils::format_actor_url,\n};\nuse chrono::{DateTime, Utc};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  NullableExpressionMethods,\n  QueryDsl,\n  dsl::{exists, insert_into, not},\n  expression::SelectableHelper,\n  select,\n  update,\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_uplete::{UpleteCount, uplete};\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::{CommunityFollowerState, CommunityNotificationsMode, CommunityVisibility, ListingType},\n  schema::{comment, community, community_actions, instance, local_user, post},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::{coalesce, coalesce_2_nullable, lower, random_smallint},\n};\nuse lemmy_utils::{\n  CACHE_DURATION_LARGEST_COMMUNITY,\n  error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError},\n  settings::structs::Settings,\n};\nuse moka::future::Cache;\nuse std::sync::{Arc, LazyLock};\nuse url::Url;\n\nimpl Crud for Community {\n  type InsertForm = CommunityInsertForm;\n  type UpdateForm = CommunityUpdateForm;\n  type IdType = CommunityId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    let community_ = insert_into(community::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)?;\n\n    // Initialize languages for new community\n    CommunityLanguage::update(pool, vec![], community_.id).await?;\n\n    Ok(community_)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(community::table.find(community_id))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CommunityActions {\n  pub async fn join(pool: &mut DbPool<'_>, form: &CommunityModeratorForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_actions::table)\n      .values(form)\n      .on_conflict((\n        community_actions::person_id,\n        community_actions::community_id,\n      ))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  pub async fn leave(\n    pool: &mut DbPool<'_>,\n    form: &CommunityModeratorForm,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(community_actions::table.find((form.person_id, form.community_id)))\n      .set_null(community_actions::became_moderator_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n}\n\n#[derive(Debug)]\npub enum CollectionType {\n  Moderators,\n  Featured,\n}\n\nimpl Community {\n  pub async fn insert_apub(\n    pool: &mut DbPool<'_>,\n    timestamp: DateTime<Utc>,\n    form: &CommunityInsertForm,\n  ) -> LemmyResult<Self> {\n    let is_new_community = match &form.ap_id {\n      Some(id) => Community::read_from_apub_id(pool, id).await?.is_none(),\n      None => true,\n    };\n    let conn = &mut get_conn(pool).await?;\n\n    // Can't do separate insert/update commands because InsertForm/UpdateForm aren't convertible\n    let community_ = insert_into(community::table)\n      .values(form)\n      .on_conflict(community::ap_id)\n      .filter_target(coalesce(community::updated_at, community::published_at).lt(timestamp))\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await?;\n\n    // Initialize languages for new community\n    if is_new_community {\n      CommunityLanguage::update(pool, vec![], community_.id).await?;\n    }\n\n    Ok(community_)\n  }\n\n  /// Get the community which has a given moderators or featured url, also return the collection\n  /// type\n  pub async fn get_by_collection_url(\n    pool: &mut DbPool<'_>,\n    url: &DbUrl,\n  ) -> LemmyResult<(Community, CollectionType)> {\n    let conn = &mut get_conn(pool).await?;\n    let res = community::table\n      .filter(community::moderators_url.eq(url))\n      .first(conn)\n      .await;\n\n    if let Ok(c) = res {\n      Ok((c, CollectionType::Moderators))\n    } else {\n      let res = community::table\n        .filter(community::featured_url.eq(url))\n        .first(conn)\n        .await;\n      if let Ok(c) = res {\n        Ok((c, CollectionType::Featured))\n      } else {\n        Err(LemmyErrorType::NotFound.into())\n      }\n    }\n  }\n\n  pub async fn set_featured_posts(\n    community_id: CommunityId,\n    posts: Vec<Post>,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    for p in &posts {\n      debug_assert!(p.community_id == community_id);\n    }\n    // Mark the given posts as featured and all other posts as not featured.\n    let post_ids = posts.iter().map(|p| p.id);\n    update(post::table)\n      .filter(post::community_id.eq(community_id))\n      // This filter is just for performance\n      .filter(post::featured_community.or(post::id.eq_any(post_ids.clone())))\n      .set(post::featured_community.eq(post::id.eq_any(post_ids)))\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn get_random_community_id(\n    pool: &mut DbPool<'_>,\n    type_: &Option<ListingType>,\n    show_nsfw: Option<bool>,\n  ) -> LemmyResult<CommunityId> {\n    let conn = &mut get_conn(pool).await?;\n\n    // This is based on the random page selection algorithm in MediaWiki. It assigns a random number\n    // X to each item. To pick a random one, it generates a random number Y and gets the item with\n    // the lowest X value where X >= Y.\n    //\n    // https://phabricator.wikimedia.org/source/mediawiki/browse/master/includes/specials/SpecialRandomPage.php;763c5f084101676ab1bc52862e1ffbd24585a365\n    //\n    // The difference is we also regenerate the item's assigned number when the item is picked.\n    // Without this, items would have permanent variations in the probability of being picked.\n    // Additionally, in each group of multiple items that are assigned the same random number (a\n    // more likely occurence with `smallint`), there would be only one item that ever gets\n    // picked.\n\n    let try_pick = || {\n      let mut query = community::table\n        .filter(not(\n          community::deleted\n            .or(community::removed)\n            .or(community::visibility.eq(CommunityVisibility::Private)),\n        ))\n        .order(community::random_number.asc())\n        .select(community::id)\n        .into_boxed();\n\n      if let Some(ListingType::Local) = type_ {\n        query = query.filter(community::local);\n      }\n\n      if !show_nsfw.unwrap_or(false) {\n        query = query.filter(not(community::nsfw));\n      }\n\n      query\n    };\n\n    diesel::update(community::table)\n      .filter(\n        community::id.nullable().eq(coalesce_2_nullable(\n          try_pick()\n            .filter(community::random_number.nullable().ge(\n              // Without `select` and `single_value`, this would call `random_smallint` separately\n              // for each row\n              select(random_smallint()).single_value(),\n            ))\n            .single_value(),\n          // Wrap to the beginning if the generated number is higher than all\n          // `community::random_number` values, just like in the MediaWiki algorithm\n          try_pick().single_value(),\n        )),\n      )\n      .set(community::random_number.eq(random_smallint()))\n      .returning(community::id)\n      .get_result::<CommunityId>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  #[diesel::dsl::auto_type(no_type_alias)]\n  pub fn hide_removed_and_deleted() -> _ {\n    community::removed\n      .eq(false)\n      .and(community::deleted.eq(false))\n  }\n\n  pub async fn update_federated_followers(\n    pool: &mut DbPool<'_>,\n    for_community_id: CommunityId,\n    new_subscribers: i32,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(community::table.find(for_community_id))\n      .set(community::dsl::subscribers.eq(new_subscribers))\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CommunityActions {\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    person_id: PersonId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    community_actions::table\n      .find((person_id, community_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn delete_mods_for_community(\n    pool: &mut DbPool<'_>,\n    for_community_id: CommunityId,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(community_actions::table.filter(community_actions::community_id.eq(for_community_id)))\n      .set_null(community_actions::became_moderator_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn leave_mod_team_for_all_communities(\n    pool: &mut DbPool<'_>,\n    for_person_id: PersonId,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(community_actions::table.filter(community_actions::person_id.eq(for_person_id)))\n      .set_null(community_actions::became_moderator_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn get_person_moderated_communities(\n    pool: &mut DbPool<'_>,\n    for_person_id: PersonId,\n  ) -> LemmyResult<Vec<CommunityId>> {\n    let conn = &mut get_conn(pool).await?;\n    community_actions::table\n      .filter(community_actions::became_moderator_at.is_not_null())\n      .filter(community_actions::person_id.eq(for_person_id))\n      .select(community_actions::community_id)\n      .load::<CommunityId>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Check if we should accept activity in remote community. This requires either:\n  /// - Local follower of the community\n  /// - Local post or comment in the community\n  ///\n  /// Dont use this check for local communities.\n  pub async fn check_accept_activity_in_community(\n    pool: &mut DbPool<'_>,\n    remote_community: &Community,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let remote_community_id = remote_community.id;\n    let follow_action = community_actions::table\n      .filter(community_actions::followed_at.is_not_null())\n      .filter(community_actions::community_id.eq(remote_community_id));\n    let local_post = post::table\n      .filter(post::community_id.eq(remote_community_id))\n      .filter(post::local);\n    let local_comment = comment::table\n      .inner_join(post::table)\n      .filter(post::community_id.eq(remote_community_id))\n      .filter(comment::local);\n    select(exists(follow_action).or(exists(local_post).or(exists(local_comment))))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(UntranslatedError::CommunityHasNoFollowers(remote_community.ap_id.to_string()).into())\n  }\n\n  pub async fn approve_private_community_follower(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    follower_id: PersonId,\n    approver_id: PersonId,\n    state: CommunityFollowerState,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = community_actions::table\n      .find((follower_id, community_id))\n      .filter(community_actions::followed_at.is_not_null());\n    diesel::update(find_action)\n      .set((\n        community_actions::follow_state.eq(state),\n        community_actions::follow_approver_id.eq(approver_id),\n      ))\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn fetch_largest_subscribed_community(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Option<CommunityId>> {\n    static CACHE: LazyLock<Cache<PersonId, Option<CommunityId>>> = LazyLock::new(|| {\n      Cache::builder()\n        .max_capacity(1000)\n        .time_to_live(CACHE_DURATION_LARGEST_COMMUNITY)\n        .build()\n    });\n    CACHE\n      .try_get_with(person_id, async move {\n        let conn = &mut get_conn(pool).await?;\n        community_actions::table\n          .filter(community_actions::followed_at.is_not_null())\n          .filter(community_actions::person_id.eq(person_id))\n          .inner_join(community::table.on(community::id.eq(community_actions::community_id)))\n          .order_by(community::users_active_month.desc())\n          .select(community::id)\n          .first::<CommunityId>(conn)\n          .await\n          .optional()\n          .with_lemmy_type(LemmyErrorType::NotFound)\n      })\n      .await\n      .map_err(|_e: Arc<LemmyError>| LemmyErrorType::NotFound.into())\n  }\n\n  pub async fn update_notification_state(\n    community_id: CommunityId,\n    person_id: PersonId,\n    new_state: CommunityNotificationsMode,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let form = (\n      community_actions::person_id.eq(person_id),\n      community_actions::community_id.eq(community_id),\n      community_actions::notifications.eq(new_state),\n    );\n\n    insert_into(community_actions::table)\n      .values(form.clone())\n      .on_conflict((\n        community_actions::person_id,\n        community_actions::community_id,\n      ))\n      .do_update()\n      .set(form)\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn list_subscribers(\n    community_id: CommunityId,\n    is_post: bool,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<PersonId>> {\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = community_actions::table\n      .inner_join(local_user::table.on(community_actions::person_id.eq(local_user::person_id)))\n      .filter(community_actions::community_id.eq(community_id))\n      .select(local_user::person_id)\n      .into_boxed();\n    if is_post {\n      query = query.filter(\n        community_actions::notifications\n          .eq(CommunityNotificationsMode::AllPosts)\n          .or(community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments)),\n      );\n    } else {\n      query = query.filter(\n        community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments),\n      );\n    }\n    query\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl Bannable for CommunityActions {\n  type Form = CommunityPersonBanForm;\n  async fn ban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_actions::table)\n      .values(form)\n      .on_conflict((\n        community_actions::community_id,\n        community_actions::person_id,\n      ))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn unban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(community_actions::table.find((form.person_id, form.community_id)))\n      .set_null(community_actions::received_ban_at)\n      .set_null(community_actions::ban_expires_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Followable for CommunityActions {\n  type Form = CommunityFollowerForm;\n  type IdType = CommunityId;\n\n  async fn follow(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_actions::table)\n      .values(form)\n      .on_conflict((\n        community_actions::community_id,\n        community_actions::person_id,\n      ))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  async fn follow_accepted(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    person_id: PersonId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = community_actions::table\n      .find((person_id, community_id))\n      .filter(community_actions::follow_state.is_not_null());\n    diesel::update(find_action)\n      .set(community_actions::follow_state.eq(Some(CommunityFollowerState::Accepted)))\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn unfollow(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    community_id: Self::IdType,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(community_actions::table.find((person_id, community_id)))\n      .set_null(community_actions::followed_at)\n      .set_null(community_actions::follow_state)\n      .set_null(community_actions::follow_approver_id)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Blockable for CommunityActions {\n  type Form = CommunityBlockForm;\n  type ObjectIdType = CommunityId;\n  type ObjectType = Community;\n\n  async fn block(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_actions::table)\n      .values(form)\n      .on_conflict((\n        community_actions::person_id,\n        community_actions::community_id,\n      ))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  async fn unblock(\n    pool: &mut DbPool<'_>,\n    community_block_form: &Self::Form,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(community_actions::table.find((\n      community_block_form.person_id,\n      community_block_form.community_id,\n    )))\n    .set_null(community_actions::blocked_at)\n    .get_result(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn read_block(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    community_id: Self::ObjectIdType,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = community_actions::table\n      .find((person_id, community_id))\n      .filter(community_actions::blocked_at.is_not_null());\n\n    select(not(exists(find_action)))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(LemmyErrorType::CommunityIsBlocked.into())\n  }\n\n  async fn read_blocks_for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Vec<Self::ObjectType>> {\n    let conn = &mut get_conn(pool).await?;\n    community_actions::table\n      .filter(community_actions::blocked_at.is_not_null())\n      .inner_join(community::table)\n      .select(community::all_columns)\n      .filter(community_actions::person_id.eq(person_id))\n      .filter(community::deleted.eq(false))\n      .filter(community::removed.eq(false))\n      .order_by(community_actions::blocked_at)\n      .load::<Community>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl ApubActor for Community {\n  async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: &DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    community::table\n      .filter(lower(community::ap_id).eq(object_id.to_lowercase()))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  async fn read_from_name(\n    pool: &mut DbPool<'_>,\n    community_name: &str,\n    domain: Option<&str>,\n    include_deleted: bool,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let mut q = community::table\n      .inner_join(instance::table)\n      .into_boxed()\n      .filter(lower(community::name).eq(community_name.to_lowercase()))\n      .select(community::all_columns);\n    if !include_deleted {\n      q = q.filter(Self::hide_removed_and_deleted())\n    }\n    if let Some(domain) = domain {\n      q = q.filter(lower(instance::domain).eq(domain.to_lowercase()))\n    } else {\n      q = q.filter(community::local.eq(true))\n    }\n    q.first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  fn actor_url(&self, settings: &Settings) -> LemmyResult<Url> {\n    let domain = self\n      .ap_id\n      .inner()\n      .domain()\n      .ok_or(LemmyErrorType::NotFound)?;\n\n    format_actor_url(&self.name, domain, 'c', settings)\n  }\n\n  fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/c/{name}\"))?.into())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::{\n    source::{\n      comment::{Comment, CommentInsertForm},\n      community::{\n        Community,\n        CommunityActions,\n        CommunityFollowerForm,\n        CommunityInsertForm,\n        CommunityModeratorForm,\n        CommunityPersonBanForm,\n        CommunityUpdateForm,\n      },\n      instance::Instance,\n      local_user::LocalUser,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n    },\n    traits::{Bannable, Followable},\n    utils::RANK_DEFAULT,\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let bobby_person = PersonInsertForm::test_form(inserted_instance.id, \"bobby\");\n    let inserted_bobby = Person::create(pool, &bobby_person).await?;\n\n    let artemis_person = PersonInsertForm::test_form(inserted_instance.id, \"artemis\");\n    let inserted_artemis = Person::create(pool, &artemis_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let expected_community = Community {\n      id: inserted_community.id,\n      name: \"TIL\".into(),\n      title: \"nada\".to_owned(),\n      sidebar: None,\n      summary: None,\n      nsfw: false,\n      removed: false,\n      deleted: false,\n      published_at: inserted_community.published_at,\n      updated_at: None,\n      ap_id: inserted_community.ap_id.clone(),\n      local: true,\n      private_key: None,\n      public_key: \"pubkey\".to_owned(),\n      last_refreshed_at: inserted_community.published_at,\n      icon: None,\n      banner: None,\n      followers_url: inserted_community.followers_url.clone(),\n      inbox_url: inserted_community.inbox_url.clone(),\n      moderators_url: None,\n      featured_url: None,\n      posting_restricted_to_mods: false,\n      instance_id: inserted_instance.id,\n      visibility: CommunityVisibility::Public,\n      random_number: inserted_community.random_number,\n      subscribers: 1,\n      posts: 0,\n      comments: 0,\n      users_active_day: 0,\n      users_active_week: 0,\n      users_active_month: 0,\n      users_active_half_year: 0,\n      hot_rank: RANK_DEFAULT,\n      subscribers_local: 1,\n      report_count: 0,\n      unresolved_report_count: 0,\n      interactions_month: 0,\n      local_removed: false,\n    };\n\n    let community_follower_form = CommunityFollowerForm::new(\n      inserted_community.id,\n      inserted_bobby.id,\n      CommunityFollowerState::Accepted,\n    );\n\n    let inserted_community_follower =\n      CommunityActions::follow(pool, &community_follower_form).await?;\n\n    assert_eq!(\n      Some(CommunityFollowerState::Accepted),\n      inserted_community_follower.follow_state\n    );\n\n    let bobby_moderator_form =\n      CommunityModeratorForm::new(inserted_community.id, inserted_bobby.id);\n\n    let inserted_bobby_moderator = CommunityActions::join(pool, &bobby_moderator_form).await?;\n    assert!(inserted_bobby_moderator.became_moderator_at.is_some());\n\n    let artemis_moderator_form =\n      CommunityModeratorForm::new(inserted_community.id, inserted_artemis.id);\n\n    let _inserted_artemis_moderator = CommunityActions::join(pool, &artemis_moderator_form).await?;\n\n    let moderator_person_ids = vec![inserted_bobby.id, inserted_artemis.id];\n\n    // Make sure bobby is marked as a higher mod than artemis, and vice versa\n    let bobby_higher_check_2 = LocalUser::is_higher_mod_or_admin_check(\n      pool,\n      inserted_community.id,\n      inserted_bobby.id,\n      moderator_person_ids.clone(),\n    )\n    .await;\n    assert!(bobby_higher_check_2.is_ok());\n\n    // This should throw an error, since artemis was added later\n    let artemis_higher_check = LocalUser::is_higher_mod_or_admin_check(\n      pool,\n      inserted_community.id,\n      inserted_artemis.id,\n      moderator_person_ids,\n    )\n    .await;\n    assert!(artemis_higher_check.is_err());\n\n    let community_person_ban_form =\n      CommunityPersonBanForm::new(inserted_community.id, inserted_bobby.id);\n\n    let inserted_community_person_ban =\n      CommunityActions::ban(pool, &community_person_ban_form).await?;\n\n    assert!(inserted_community_person_ban.received_ban_at.is_some());\n    assert!(inserted_community_person_ban.ban_expires_at.is_none());\n    let read_community = Community::read(pool, inserted_community.id).await?;\n\n    let update_community_form = CommunityUpdateForm {\n      title: Some(\"nada\".to_owned()),\n      ..Default::default()\n    };\n    let updated_community =\n      Community::update(pool, inserted_community.id, &update_community_form).await?;\n\n    let ignored_community = CommunityActions::unfollow(\n      pool,\n      community_follower_form.person_id,\n      community_follower_form.community_id,\n    )\n    .await?;\n    let left_community = CommunityActions::leave(pool, &bobby_moderator_form).await?;\n    let unban = CommunityActions::unban(pool, &community_person_ban_form).await?;\n    let num_deleted = Community::delete(pool, inserted_community.id).await?;\n    Person::delete(pool, inserted_bobby.id).await?;\n    Person::delete(pool, inserted_artemis.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    assert_eq!(expected_community, read_community);\n    assert_eq!(expected_community, updated_community);\n    assert_eq!(UpleteCount::only_updated(1), ignored_community);\n    assert_eq!(UpleteCount::only_updated(1), left_community);\n    assert_eq!(UpleteCount::only_deleted(1), unban);\n    // assert_eq!(2, loaded_count);\n    assert_eq!(1, num_deleted);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"thommy_community_agg\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let another_person = PersonInsertForm::test_form(inserted_instance.id, \"jerry_community_agg\");\n\n    let another_inserted_person = Person::create(pool, &another_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL_community_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let another_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL_community_agg_2\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let another_inserted_community = Community::create(pool, &another_community).await?;\n\n    let first_person_follow = CommunityFollowerForm::new(\n      inserted_community.id,\n      inserted_person.id,\n      CommunityFollowerState::Accepted,\n    );\n\n    CommunityActions::follow(pool, &first_person_follow).await?;\n\n    let second_person_follow = CommunityFollowerForm::new(\n      inserted_community.id,\n      another_inserted_person.id,\n      CommunityFollowerState::Accepted,\n    );\n\n    CommunityActions::follow(pool, &second_person_follow).await?;\n\n    let another_community_follow = CommunityFollowerForm::new(\n      another_inserted_community.id,\n      inserted_person.id,\n      CommunityFollowerState::Accepted,\n    );\n\n    CommunityActions::follow(pool, &another_community_follow).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let child_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let _inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    let community_aggregates_before_delete = Community::read(pool, inserted_community.id).await?;\n\n    assert_eq!(2, community_aggregates_before_delete.subscribers);\n    assert_eq!(2, community_aggregates_before_delete.subscribers_local);\n    assert_eq!(1, community_aggregates_before_delete.posts);\n    assert_eq!(2, community_aggregates_before_delete.comments);\n\n    // Test the other community\n    let another_community_aggs = Community::read(pool, another_inserted_community.id).await?;\n    assert_eq!(1, another_community_aggs.subscribers);\n    assert_eq!(1, another_community_aggs.subscribers_local);\n    assert_eq!(0, another_community_aggs.posts);\n    assert_eq!(0, another_community_aggs.comments);\n\n    // Unfollow test\n    CommunityActions::unfollow(\n      pool,\n      second_person_follow.person_id,\n      second_person_follow.community_id,\n    )\n    .await?;\n    let after_unfollow = Community::read(pool, inserted_community.id).await?;\n    assert_eq!(1, after_unfollow.subscribers);\n    assert_eq!(1, after_unfollow.subscribers_local);\n\n    // Follow again just for the later tests\n    CommunityActions::follow(pool, &second_person_follow).await?;\n    let after_follow_again = Community::read(pool, inserted_community.id).await?;\n    assert_eq!(2, after_follow_again.subscribers);\n    assert_eq!(2, after_follow_again.subscribers_local);\n\n    // Remove a parent post (the comment count should also be 0)\n    Post::delete(pool, inserted_post.id).await?;\n    let after_parent_post_delete = Community::read(pool, inserted_community.id).await?;\n    assert_eq!(0, after_parent_post_delete.posts);\n    assert_eq!(0, after_parent_post_delete.comments);\n\n    // Remove the 2nd person\n    Person::delete(pool, another_inserted_person.id).await?;\n    let after_person_delete = Community::read(pool, inserted_community.id).await?;\n    assert_eq!(1, after_person_delete.subscribers);\n    assert_eq!(1, after_person_delete.subscribers_local);\n\n    // This should delete all the associated rows, and fire triggers\n    let person_num_deleted = Person::delete(pool, inserted_person.id).await?;\n    assert_eq!(1, person_num_deleted);\n\n    // Delete the community\n    let community_num_deleted = Community::delete(pool, inserted_community.id).await?;\n    assert_eq!(1, community_num_deleted);\n\n    let another_community_num_deleted =\n      Community::delete(pool, another_inserted_community.id).await?;\n    assert_eq!(1, another_community_num_deleted);\n\n    // Should be none found, since the creator was deleted\n    let after_delete = Community::read(pool, inserted_community.id).await;\n    assert!(after_delete.is_err());\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/community_community_follow.rs",
    "content": "use crate::{\n  diesel::{ExpressionMethods, QueryDsl},\n  newtypes::CommunityId,\n  source::community_community_follow::CommunityCommunityFollow,\n};\nuse diesel::{delete, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::community_community_follow;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::LemmyResult;\n\nimpl CommunityCommunityFollow {\n  pub async fn follow(\n    pool: &mut DbPool<'_>,\n    target_id: CommunityId,\n    community_id: CommunityId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_community_follow::table)\n      .values((\n        community_community_follow::target_id.eq(target_id),\n        community_community_follow::community_id.eq(community_id),\n      ))\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn unfollow(\n    pool: &mut DbPool<'_>,\n    target_id: CommunityId,\n    community_id: CommunityId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    delete(\n      community_community_follow::table\n        .filter(community_community_follow::target_id.eq(target_id))\n        .filter(community_community_follow::community_id.eq(community_id)),\n    )\n    .execute(conn)\n    .await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/community_report.rs",
    "content": "use crate::{\n  newtypes::{CommunityId, CommunityReportId},\n  source::community_report::{CommunityReport, CommunityReportForm},\n  traits::Reportable,\n};\nuse chrono::Utc;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  QueryDsl,\n  dsl::{insert_into, update},\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{PersonId, schema::community_report};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Reportable for CommunityReport {\n  type Form = CommunityReportForm;\n  type IdType = CommunityReportId;\n  type ObjectIdType = CommunityId;\n  /// creates a community report and returns it\n  ///\n  /// * `conn` - the postgres connection\n  /// * `community_report_form` - the filled CommunityReportForm to insert\n  async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_report::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  /// resolve a community report\n  ///\n  /// * `conn` - the postgres connection\n  /// * `report_id` - the id of the report to resolve\n  /// * `by_resolver_id` - the id of the user resolving the report\n  async fn update_resolved(\n    pool: &mut DbPool<'_>,\n    report_id_: Self::IdType,\n    by_resolver_id: PersonId,\n    is_resolved: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(community_report::table.find(report_id_))\n      .set((\n        community_report::resolved.eq(is_resolved),\n        community_report::resolver_id.eq(by_resolver_id),\n        community_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_apub(\n    pool: &mut DbPool<'_>,\n    object_id: Self::ObjectIdType,\n    report_creator_id: PersonId,\n    resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      community_report::table.filter(\n        community_report::community_id\n          .eq(object_id)\n          .and(community_report::creator_id.eq(report_creator_id)),\n      ),\n    )\n    .set((\n      community_report::resolved.eq(true),\n      community_report::resolver_id.eq(resolver_id),\n      community_report::updated_at.eq(Utc::now()),\n    ))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_all_for_object(\n    pool: &mut DbPool<'_>,\n    community_id_: Self::ObjectIdType,\n    by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(community_report::table.filter(community_report::community_id.eq(community_id_)))\n      .set((\n        community_report::resolved.eq(true),\n        community_report::resolver_id.eq(by_resolver_id),\n        community_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/community_tag.rs",
    "content": "use crate::{\n  diesel::SelectableHelper,\n  newtypes::{CommunityId, CommunityTagId, PostId},\n  source::{\n    community_tag::{\n      CommunityTag,\n      CommunityTagInsertForm,\n      CommunityTagUpdateForm,\n      CommunityTagsView,\n      PostCommunityTag,\n      PostCommunityTagForm,\n    },\n    post::Post,\n  },\n};\nuse diesel::{\n  ExpressionMethods,\n  QueryDsl,\n  delete,\n  deserialize::FromSql,\n  insert_into,\n  pg::{Pg, PgValue},\n  serialize::ToSql,\n  sql_types::{Json, Nullable},\n  upsert::excluded,\n};\nuse diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_db_schema_file::schema::{community_tag, post_community_tag};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse std::collections::HashSet;\n\nimpl Crud for CommunityTag {\n  type InsertForm = CommunityTagInsertForm;\n  type UpdateForm = CommunityTagUpdateForm;\n  type IdType = CommunityTagId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(community_tag::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    pid: CommunityTagId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(community_tag::table.find(pid))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CommunityTag {\n  pub async fn read_for_community(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    community_tag::table\n      .filter(community_tag::community_id.eq(community_id))\n      .filter(community_tag::deleted.eq(false))\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update_many(\n    pool: &mut DbPool<'_>,\n    mut forms: Vec<CommunityTagInsertForm>,\n    existing_tags: Vec<CommunityTag>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let new_tag_ids = forms\n      .iter()\n      .map(|tag| tag.ap_id.clone())\n      .collect::<HashSet<_>>();\n    let delete_forms = existing_tags\n      .into_iter()\n      .filter(|tag| !new_tag_ids.contains(&tag.ap_id))\n      .map(|t| CommunityTagInsertForm {\n        ap_id: t.ap_id,\n        name: t.name,\n        display_name: None,\n        community_id: t.community_id,\n        deleted: Some(true),\n        summary: None,\n        color: Some(t.color),\n      });\n    forms.extend(delete_forms);\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          insert_into(community_tag::table)\n            .values(&forms)\n            .on_conflict(community_tag::ap_id)\n            .do_update()\n            .set((\n              community_tag::display_name.eq(excluded(community_tag::display_name)),\n              community_tag::summary.eq(excluded(community_tag::summary)),\n              community_tag::deleted.eq(excluded(community_tag::deleted)),\n            ))\n            .execute(conn)\n            .await?;\n\n          Ok(())\n        }\n        .scope_boxed()\n      })\n      .await?;\n\n    Ok(())\n  }\n\n  pub async fn read_for_post(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n  ) -> LemmyResult<Vec<CommunityTag>> {\n    let conn = &mut get_conn(pool).await?;\n    post_community_tag::table\n      .inner_join(community_tag::table)\n      .filter(post_community_tag::post_id.eq(post_id))\n      .filter(community_tag::deleted.eq(false))\n      .select(community_tag::all_columns)\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read_apub(pool: &mut DbPool<'_>, ap_id: &DbUrl) -> LemmyResult<CommunityTag> {\n    let conn = &mut get_conn(pool).await?;\n    community_tag::table\n      .filter(community_tag::ap_id.eq(ap_id))\n      .filter(community_tag::deleted.eq(false))\n      .select(community_tag::all_columns)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl FromSql<Nullable<Json>, Pg> for CommunityTagsView {\n  fn from_sql(bytes: PgValue) -> diesel::deserialize::Result<Self> {\n    let value = <serde_json::Value as FromSql<Json, Pg>>::from_sql(bytes)?;\n    Ok(serde_json::from_value::<CommunityTagsView>(value)?)\n  }\n  fn from_nullable_sql(\n    bytes: Option<<Pg as diesel::backend::Backend>::RawValue<'_>>,\n  ) -> diesel::deserialize::Result<Self> {\n    match bytes {\n      Some(bytes) => Self::from_sql(bytes),\n      None => Ok(Self(vec![])),\n    }\n  }\n}\n\nimpl ToSql<Nullable<Json>, Pg> for CommunityTagsView {\n  fn to_sql(&self, out: &mut diesel::serialize::Output<Pg>) -> diesel::serialize::Result {\n    let value = serde_json::to_value(self)?;\n    <serde_json::Value as ToSql<Json, Pg>>::to_sql(&value, &mut out.reborrow())\n  }\n}\n\nimpl PostCommunityTag {\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    post: &Post,\n    community_tag_ids: &[CommunityTagId],\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          delete(post_community_tag::table.filter(post_community_tag::post_id.eq(post.id)))\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::Deleted)?;\n\n          let forms = community_tag_ids\n            .iter()\n            .map(|tag_id| PostCommunityTagForm {\n              post_id: post.id,\n              community_tag_id: *tag_id,\n            })\n            .collect::<Vec<_>>();\n          insert_into(post_community_tag::table)\n            .values(forms)\n            .returning(Self::as_select())\n            .get_results(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntCreate)\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/custom_emoji.rs",
    "content": "use crate::{\n  newtypes::CustomEmojiId,\n  source::{\n    custom_emoji::{CustomEmoji, CustomEmojiInsertForm, CustomEmojiUpdateForm},\n    custom_emoji_keyword::{CustomEmojiKeyword, CustomEmojiKeywordInsertForm},\n  },\n};\nuse diesel::{ExpressionMethods, QueryDsl, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::{\n  custom_emoji::dsl::custom_emoji,\n  custom_emoji_keyword::dsl::{custom_emoji_id, custom_emoji_keyword},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Crud for CustomEmoji {\n  type InsertForm = CustomEmojiInsertForm;\n  type UpdateForm = CustomEmojiUpdateForm;\n  type IdType = CustomEmojiId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(custom_emoji)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    emoji_id: Self::IdType,\n    new_custom_emoji: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(custom_emoji.find(emoji_id))\n      .set(new_custom_emoji)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl CustomEmojiKeyword {\n  pub async fn create_from_keywords(\n    pool: &mut DbPool<'_>,\n    for_custom_emoji_id: CustomEmojiId,\n    keywords: &[String],\n  ) -> LemmyResult<Vec<Self>> {\n    let forms = keywords\n      .iter()\n      .map(|k| CustomEmojiKeywordInsertForm {\n        custom_emoji_id: for_custom_emoji_id,\n        keyword: k.to_lowercase().trim().to_string(),\n      })\n      .collect();\n\n    Self::create(pool, &forms).await\n  }\n\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    form: &Vec<CustomEmojiKeywordInsertForm>,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(custom_emoji_keyword)\n      .values(form)\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  pub async fn delete(pool: &mut DbPool<'_>, emoji_id: CustomEmojiId) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(custom_emoji_keyword.filter(custom_emoji_id.eq(emoji_id)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/email_verification.rs",
    "content": "use crate::{\n  newtypes::LocalUserId,\n  source::email_verification::{EmailVerification, EmailVerificationForm},\n};\nuse diesel::{ExpressionMethods, QueryDsl, dsl::IntervalDsl, insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::email_verification;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  utils::now,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl EmailVerification {\n  pub async fn create(pool: &mut DbPool<'_>, form: &EmailVerificationForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(email_verification::table)\n      .values(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn read_for_token(pool: &mut DbPool<'_>, token: &str) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    email_verification::table\n      .filter(email_verification::verification_token.eq(token))\n      .filter(email_verification::published_at.gt(now() - 7.days()))\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n  pub async fn delete_old_tokens_for_local_user(\n    pool: &mut DbPool<'_>,\n    local_user_id_: LocalUserId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(\n      email_verification::table.filter(email_verification::local_user_id.eq(local_user_id_)),\n    )\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/federation_allowlist.rs",
    "content": "use crate::source::federation_allowlist::{FederationAllowList, FederationAllowListForm};\nuse diesel::{ExpressionMethods, QueryDsl, delete, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{InstanceId, schema::federation_allowlist};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl FederationAllowList {\n  pub async fn allow(pool: &mut DbPool<'_>, form: &FederationAllowListForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(federation_allowlist::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  pub async fn unallow(pool: &mut DbPool<'_>, instance_id_: InstanceId) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    delete(federation_allowlist::table.filter(federation_allowlist::instance_id.eq(instance_id_)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use crate::source::instance::Instance;\n  use lemmy_diesel_utils::connection::build_db_pool_for_tests;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_allowlist_insert_and_clear() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let instances = vec![\n      Instance::read_or_create(pool, \"tld1.xyz\").await?,\n      Instance::read_or_create(pool, \"tld2.xyz\").await?,\n      Instance::read_or_create(pool, \"tld3.xyz\").await?,\n    ];\n    let forms: Vec<_> = instances\n      .iter()\n      .map(|i| FederationAllowListForm::new(i.id))\n      .collect();\n\n    for f in &forms {\n      FederationAllowList::allow(pool, f).await?;\n    }\n\n    let allows = Instance::allowlist(pool).await?;\n\n    assert_eq!(3, allows.len());\n    assert_eq!(instances, allows);\n\n    // Now test clearing them\n    for f in forms {\n      FederationAllowList::unallow(pool, f.instance_id).await?;\n    }\n    let allows = Instance::allowlist(pool).await?;\n    assert_eq!(0, allows.len());\n\n    Instance::delete_all(pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/federation_blocklist.rs",
    "content": "use crate::source::federation_blocklist::{FederationBlockList, FederationBlockListForm};\nuse diesel::{ExpressionMethods, QueryDsl, delete, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{InstanceId, schema::federation_blocklist};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl FederationBlockList {\n  pub async fn block(pool: &mut DbPool<'_>, form: &FederationBlockListForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(federation_blocklist::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  pub async fn unblock(pool: &mut DbPool<'_>, instance_id_: InstanceId) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    delete(federation_blocklist::table.filter(federation_blocklist::instance_id.eq(instance_id_)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/federation_queue_state.rs",
    "content": "use crate::source::federation_queue_state::FederationQueueState;\nuse diesel::{ExpressionMethods, Insertable, OptionalExtension, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{InstanceId, schema::federation_queue_state};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl FederationQueueState {\n  /// load state or return a default empty value\n  pub async fn load(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Ok(\n      federation_queue_state::table\n        .filter(federation_queue_state::instance_id.eq(instance_id))\n        .select(FederationQueueState::as_select())\n        .get_result(conn)\n        .await\n        .optional()?\n        .unwrap_or(FederationQueueState {\n          instance_id,\n          fail_count: 0,\n          last_retry_at: None,\n          last_successful_id: None, // this value is set to the most current id for new instances\n          last_successful_published_time_at: None,\n        }),\n    )\n  }\n  pub async fn upsert(pool: &mut DbPool<'_>, state: &FederationQueueState) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    state\n      .insert_into(federation_queue_state::table)\n      .on_conflict(federation_queue_state::instance_id)\n      .do_update()\n      .set(state)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/images.rs",
    "content": "use crate::source::images::{\n  ImageDetails,\n  ImageDetailsInsertForm,\n  LocalImage,\n  LocalImageForm,\n  RemoteImage,\n};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  QueryDsl,\n  dsl::exists,\n  insert_into,\n  select,\n};\nuse diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{image_details, local_image, remote_image},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse url::Url;\n\nimpl LocalImage {\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    form: &LocalImageForm,\n    image_details_form: &ImageDetailsInsertForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    conn\n      .run_transaction(|conn| {\n        async move {\n          let local_insert = insert_into(local_image::table)\n            .values(form)\n            .get_result::<Self>(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntCreate);\n\n          ImageDetails::create(&mut conn.into(), image_details_form).await?;\n\n          local_insert\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n\n  pub async fn validate_by_alias_and_user(\n    pool: &mut DbPool<'_>,\n    alias: &str,\n    person_id: PersonId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    select(exists(\n      local_image::table.filter(\n        local_image::pictrs_alias\n          .eq(alias)\n          .and(local_image::person_id.eq(person_id)),\n      ),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotFound.into())\n  }\n\n  pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq(alias)))\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  /// Delete many aliases. Should be used with a pictrs purge.\n  pub async fn delete_by_aliases(pool: &mut DbPool<'_>, aliases: &[String]) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq_any(aliases)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n\nimpl RemoteImage {\n  pub async fn create(pool: &mut DbPool<'_>, links: Vec<Url>) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    let forms = links\n      .into_iter()\n      .map(|url| remote_image::dsl::link.eq::<DbUrl>(url.into()))\n      .collect::<Vec<_>>();\n    insert_into(remote_image::table)\n      .values(forms)\n      .on_conflict_do_nothing()\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn validate(pool: &mut DbPool<'_>, link_: DbUrl) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    select(exists(\n      remote_image::table.filter(remote_image::link.eq(link_)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotFound.into())\n  }\n}\n\nimpl ImageDetails {\n  pub async fn create(pool: &mut DbPool<'_>, form: &ImageDetailsInsertForm) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(image_details::table)\n      .values(form)\n      .on_conflict_do_nothing()\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/instance.rs",
    "content": "use crate::{\n  diesel::dsl::IntervalDsl,\n  source::instance::{\n    Instance,\n    InstanceActions,\n    InstanceBanForm,\n    InstanceCommunitiesBlockForm,\n    InstanceForm,\n    InstancePersonsBlockForm,\n  },\n  traits::Bannable,\n};\nuse chrono::Utc;\nuse diesel::{\n  ExpressionMethods,\n  NullableExpressionMethods,\n  OptionalExtension,\n  QueryDsl,\n  SelectableHelper,\n  dsl::{count_star, exists, insert_into, not, select},\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_uplete::{UpleteCount, uplete};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  schema::{\n    federation_allowlist,\n    federation_blocklist,\n    federation_queue_state,\n    instance,\n    instance_actions,\n  },\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  utils::{\n    functions::{coalesce, lower},\n    now,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Instance {\n  /// Attempt to read Instance column for the given domain. If it doesn't exist, insert a new one.\n  /// There is no need for update as the domain of an existing instance cant change.\n  pub async fn read_or_create(pool: &mut DbPool<'_>, domain_: &str) -> LemmyResult<Self> {\n    use lemmy_db_schema_file::schema::instance::domain;\n    let conn = &mut get_conn(pool).await?;\n\n    // First try to read the instance row and return directly if found\n    let instance = instance::table\n      .filter(lower(domain).eq(&domain_.to_lowercase()))\n      .first(conn)\n      .await\n      .optional()?;\n\n    // TODO could convert this to unwrap_or_else once async closures are stable\n    match instance {\n      Some(i) => Ok(i),\n      None => {\n        // Instance not in database yet, insert it\n        let form = InstanceForm {\n          updated_at: Some(Utc::now()),\n          ..InstanceForm::new(domain_.to_string())\n        };\n        insert_into(instance::table)\n          .values(&form)\n          // Necessary because this method may be called concurrently for the same domain. This\n          // could be handled with a transaction, but nested transactions arent allowed\n          .on_conflict(instance::domain)\n          .do_update()\n          .set(&form)\n          .get_result::<Self>(conn)\n          .await\n          .with_lemmy_type(LemmyErrorType::CouldntCreate)\n      }\n    }\n  }\n  pub async fn read(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    instance::table\n      .find(instance_id)\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    instance_id: InstanceId,\n    form: InstanceForm,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(instance::table.find(instance_id))\n      .set(form)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn delete(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(instance::table.find(instance_id))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  pub async fn read_all(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Instance>> {\n    let conn = &mut get_conn(pool).await?;\n    instance::table\n      .select(Self::as_select())\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Only for use in tests\n  pub async fn delete_all(pool: &mut DbPool<'_>) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(federation_queue_state::table)\n      .execute(conn)\n      .await?;\n    diesel::delete(instance::table)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  pub async fn allowlist(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    instance::table\n      .inner_join(federation_allowlist::table)\n      .select(Self::as_select())\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn blocklist(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    instance::table\n      .inner_join(federation_blocklist::table)\n      .select(Self::as_select())\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// returns a list of all instances, each with a flag of whether the instance is allowed or not\n  /// and dead or not ordered by id\n  pub async fn read_federated_with_blocked_and_dead(\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<(Self, bool, bool)>> {\n    let conn = &mut get_conn(pool).await?;\n    let is_dead_expr = coalesce(instance::updated_at, instance::published_at).lt(now() - 3.days());\n    // this needs to be done in two steps because the meaning of the \"blocked\" column depends on the\n    // existence of any value at all in the allowlist. (so a normal join wouldn't work)\n    let use_allowlist = federation_allowlist::table\n      .select(count_star().gt(0))\n      .get_result::<bool>(conn)\n      .await?;\n    if use_allowlist {\n      instance::table\n        .left_join(federation_allowlist::table)\n        .select((\n          Self::as_select(),\n          federation_allowlist::instance_id.nullable().is_not_null(),\n          is_dead_expr,\n        ))\n        .order_by(instance::id)\n        .get_results::<(Self, bool, bool)>(conn)\n        .await\n        .with_lemmy_type(LemmyErrorType::NotFound)\n    } else {\n      instance::table\n        .left_join(federation_blocklist::table)\n        .select((\n          Self::as_select(),\n          federation_blocklist::instance_id.nullable().is_null(),\n          is_dead_expr,\n        ))\n        .order_by(instance::id)\n        .get_results::<(Self, bool, bool)>(conn)\n        .await\n        .with_lemmy_type(LemmyErrorType::NotFound)\n    }\n  }\n}\n\nimpl InstanceActions {\n  pub async fn block_communities(\n    pool: &mut DbPool<'_>,\n    form: &InstanceCommunitiesBlockForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(instance_actions::table)\n      .values(form)\n      .on_conflict((instance_actions::person_id, instance_actions::instance_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  pub async fn unblock_communities(\n    pool: &mut DbPool<'_>,\n    form: &InstanceCommunitiesBlockForm,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(instance_actions::table.find((form.person_id, form.instance_id)))\n      .set_null(instance_actions::blocked_communities_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  /// Checks to see if there's a block for the instances communities\n  pub async fn read_communities_block(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    instance_id: InstanceId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = instance_actions::table\n      .find((person_id, instance_id))\n      .filter(instance_actions::blocked_communities_at.is_not_null());\n    select(not(exists(find_action)))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(LemmyErrorType::InstanceIsBlocked.into())\n  }\n\n  pub async fn read_communities_block_for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Vec<Instance>> {\n    let conn = &mut get_conn(pool).await?;\n    instance_actions::table\n      .filter(instance_actions::blocked_communities_at.is_not_null())\n      .inner_join(instance::table)\n      .select(instance::all_columns)\n      .filter(instance_actions::person_id.eq(person_id))\n      .order_by(instance_actions::blocked_communities_at)\n      .load::<Instance>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn block_persons(\n    pool: &mut DbPool<'_>,\n    form: &InstancePersonsBlockForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(instance_actions::table)\n      .values(form)\n      .on_conflict((instance_actions::person_id, instance_actions::instance_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  pub async fn unblock_persons(\n    pool: &mut DbPool<'_>,\n    form: &InstancePersonsBlockForm,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(instance_actions::table.find((form.person_id, form.instance_id)))\n      .set_null(instance_actions::blocked_persons_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  /// Checks to see if there's a block either from the instance person.\n  pub async fn read_persons_block(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    instance_id: InstanceId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = instance_actions::table\n      .find((person_id, instance_id))\n      .filter(instance_actions::blocked_persons_at.is_not_null());\n    select(not(exists(find_action)))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(LemmyErrorType::InstanceIsBlocked.into())\n  }\n\n  pub async fn read_persons_block_for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Vec<Instance>> {\n    let conn = &mut get_conn(pool).await?;\n    instance_actions::table\n      .filter(instance_actions::blocked_persons_at.is_not_null())\n      .inner_join(instance::table)\n      .select(instance::all_columns)\n      .filter(instance_actions::person_id.eq(person_id))\n      .order_by(instance_actions::blocked_persons_at)\n      .load::<Instance>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn check_ban(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    instance_id: InstanceId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let ban_exists = select(exists(\n      instance_actions::table\n        .filter(instance_actions::person_id.eq(person_id))\n        .filter(instance_actions::instance_id.eq(instance_id))\n        .filter(instance_actions::received_ban_at.is_not_null()),\n    ))\n    .get_result::<bool>(conn)\n    .await?;\n\n    if ban_exists {\n      return Err(LemmyErrorType::SiteBan.into());\n    }\n    Ok(())\n  }\n}\n\nimpl Bannable for InstanceActions {\n  type Form = InstanceBanForm;\n  async fn ban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Ok(\n      insert_into(instance_actions::table)\n        .values(form)\n        .on_conflict((instance_actions::person_id, instance_actions::instance_id))\n        .do_update()\n        .set(form)\n        .returning(Self::as_select())\n        .get_result::<Self>(conn)\n        .await?,\n    )\n  }\n  async fn unban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    Ok(\n      uplete(instance_actions::table.find((form.person_id, form.instance_id)))\n        .set_null(instance_actions::received_ban_at)\n        .set_null(instance_actions::ban_expires_at)\n        .get_result(conn)\n        .await?,\n    )\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/keyword_block.rs",
    "content": "use crate::{\n  newtypes::LocalUserId,\n  source::keyword_block::{LocalUserKeywordBlock, LocalUserKeywordBlockForm},\n};\nuse diesel::{ExpressionMethods, QueryDsl, delete, insert_into};\nuse diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_db_schema_file::schema::local_user_keyword_block;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalUserKeywordBlock {\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    for_local_user_id: LocalUserId,\n  ) -> LemmyResult<Vec<String>> {\n    let conn = &mut get_conn(pool).await?;\n    local_user_keyword_block::table\n      .filter(local_user_keyword_block::local_user_id.eq(for_local_user_id))\n      .select(local_user_keyword_block::keyword)\n      .load(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    blocking_keywords: Vec<String>,\n    for_local_user_id: LocalUserId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    // No need to update if keywords unchanged\n    conn\n      .run_transaction(|conn| {\n        async move {\n          delete(local_user_keyword_block::table)\n            .filter(local_user_keyword_block::local_user_id.eq(for_local_user_id))\n            .filter(local_user_keyword_block::keyword.ne_all(&blocking_keywords))\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)?;\n          let forms = blocking_keywords\n            .into_iter()\n            .map(|k| LocalUserKeywordBlockForm {\n              local_user_id: for_local_user_id,\n              keyword: k,\n            })\n            .collect::<Vec<_>>();\n          insert_into(local_user_keyword_block::table)\n            .values(forms)\n            .on_conflict_do_nothing()\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/language.rs",
    "content": "use super::actor_language::UNDETERMINED_ID;\nuse crate::{diesel::ExpressionMethods, newtypes::LanguageId, source::language::Language};\nuse diesel::{QueryDsl, dsl::count};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::{language, post};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::{\n  CacheLock,\n  build_cache,\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n};\nuse std::sync::LazyLock;\n\nimpl Language {\n  /// Returns list of all available languages, with most used languages first\n  pub async fn read_all(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    static CACHE: CacheLock<Vec<Language>> = LazyLock::new(build_cache);\n    CACHE\n      .try_get_with((), async move {\n        let conn = &mut get_conn(pool).await?;\n        language::table\n          .left_join(post::table)\n          .group_by(language::id)\n          .order_by(count(post::id).desc())\n          .select(language::all_columns)\n          .load(conn)\n          .await\n      })\n      .await\n      .map_err(|_e| LemmyErrorType::NotFound.into())\n  }\n\n  pub async fn read_from_id(pool: &mut DbPool<'_>, id_: LanguageId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    language::table\n      .find(id_)\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Attempts to find the given language code and return its ID.\n  pub async fn read_id_from_code(pool: &mut DbPool<'_>, code_: &str) -> LemmyResult<LanguageId> {\n    let conn = &mut get_conn(pool).await?;\n    let res = language::table\n      .filter(language::code.eq(code_))\n      .first::<Self>(conn)\n      .await\n      .map(|l| l.id);\n\n    // Return undetermined by default\n    Ok(res.unwrap_or(UNDETERMINED_ID))\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use crate::source::language::Language;\n  use lemmy_diesel_utils::connection::build_db_pool_for_tests;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let mut all = Language::read_all(pool).await?;\n\n    // Languages are returned in order of popularity, so to make this test work we need to\n    // manually sort them by id.\n    all.sort_by(|a, b| a.id.0.cmp(&b.id.0));\n\n    assert_eq!(184, all.len());\n    assert_eq!(\"ak\", all[5].code);\n    assert_eq!(\"lv\", all[99].code);\n    assert_eq!(\"yi\", all[179].code);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/local_site.rs",
    "content": "use crate::source::local_site::{LocalSite, LocalSiteInsertForm, LocalSiteUpdateForm};\nuse diesel::dsl::insert_into;\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::local_site;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalSite {\n  pub async fn create(pool: &mut DbPool<'_>, form: &LocalSiteInsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(local_site::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn update(pool: &mut DbPool<'_>, form: &LocalSiteUpdateForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(local_site::table)\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn delete(pool: &mut DbPool<'_>) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::delete(local_site::table)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use crate::{\n    source::{\n      comment::{Comment, CommentInsertForm},\n      community::{Community, CommunityInsertForm, CommunityUpdateForm},\n      instance::Instance,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n      site::Site,\n    },\n    test_data::TestData,\n  };\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  async fn read_local_site(pool: &mut DbPool<'_>) -> LemmyResult<LocalSite> {\n    let conn = &mut get_conn(pool).await?;\n    local_site::table\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  async fn prepare_site_with_community(\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<(TestData, Person, Community)> {\n    let data = TestData::create(pool).await?;\n\n    let new_person = PersonInsertForm::test_form(data.instance.id, \"thommy_site_agg\");\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      data.instance.id,\n      \"TIL_site_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    Ok((data, inserted_person, inserted_community))\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let (data, inserted_person, inserted_community) = prepare_site_with_community(pool).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n\n    // Insert two of those posts\n    let inserted_post = Post::create(pool, &new_post).await?;\n    let _inserted_post_again = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n\n    // Insert two of those comments\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let child_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let _inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    let site_aggregates_before_delete = read_local_site(pool).await?;\n\n    // TODO: this is unstable, sometimes it returns 0 users, sometimes 1\n    //assert_eq!(0, site_aggregates_before_delete.users);\n    assert_eq!(1, site_aggregates_before_delete.communities);\n    assert_eq!(2, site_aggregates_before_delete.posts);\n    assert_eq!(2, site_aggregates_before_delete.comments);\n\n    // Try a post delete\n    Post::delete(pool, inserted_post.id).await?;\n    let site_aggregates_after_post_delete = read_local_site(pool).await?;\n    assert_eq!(1, site_aggregates_after_post_delete.posts);\n    assert_eq!(0, site_aggregates_after_post_delete.comments);\n\n    // This shouuld delete all the associated rows, and fire triggers\n    let person_num_deleted = Person::delete(pool, inserted_person.id).await?;\n    assert_eq!(1, person_num_deleted);\n\n    // Delete the community\n    let community_num_deleted = Community::delete(pool, inserted_community.id).await?;\n    assert_eq!(1, community_num_deleted);\n\n    // Site should still exist, it can without a site creator.\n    let after_delete_creator = read_local_site(pool).await;\n    assert!(after_delete_creator.is_ok());\n\n    Site::delete(pool, data.site.id).await?;\n    let after_delete_site = read_local_site(pool).await;\n    assert!(after_delete_site.is_err());\n\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_soft_delete() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let (data, inserted_person, inserted_community) = prepare_site_with_community(pool).await?;\n\n    let site_aggregates_before = read_local_site(pool).await?;\n    assert_eq!(1, site_aggregates_before.communities);\n\n    Community::update(\n      pool,\n      inserted_community.id,\n      &CommunityUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let site_aggregates_after_delete = read_local_site(pool).await?;\n    assert_eq!(0, site_aggregates_after_delete.communities);\n\n    Community::update(\n      pool,\n      inserted_community.id,\n      &CommunityUpdateForm {\n        deleted: Some(false),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    Community::update(\n      pool,\n      inserted_community.id,\n      &CommunityUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let site_aggregates_after_remove = read_local_site(pool).await?;\n    assert_eq!(0, site_aggregates_after_remove.communities);\n\n    Community::update(\n      pool,\n      inserted_community.id,\n      &CommunityUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let site_aggregates_after_remove_delete = read_local_site(pool).await?;\n    assert_eq!(0, site_aggregates_after_remove_delete.communities);\n\n    Community::delete(pool, inserted_community.id).await?;\n    Person::delete(pool, inserted_person.id).await?;\n    data.delete(pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/local_site_rate_limit.rs",
    "content": "use crate::{\n  diesel::OptionalExtension,\n  source::local_site_rate_limit::{\n    LocalSiteRateLimit,\n    LocalSiteRateLimitInsertForm,\n    LocalSiteRateLimitUpdateForm,\n  },\n};\nuse diesel::dsl::insert_into;\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::local_site_rate_limit;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalSiteRateLimit {\n  pub async fn read(pool: &mut DbPool<'_>) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    local_site_rate_limit::table\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    form: &LocalSiteRateLimitInsertForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(local_site_rate_limit::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    form: &LocalSiteRateLimitUpdateForm,\n  ) -> LemmyResult<()> {\n    // avoid error \"There are no changes to save. This query cannot be built\"\n    if form.is_empty() {\n      return Ok(());\n    }\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(local_site_rate_limit::table)\n      .set(form)\n      .get_result::<Self>(conn)\n      .await?;\n    Ok(())\n  }\n}\n\nimpl LocalSiteRateLimitUpdateForm {\n  fn is_empty(&self) -> bool {\n    self.message_max_requests.is_none()\n      && self.message_interval_seconds.is_none()\n      && self.post_max_requests.is_none()\n      && self.post_interval_seconds.is_none()\n      && self.register_max_requests.is_none()\n      && self.register_interval_seconds.is_none()\n      && self.image_max_requests.is_none()\n      && self.image_interval_seconds.is_none()\n      && self.comment_max_requests.is_none()\n      && self.comment_interval_seconds.is_none()\n      && self.search_max_requests.is_none()\n      && self.search_interval_seconds.is_none()\n      && self.updated_at.is_none()\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/local_site_url_blocklist.rs",
    "content": "use crate::source::local_site_url_blocklist::{LocalSiteUrlBlocklist, LocalSiteUrlBlocklistForm};\nuse diesel::dsl::insert_into;\nuse diesel_async::{AsyncPgConnection, RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_db_schema_file::schema::local_site_url_blocklist;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalSiteUrlBlocklist {\n  pub async fn replace(pool: &mut DbPool<'_>, url_blocklist: Vec<String>) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          Self::clear(conn).await?;\n\n          let forms = url_blocklist\n            .into_iter()\n            .map(|url| LocalSiteUrlBlocklistForm {\n              url,\n              updated_at: None,\n            })\n            .collect::<Vec<_>>();\n\n          insert_into(local_site_url_blocklist::table)\n            .values(forms)\n            .execute(conn)\n            .await\n            .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n        }\n        .scope_boxed()\n      })\n      .await\n  }\n\n  async fn clear(conn: &mut AsyncPgConnection) -> LemmyResult<usize> {\n    diesel::delete(local_site_url_blocklist::table)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  pub async fn get_all(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    local_site_url_blocklist::table\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/local_user.rs",
    "content": "use crate::{\n  newtypes::{CommunityId, LanguageId, LocalUserId},\n  source::{\n    actor_language::LocalUserLanguage,\n    local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n    site::Site,\n  },\n};\nuse bcrypt::{DEFAULT_COST, hash};\nuse diesel::{\n  CombineDsl,\n  ExpressionMethods,\n  JoinOnDsl,\n  QueryDsl,\n  dsl::{IntervalDsl, insert_into, not},\n  result::Error,\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::CommunityVisibility,\n  schema::{community, community_actions, local_user, person, registration_application},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  utils::{\n    functions::{coalesce, lower},\n    now,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalUser {\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    form: &LocalUserInsertForm,\n    languages: Vec<LanguageId>,\n  ) -> LemmyResult<LocalUser> {\n    let conn = &mut get_conn(pool).await?;\n    let mut form_with_encrypted_password = form.clone();\n\n    if let Some(password_encrypted) = &form.password_encrypted {\n      let password_hash = hash(password_encrypted, DEFAULT_COST)?;\n      form_with_encrypted_password.password_encrypted = Some(password_hash);\n    }\n\n    let local_user_ = insert_into(local_user::table)\n      .values(form_with_encrypted_password)\n      .get_result::<Self>(conn)\n      .await?;\n\n    LocalUserLanguage::update(pool, languages, local_user_.id).await?;\n\n    Ok(local_user_)\n  }\n\n  pub async fn update(\n    pool: &mut DbPool<'_>,\n    local_user_id: LocalUserId,\n    form: &LocalUserUpdateForm,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    let res = diesel::update(local_user::table.find(local_user_id))\n      .set(form)\n      .execute(conn)\n      .await;\n    // Diesel will throw an error if the query is all Nones (not updating anything), ignore this.\n    match res {\n      Err(Error::QueryBuilderError(_)) => Ok(0),\n      other => other,\n    }\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn delete(pool: &mut DbPool<'_>, id: LocalUserId) -> LemmyResult<usize> {\n    let conn = &mut *get_conn(pool).await?;\n    diesel::delete(local_user::table.find(id))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  pub async fn update_password(\n    pool: &mut DbPool<'_>,\n    local_user_id: LocalUserId,\n    new_password: &str,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let password_hash = hash(new_password, DEFAULT_COST)?;\n\n    diesel::update(local_user::table.find(local_user_id))\n      .set((local_user::password_encrypted.eq(password_hash),))\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn set_all_users_email_verified(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(local_user::table)\n      .set(local_user::email_verified.eq(true))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn set_all_users_registration_applications_accepted(\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(local_user::table)\n      .set(local_user::accepted_application.eq(true))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn delete_old_denied_local_users(pool: &mut DbPool<'_>) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    // Make sure:\n    // - An admin has interacted with the application\n    // - The app is older than a week\n    // - The accepted_application is false\n    let old_denied_registrations = registration_application::table\n      .filter(registration_application::admin_id.is_not_null())\n      .filter(registration_application::published_at.lt(now() - 1.week()))\n      .select(registration_application::local_user_id);\n\n    // Delete based on join logic is here:\n    // https://stackoverflow.com/questions/60836040/how-do-i-perform-a-delete-with-sub-query-in-diesel-against-a-postgres-database\n    let local_users = local_user::table\n      .filter(local_user::id.eq_any(old_denied_registrations))\n      .filter(not(local_user::accepted_application))\n      .select(local_user::person_id);\n\n    // Delete the person rows, which should automatically clear the local_user ones\n    let persons = person::table.filter(person::id.eq_any(local_users));\n\n    diesel::delete(persons)\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  pub async fn check_is_email_taken(pool: &mut DbPool<'_>, email: &str) -> LemmyResult<()> {\n    use diesel::dsl::{exists, select};\n    let conn = &mut get_conn(pool).await?;\n    select(not(exists(local_user::table.filter(\n      lower(coalesce(local_user::email, \"\")).eq(email.to_lowercase()),\n    ))))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::EmailAlreadyTaken.into())\n  }\n\n  // TODO: maybe move this and pass in LocalUserView\n  pub async fn export_backup(\n    pool: &mut DbPool<'_>,\n    person_id_: PersonId,\n  ) -> LemmyResult<UserBackupLists> {\n    use lemmy_db_schema_file::schema::{\n      comment,\n      comment_actions,\n      community,\n      community_actions,\n      instance,\n      instance_actions,\n      person_actions,\n      post,\n      post_actions,\n    };\n    let conn = &mut get_conn(pool).await?;\n\n    let followed_communities = community_actions::table\n      .filter(community_actions::followed_at.is_not_null())\n      .filter(community_actions::person_id.eq(person_id_))\n      .inner_join(community::table)\n      .select(community::ap_id)\n      .get_results(conn)\n      .await?;\n\n    let saved_posts = post_actions::table\n      .filter(post_actions::saved_at.is_not_null())\n      .filter(post_actions::person_id.eq(person_id_))\n      .inner_join(post::table)\n      .select(post::ap_id)\n      .get_results(conn)\n      .await?;\n\n    let saved_comments = comment_actions::table\n      .filter(comment_actions::saved_at.is_not_null())\n      .filter(comment_actions::person_id.eq(person_id_))\n      .inner_join(comment::table)\n      .select(comment::ap_id)\n      .get_results(conn)\n      .await?;\n\n    let blocked_communities = community_actions::table\n      .filter(community_actions::blocked_at.is_not_null())\n      .filter(community_actions::person_id.eq(person_id_))\n      .inner_join(community::table)\n      .select(community::ap_id)\n      .get_results(conn)\n      .await?;\n\n    let blocked_users = person_actions::table\n      .filter(person_actions::blocked_at.is_not_null())\n      .filter(person_actions::person_id.eq(person_id_))\n      .inner_join(person::table.on(person_actions::target_id.eq(person::id)))\n      .select(person::ap_id)\n      .get_results(conn)\n      .await?;\n\n    let blocked_instances_communities = instance_actions::table\n      .filter(instance_actions::blocked_communities_at.is_not_null())\n      .filter(instance_actions::person_id.eq(person_id_))\n      .inner_join(instance::table)\n      .select(instance::domain)\n      .get_results(conn)\n      .await?;\n\n    let blocked_instances_persons = instance_actions::table\n      .filter(instance_actions::blocked_persons_at.is_not_null())\n      .filter(instance_actions::person_id.eq(person_id_))\n      .inner_join(instance::table)\n      .select(instance::domain)\n      .get_results(conn)\n      .await?;\n\n    // TODO: use join for parallel queries?\n\n    Ok(UserBackupLists {\n      followed_communities,\n      saved_posts,\n      saved_comments,\n      blocked_communities,\n      blocked_users,\n      blocked_instances_communities,\n      blocked_instances_persons,\n    })\n  }\n\n  /// Checks to make sure the acting admin is higher than the target admin\n  pub async fn is_higher_admin_check(\n    pool: &mut DbPool<'_>,\n    admin_person_id: PersonId,\n    target_person_ids: Vec<PersonId>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    // Build the list of persons\n    let mut persons = target_person_ids;\n    persons.push(admin_person_id);\n    persons.dedup();\n\n    let res = local_user::table\n      .filter(local_user::admin.eq(true))\n      .filter(local_user::person_id.eq_any(persons))\n      .order_by(local_user::id)\n      // This does a limit 1 select first\n      .first::<LocalUser>(conn)\n      .await?;\n\n    // If the first result sorted by published is the acting admin\n    if res.person_id == admin_person_id {\n      Ok(())\n    } else {\n      Err(LemmyErrorType::NotHigherAdmin.into())\n    }\n  }\n\n  /// Checks to make sure the acting moderator is higher than the target moderator\n  pub async fn is_higher_mod_or_admin_check(\n    pool: &mut DbPool<'_>,\n    for_community_id: CommunityId,\n    admin_person_id: PersonId,\n    target_person_ids: Vec<PersonId>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    // Build the list of persons\n    let mut persons = target_person_ids;\n    persons.push(admin_person_id);\n    persons.dedup();\n\n    let admins = local_user::table\n      .filter(local_user::admin.eq(true))\n      .filter(local_user::person_id.eq_any(&persons))\n      .order_by(local_user::id)\n      .select(local_user::person_id);\n\n    let mods = community_actions::table\n      .filter(community_actions::became_moderator_at.is_not_null())\n      .filter(community_actions::community_id.eq(for_community_id))\n      .filter(community_actions::person_id.eq_any(&persons))\n      .order_by(community_actions::became_moderator_at)\n      .select(community_actions::person_id);\n\n    let res = admins.union_all(mods).get_results::<PersonId>(conn).await?;\n    let first_person = res.as_slice().first().ok_or(LemmyErrorType::NotHigherMod)?;\n\n    // If the first result sorted by published is the acting mod\n    if *first_person == admin_person_id {\n      Ok(())\n    } else {\n      Err(LemmyErrorType::NotHigherMod.into())\n    }\n  }\n}\n\n/// Adds some helper functions for an optional LocalUser\npub trait LocalUserOptionHelper {\n  fn person_id(&self) -> Option<PersonId>;\n  fn local_user_id(&self) -> Option<LocalUserId>;\n  fn show_bot_accounts(&self) -> bool;\n  fn show_read_posts(&self) -> bool;\n  fn is_admin(&self) -> bool;\n  fn show_nsfw(&self, site: &Site) -> bool;\n  fn hide_media(&self) -> bool;\n  fn visible_communities_only<Q>(&self, query: Q) -> Q\n  where\n    Q: diesel::query_dsl::methods::FilterDsl<\n        diesel::dsl::Eq<community::visibility, CommunityVisibility>,\n        Output = Q,\n      >;\n}\n\nimpl LocalUserOptionHelper for Option<&LocalUser> {\n  fn person_id(&self) -> Option<PersonId> {\n    self.map(|l| l.person_id)\n  }\n\n  fn local_user_id(&self) -> Option<LocalUserId> {\n    self.map(|l| l.id)\n  }\n\n  fn show_bot_accounts(&self) -> bool {\n    self.map(|l| l.show_bot_accounts).unwrap_or(true)\n  }\n\n  fn show_read_posts(&self) -> bool {\n    self.map(|l| l.show_read_posts).unwrap_or(true)\n  }\n\n  fn is_admin(&self) -> bool {\n    self.map(|l| l.admin).unwrap_or(false)\n  }\n\n  fn show_nsfw(&self, site: &Site) -> bool {\n    self\n      .map(|l| l.show_nsfw)\n      .unwrap_or(site.content_warning.is_some())\n  }\n\n  fn hide_media(&self) -> bool {\n    self.map(|l| l.hide_media).unwrap_or(false)\n  }\n\n  // TODO: use this function for private community checks, but the generics get extremely confusing\n  fn visible_communities_only<Q>(&self, query: Q) -> Q\n  where\n    Q: diesel::query_dsl::methods::FilterDsl<\n        diesel::dsl::Eq<community::visibility, CommunityVisibility>,\n        Output = Q,\n      >,\n  {\n    if self.is_none() {\n      query.filter(community::visibility.eq(CommunityVisibility::Public))\n    } else {\n      query\n    }\n  }\n}\n\nimpl LocalUserInsertForm {\n  pub fn test_form(person_id: PersonId) -> Self {\n    Self::new(person_id, Some(String::new()))\n  }\n\n  pub fn test_form_admin(person_id: PersonId) -> Self {\n    LocalUserInsertForm {\n      admin: Some(true),\n      ..Self::test_form(person_id)\n    }\n  }\n}\n\npub struct UserBackupLists {\n  pub followed_communities: Vec<DbUrl>,\n  pub saved_posts: Vec<DbUrl>,\n  pub saved_comments: Vec<DbUrl>,\n  pub blocked_communities: Vec<DbUrl>,\n  pub blocked_users: Vec<DbUrl>,\n  pub blocked_instances_communities: Vec<String>,\n  pub blocked_instances_persons: Vec<String>,\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm},\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_admin_higher_check() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let fiona_person = PersonInsertForm::test_form(inserted_instance.id, \"fiona\");\n    let inserted_fiona_person = Person::create(pool, &fiona_person).await?;\n\n    let fiona_local_user_form = LocalUserInsertForm::test_form_admin(inserted_fiona_person.id);\n    let _inserted_fiona_local_user =\n      LocalUser::create(pool, &fiona_local_user_form, vec![]).await?;\n\n    let delores_person = PersonInsertForm::test_form(inserted_instance.id, \"delores\");\n    let inserted_delores_person = Person::create(pool, &delores_person).await?;\n    let delores_local_user_form = LocalUserInsertForm::test_form_admin(inserted_delores_person.id);\n    let _inserted_delores_local_user =\n      LocalUser::create(pool, &delores_local_user_form, vec![]).await?;\n\n    let admin_person_ids = vec![inserted_fiona_person.id, inserted_delores_person.id];\n\n    // Make sure fiona is marked as a higher admin than delores, and vice versa\n    let fiona_higher_check =\n      LocalUser::is_higher_admin_check(pool, inserted_fiona_person.id, admin_person_ids.clone())\n        .await;\n    assert!(fiona_higher_check.is_ok());\n\n    // This should throw an error, since delores was added later\n    let delores_higher_check =\n      LocalUser::is_higher_admin_check(pool, inserted_delores_person.id, admin_person_ids).await;\n    assert!(delores_higher_check.is_err());\n\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_email_taken() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let darwin_email = \"charles.darwin@gmail.com\";\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let darwin_person = PersonInsertForm::test_form(inserted_instance.id, \"darwin\");\n    let inserted_darwin_person = Person::create(pool, &darwin_person).await?;\n\n    let mut darwin_local_user_form =\n      LocalUserInsertForm::test_form_admin(inserted_darwin_person.id);\n    darwin_local_user_form.email = Some(darwin_email.into());\n    let _inserted_darwin_local_user =\n      LocalUser::create(pool, &darwin_local_user_form, vec![]).await?;\n\n    let check = LocalUser::check_is_email_taken(pool, darwin_email).await;\n    assert!(check.is_err());\n\n    let passed_check = LocalUser::check_is_email_taken(pool, \"not_charles@gmail.com\").await;\n    assert!(passed_check.is_ok());\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/login_token.rs",
    "content": "use crate::{\n  diesel::{ExpressionMethods, QueryDsl},\n  newtypes::LocalUserId,\n  source::login_token::{LoginToken, LoginTokenCreateForm},\n};\nuse diesel::{delete, dsl::exists, insert_into, select};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::login_token::{dsl::login_token, user_id};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LoginToken {\n  pub async fn create(pool: &mut DbPool<'_>, form: LoginTokenCreateForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(login_token)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  /// Check if the given token is valid for user.\n  pub async fn validate(\n    pool: &mut DbPool<'_>,\n    user_id_: LocalUserId,\n    token_: &str,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      login_token.find(token_).filter(user_id.eq(user_id_)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotLoggedIn.into())\n  }\n\n  pub async fn list(pool: &mut DbPool<'_>, user_id_: LocalUserId) -> LemmyResult<Vec<LoginToken>> {\n    let conn = &mut get_conn(pool).await?;\n\n    login_token\n      .filter(user_id.eq(user_id_))\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Invalidate specific token on user logout.\n  pub async fn invalidate(pool: &mut DbPool<'_>, token_: &str) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    delete(login_token.find(token_))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  /// Invalidate all logins of given user on password reset/change, or account deletion.\n  pub async fn invalidate_all(pool: &mut DbPool<'_>, user_id_: LocalUserId) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    delete(login_token.filter(user_id.eq(user_id_)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/mod.rs",
    "content": "pub mod activity;\npub mod actor_language;\npub mod comment;\npub mod comment_report;\npub mod community;\npub mod community_community_follow;\npub mod community_report;\npub mod community_tag;\npub mod custom_emoji;\npub mod email_verification;\npub mod federation_allowlist;\npub mod federation_blocklist;\npub mod federation_queue_state;\npub mod images;\npub mod instance;\npub mod keyword_block;\npub mod language;\npub mod local_site;\npub mod local_site_rate_limit;\npub mod local_site_url_blocklist;\npub mod local_user;\npub mod login_token;\npub mod modlog;\npub mod multi_community;\npub mod notification;\npub mod oauth_account;\npub mod oauth_provider;\npub mod password_reset_request;\npub mod person;\npub mod post;\npub mod post_report;\npub mod private_message;\npub mod private_message_report;\npub mod registration_application;\npub mod secret;\npub mod site;\npub mod tagline;\n"
  },
  {
    "path": "crates/db_schema/src/impls/modlog.rs",
    "content": "use crate::{\n  newtypes::{CommunityId, ModlogId},\n  source::{\n    comment::Comment,\n    modlog::{Modlog, ModlogInsertForm},\n    person::Person,\n    post::Post,\n  },\n};\nuse chrono::{DateTime, Utc};\nuse diesel::dsl::insert_into;\nuse diesel_async::RunQueryDsl;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::modlog;\nuse lemmy_db_schema_file::{InstanceId, PersonId, enums::ModlogKind};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Modlog {\n  pub async fn create<'a>(\n    pool: &mut DbPool<'_>,\n    form: &[ModlogInsertForm<'a>],\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(modlog::table)\n      .values(form)\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n}\n\nimpl<'a> ModlogInsertForm<'a> {\n  pub fn admin_ban(\n    mod_person: &Person,\n    target_person_id: PersonId,\n    banned: bool,\n    expires_at: Option<DateTime<Utc>>,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      expires_at,\n      target_person_id: Some(target_person_id),\n      target_instance_id: Some(mod_person.instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminBan, !banned, mod_person.id)\n    }\n  }\n  pub fn admin_add(mod_person: &Person, target_person_id: PersonId, added: bool) -> Self {\n    Self {\n      target_person_id: Some(target_person_id),\n      target_instance_id: Some(mod_person.instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminAdd, !added, mod_person.id)\n    }\n  }\n  pub fn mod_remove_post(\n    mod_person_id: PersonId,\n    post: &Post,\n    removed: bool,\n    reason: &'a str,\n    bulk_action_parent_id: Option<ModlogId>,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_post_id: Some(post.id),\n      target_community_id: Some(post.community_id),\n      target_person_id: Some(post.creator_id),\n      bulk_action_parent_id,\n      ..ModlogInsertForm::new(ModlogKind::ModRemovePost, !removed, mod_person_id)\n    }\n  }\n  pub fn mod_remove_comment(\n    mod_person_id: PersonId,\n    comment: &Comment,\n    community_id: CommunityId,\n    removed: bool,\n    reason: &'a str,\n    bulk_action_parent_id: Option<ModlogId>,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_comment_id: Some(comment.id),\n      target_post_id: Some(comment.post_id),\n      target_community_id: Some(community_id),\n      target_person_id: Some(comment.creator_id),\n      bulk_action_parent_id,\n      ..ModlogInsertForm::new(ModlogKind::ModRemoveComment, !removed, mod_person_id)\n    }\n  }\n  pub fn mod_lock_comment(\n    mod_person_id: PersonId,\n    comment: &Comment,\n    community_id: CommunityId,\n    removed: bool,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_comment_id: Some(comment.id),\n      target_post_id: Some(comment.post_id),\n      target_community_id: Some(community_id),\n      target_person_id: Some(comment.creator_id),\n      ..ModlogInsertForm::new(ModlogKind::ModLockComment, !removed, mod_person_id)\n    }\n  }\n  pub fn mod_lock_post(\n    mod_person_id: PersonId,\n    post: &Post,\n    locked: bool,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_post_id: Some(post.id),\n      target_community_id: Some(post.community_id),\n      target_person_id: Some(post.creator_id),\n      ..ModlogInsertForm::new(ModlogKind::ModLockPost, !locked, mod_person_id)\n    }\n  }\n  pub fn mod_create_comment_warning(\n    mod_person_id: PersonId,\n    comment: &Comment,\n    community_id: CommunityId,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_comment_id: Some(comment.id),\n      target_post_id: Some(comment.post_id),\n      target_community_id: Some(community_id),\n      target_person_id: Some(comment.creator_id),\n      ..ModlogInsertForm::new(ModlogKind::ModWarnComment, false, mod_person_id)\n    }\n  }\n  pub fn mod_create_post_warning(mod_person_id: PersonId, post: &Post, reason: &'a str) -> Self {\n    Self {\n      reason: Some(reason),\n      target_post_id: Some(post.id),\n      target_community_id: Some(post.community_id),\n      target_person_id: Some(post.creator_id),\n      ..ModlogInsertForm::new(ModlogKind::ModWarnPost, false, mod_person_id)\n    }\n  }\n  pub fn admin_remove_community(\n    mod_person: &Person,\n    community_id: CommunityId,\n    community_owner_id: Option<PersonId>,\n    removed: bool,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_community_id: Some(community_id),\n      target_person_id: community_owner_id,\n      target_instance_id: Some(mod_person.instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminRemoveCommunity, !removed, mod_person.id)\n    }\n  }\n\n  pub fn mod_change_community_visibility(\n    mod_person_id: PersonId,\n    community_id: CommunityId,\n  ) -> Self {\n    Self {\n      target_community_id: Some(community_id),\n      ..ModlogInsertForm::new(\n        ModlogKind::ModChangeCommunityVisibility,\n        false,\n        mod_person_id,\n      )\n    }\n  }\n  pub fn mod_ban_from_community(\n    mod_person_id: PersonId,\n    community_id: CommunityId,\n    target_person_id: PersonId,\n    removed: bool,\n    expires_at: Option<DateTime<Utc>>,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      expires_at,\n      target_community_id: Some(community_id),\n      target_person_id: Some(target_person_id),\n      ..ModlogInsertForm::new(ModlogKind::ModBanFromCommunity, !removed, mod_person_id)\n    }\n  }\n  pub fn mod_add_to_community(\n    mod_person_id: PersonId,\n    community_id: CommunityId,\n    target_person_id: PersonId,\n    added: bool,\n  ) -> Self {\n    Self {\n      target_community_id: Some(community_id),\n      target_person_id: Some(target_person_id),\n      ..ModlogInsertForm::new(ModlogKind::ModAddToCommunity, !added, mod_person_id)\n    }\n  }\n  pub fn mod_transfer_community(\n    mod_person_id: PersonId,\n    community_id: CommunityId,\n    target_person_id: PersonId,\n  ) -> Self {\n    Self {\n      target_community_id: Some(community_id),\n      target_person_id: Some(target_person_id),\n      ..ModlogInsertForm::new(ModlogKind::ModTransferCommunity, false, mod_person_id)\n    }\n  }\n  pub fn admin_allow_instance(\n    mod_person_id: PersonId,\n    instance_id: InstanceId,\n    allow: bool,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_instance_id: Some(instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminAllowInstance, !allow, mod_person_id)\n    }\n  }\n  pub fn admin_block_instance(\n    mod_person_id: PersonId,\n    instance_id: InstanceId,\n    block: bool,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      reason: Some(reason),\n      target_instance_id: Some(instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminBlockInstance, !block, mod_person_id)\n    }\n  }\n  pub fn admin_purge_comment(\n    mod_person_id: PersonId,\n    comment: &Comment,\n    community_id: CommunityId,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      target_post_id: Some(comment.post_id),\n      target_person_id: Some(comment.creator_id),\n      target_community_id: Some(community_id),\n      reason: Some(reason),\n      ..ModlogInsertForm::new(ModlogKind::AdminPurgeComment, false, mod_person_id)\n    }\n  }\n  pub fn admin_purge_post(\n    mod_person_id: PersonId,\n    community_id: CommunityId,\n    reason: &'a str,\n  ) -> Self {\n    Self {\n      target_community_id: Some(community_id),\n      reason: Some(reason),\n      ..ModlogInsertForm::new(ModlogKind::AdminPurgePost, false, mod_person_id)\n    }\n  }\n  pub fn admin_purge_community(mod_person_id: PersonId, reason: &'a str) -> Self {\n    Self {\n      reason: Some(reason),\n      ..ModlogInsertForm::new(ModlogKind::AdminPurgeCommunity, false, mod_person_id)\n    }\n  }\n  pub fn admin_purge_person(mod_person_id: PersonId, reason: &'a str) -> Self {\n    Self {\n      reason: Some(reason),\n      ..ModlogInsertForm::new(ModlogKind::AdminPurgePerson, false, mod_person_id)\n    }\n  }\n  pub fn mod_feature_post_community(mod_person_id: PersonId, post: &Post, featured: bool) -> Self {\n    Self {\n      target_post_id: Some(post.id),\n      target_community_id: Some(post.community_id),\n      ..ModlogInsertForm::new(\n        ModlogKind::ModFeaturePostCommunity,\n        !featured,\n        mod_person_id,\n      )\n    }\n  }\n  pub fn admin_feature_post_site(mod_person: &Person, post: &Post, featured: bool) -> Self {\n    Self {\n      target_post_id: Some(post.id),\n      target_community_id: Some(post.community_id),\n      target_instance_id: Some(mod_person.instance_id),\n      ..ModlogInsertForm::new(ModlogKind::AdminFeaturePostSite, !featured, mod_person.id)\n    }\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/multi_community.rs",
    "content": "use crate::{\n  diesel::{BoolExpressionMethods, OptionalExtension, PgExpressionMethods, SelectableHelper},\n  newtypes::{CommunityId, MultiCommunityId},\n  source::{\n    community::Community,\n    multi_community::{\n      MultiCommunity,\n      MultiCommunityEntry,\n      MultiCommunityEntryForm,\n      MultiCommunityFollow,\n      MultiCommunityFollowForm,\n      MultiCommunityInsertForm,\n      MultiCommunityUpdateForm,\n    },\n  },\n  traits::ApubActor,\n  utils::format_actor_url,\n};\nuse diesel::{\n  ExpressionMethods,\n  QueryDsl,\n  dsl::{delete, exists, insert_into, not},\n  select,\n  update,\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{\n    community,\n    instance,\n    multi_community,\n    multi_community_entry,\n    multi_community_follow,\n    person,\n  },\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::lower,\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nconst MULTI_COMMUNITY_ENTRY_LIMIT: i8 = 50;\n\nimpl Crud for MultiCommunity {\n  type InsertForm = MultiCommunityInsertForm;\n  type UpdateForm = MultiCommunityUpdateForm;\n  type IdType = MultiCommunityId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(multi_community::table)\n      .values(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    id: MultiCommunityId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    update(multi_community::table.find(id))\n      .set(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl MultiCommunity {\n  pub async fn upsert(pool: &mut DbPool<'_>, form: &MultiCommunityInsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(multi_community::table)\n      .values(form)\n      .on_conflict(multi_community::ap_id)\n      .do_update()\n      .set(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn follow(\n    pool: &mut DbPool<'_>,\n    form: &MultiCommunityFollowForm,\n  ) -> LemmyResult<MultiCommunityFollow> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(multi_community_follow::table)\n      .values(form)\n      .on_conflict((\n        multi_community_follow::multi_community_id,\n        multi_community_follow::person_id,\n      ))\n      .do_update()\n      .set(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn unfollow(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    multi_community_id: MultiCommunityId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    delete(\n      multi_community_follow::table\n        .filter(multi_community_follow::multi_community_id.eq(multi_community_id))\n        .filter(multi_community_follow::person_id.eq(person_id)),\n    )\n    .execute(conn)\n    .await?;\n\n    Ok(())\n  }\n\n  pub async fn follower_inboxes(\n    pool: &mut DbPool<'_>,\n    multi_community_id: MultiCommunityId,\n  ) -> LemmyResult<Vec<DbUrl>> {\n    let conn = &mut get_conn(pool).await?;\n\n    multi_community_follow::table\n      .inner_join(person::table)\n      .filter(multi_community_follow::multi_community_id.eq(multi_community_id))\n      .select(person::inbox_url)\n      .distinct()\n      .get_results(conn)\n      .await\n      .optional()?\n      .ok_or(LemmyErrorType::NotFound.into())\n  }\n\n  /// Should be called in a transaction together with update() or upsert()\n  pub async fn update_entries(\n    pool: &mut DbPool<'_>,\n    id: MultiCommunityId,\n    new_communities: &Vec<CommunityId>,\n  ) -> LemmyResult<(Vec<Community>, Vec<Community>, bool)> {\n    let conn = &mut get_conn(pool).await?;\n    if new_communities.len() >= usize::try_from(MULTI_COMMUNITY_ENTRY_LIMIT)? {\n      return Err(LemmyErrorType::MultiCommunityEntryLimitReached.into());\n    }\n\n    let removed: Vec<CommunityId> = delete(\n      multi_community_entry::table\n        .filter(multi_community_entry::multi_community_id.eq(id))\n        .filter(multi_community_entry::community_id.ne_all(new_communities)),\n    )\n    .returning(multi_community_entry::community_id)\n    .get_results::<CommunityId>(conn)\n    .await?;\n\n    let removed: Vec<Community> = community::table\n      .filter(community::id.eq_any(removed))\n      .filter(not(community::local))\n      .get_results(conn)\n      .await?;\n\n    let forms = new_communities\n      .iter()\n      .map(|community_id| MultiCommunityEntryForm {\n        multi_community_id: id,\n        community_id: *community_id,\n      })\n      .collect::<Vec<_>>();\n\n    let added: Vec<_> = insert_into(multi_community_entry::table)\n      .values(forms)\n      .on_conflict_do_nothing()\n      .returning(multi_community_entry::community_id)\n      .get_results::<CommunityId>(conn)\n      .await?;\n\n    let added: Vec<Community> = community::table\n      .filter(community::id.eq_any(added))\n      .filter(not(community::local))\n      .get_results(conn)\n      .await?;\n\n    // check if any local user follows the multi-comm\n    let has_local_followers: bool = select(exists(\n      multi_community_follow::table\n        .inner_join(person::table)\n        .inner_join(multi_community::table)\n        .filter(person::local),\n    ))\n    .get_result(conn)\n    .await?;\n\n    Ok((added, removed, has_local_followers))\n  }\n\n  pub async fn read_community_ap_ids(\n    pool: &mut DbPool<'_>,\n    multi_name: &str,\n  ) -> LemmyResult<Vec<DbUrl>> {\n    let conn = &mut get_conn(pool).await?;\n\n    multi_community::table\n      .inner_join(multi_community_entry::table.inner_join(community::table))\n      .filter(\n        community::removed\n          .or(community::deleted)\n          .is_distinct_from(true),\n      )\n      .filter(multi_community::name.eq(multi_name))\n      .select(community::ap_id)\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl ApubActor for MultiCommunity {\n  async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: &DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    multi_community::table\n      .filter(lower(multi_community::ap_id).eq(object_id.to_lowercase()))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  async fn read_from_name(\n    pool: &mut DbPool<'_>,\n    name: &str,\n    domain: Option<&str>,\n    include_deleted: bool,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let mut q = multi_community::table\n      .inner_join(instance::table)\n      .filter(lower(multi_community::name).eq(name.to_lowercase()))\n      .select(MultiCommunity::as_select())\n      .into_boxed();\n    if !include_deleted {\n      q = q.filter(multi_community::deleted.eq(false))\n    }\n    if let Some(domain) = domain {\n      q = q.filter(lower(instance::domain).eq(domain.to_lowercase()))\n    } else {\n      q = q.filter(multi_community::local.eq(true))\n    }\n    q.first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  fn actor_url(&self, settings: &Settings) -> LemmyResult<Url> {\n    let domain = self\n      .ap_id\n      .inner()\n      .domain()\n      .ok_or(LemmyErrorType::NotFound)?;\n\n    format_actor_url(&self.name, domain, 'm', settings)\n  }\n\n  fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/m/{name}\"))?.into())\n  }\n}\n\nimpl MultiCommunityEntry {\n  pub async fn create(pool: &mut DbPool<'_>, form: &MultiCommunityEntryForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(multi_community_entry::table)\n      .values(form)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn delete(pool: &mut DbPool<'_>, form: &MultiCommunityEntryForm) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    delete(\n      multi_community_entry::table\n        .filter(multi_community_entry::multi_community_id.eq(form.multi_community_id))\n        .filter(multi_community_entry::community_id.eq(form.community_id)),\n    )\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n\n  /// Make sure you aren't trying to insert more communities than the entry limit allows.\n  pub async fn check_entry_limit(\n    pool: &mut DbPool<'_>,\n    multi_community_id: MultiCommunityId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n\n    let count: i64 = multi_community_entry::table\n      .filter(multi_community_entry::multi_community_id.eq(multi_community_id))\n      .count()\n      .get_result(conn)\n      .await?;\n\n    if count >= MULTI_COMMUNITY_ENTRY_LIMIT.into() {\n      Err(LemmyErrorType::MultiCommunityEntryLimitReached.into())\n    } else {\n      Ok(())\n    }\n  }\n\n  pub async fn community_used_in_multiple(\n    pool: &mut DbPool<'_>,\n    form: &MultiCommunityEntryForm,\n  ) -> LemmyResult<bool> {\n    let conn = &mut get_conn(pool).await?;\n\n    select(exists(\n      multi_community_entry::table\n        .filter(multi_community_entry::multi_community_id.ne(form.multi_community_id))\n        .filter(multi_community_entry::community_id.eq(form.community_id)),\n    ))\n    .get_result(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::source::{\n    community::{Community, CommunityInsertForm},\n    instance::Instance,\n    multi_community::{MultiCommunity, MultiCommunityInsertForm},\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_db_schema_file::enums::CommunityFollowerState;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    multi: MultiCommunity,\n    instance: Instance,\n    community: Community,\n    person: Person,\n  }\n\n  async fn setup(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let form = PersonInsertForm::test_form(instance.id, \"bobby\");\n    let person = Person::create(pool, &form).await?;\n\n    let form = CommunityInsertForm::new(\n      instance.id,\n      \"TIL\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &form).await?;\n\n    let form =\n      MultiCommunityInsertForm::new(person.id, instance.id, \"multi\".to_string(), String::new());\n    let multi = MultiCommunity::create(pool, &form).await?;\n    assert_eq!(form.creator_id, multi.creator_id);\n    assert_eq!(form.name, multi.name);\n\n    Ok(Data {\n      multi,\n      instance,\n      community,\n      person,\n    })\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_counts() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = setup(pool).await?;\n\n    // Make sure there are no counts in the current multi.\n    assert_eq!(0, data.multi.subscribers);\n    assert_eq!(0, data.multi.subscribers_local);\n    assert_eq!(0, data.multi.communities);\n\n    // Insert a community entry\n    let entry_form = MultiCommunityEntryForm {\n      multi_community_id: data.multi.id,\n      community_id: data.community.id,\n    };\n    MultiCommunityEntry::create(pool, &entry_form).await?;\n\n    let after_entry_insert = MultiCommunity::read(pool, data.multi.id).await?;\n    assert_eq!(1, after_entry_insert.communities);\n\n    MultiCommunityEntry::delete(pool, &entry_form).await?;\n    let after_entry_delete = MultiCommunity::read(pool, data.multi.id).await?;\n    assert_eq!(0, after_entry_delete.communities);\n\n    let pending_follow_form = MultiCommunityFollowForm {\n      multi_community_id: data.multi.id,\n      person_id: data.person.id,\n      follow_state: CommunityFollowerState::Pending,\n    };\n    MultiCommunity::follow(pool, &pending_follow_form).await?;\n    let after_pending_follow = MultiCommunity::read(pool, data.multi.id).await?;\n    // Should be 0, since its a pending follow, not approved\n    assert_eq!(0, after_pending_follow.subscribers);\n    assert_eq!(0, after_pending_follow.subscribers_local);\n\n    // Unfollow (deletes the row), the count should not decrement\n    MultiCommunity::unfollow(pool, data.person.id, data.multi.id).await?;\n    let after_unfollow = MultiCommunity::read(pool, data.multi.id).await?;\n    assert_eq!(0, after_unfollow.subscribers);\n    assert_eq!(0, after_unfollow.subscribers_local);\n\n    let accepted_follow_form = MultiCommunityFollowForm {\n      multi_community_id: data.multi.id,\n      person_id: data.person.id,\n      follow_state: CommunityFollowerState::Accepted,\n    };\n    MultiCommunity::follow(pool, &accepted_follow_form).await?;\n    let after_accepted_follow = MultiCommunity::read(pool, data.multi.id).await?;\n    assert_eq!(1, after_accepted_follow.subscribers);\n    assert_eq!(1, after_accepted_follow.subscribers_local);\n\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_multi_community_apub() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = setup(pool).await?;\n\n    let multi_read_apub_empty =\n      MultiCommunity::read_community_ap_ids(pool, &data.multi.name).await?;\n    assert!(multi_read_apub_empty.is_empty());\n\n    let multi_entries = vec![data.community.id];\n    MultiCommunity::update_entries(pool, data.multi.id, &multi_entries).await?;\n\n    let multi_read_apub = MultiCommunity::read_community_ap_ids(pool, &data.multi.name).await?;\n    assert_eq!(vec![data.community.ap_id], multi_read_apub);\n\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/notification.rs",
    "content": "use crate::{\n  newtypes::{CommentId, NotificationId, PostId},\n  source::notification::{Notification, NotificationInsertForm},\n};\nuse diesel::{\n  ExpressionMethods,\n  QueryDsl,\n  delete,\n  dsl::{insert_into, update},\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{PersonId, schema::notification};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Notification {\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    form: &[NotificationInsertForm],\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(notification::table)\n      .values(form)\n      .on_conflict_do_nothing()\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn mark_read_by_comment_and_recipient(\n    pool: &mut DbPool<'_>,\n    comment_id: CommentId,\n    recipient_id: PersonId,\n    read: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      notification::table\n        .filter(notification::comment_id.eq(comment_id))\n        .filter(notification::recipient_id.eq(recipient_id)),\n    )\n    .set(notification::read.eq(read))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn mark_read_by_post_and_recipient(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    recipient_id: PersonId,\n    read: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      notification::table\n        .filter(notification::post_id.eq(post_id))\n        .filter(notification::recipient_id.eq(recipient_id)),\n    )\n    .set(notification::read.eq(read))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn mark_all_as_read(\n    pool: &mut DbPool<'_>,\n    for_recipient_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(\n      notification::table\n        .filter(notification::recipient_id.eq(for_recipient_id))\n        .filter(notification::read.eq(false)),\n    )\n    .set(notification::read.eq(true))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn mark_read_by_id_and_person(\n    pool: &mut DbPool<'_>,\n    notification_id: NotificationId,\n    recipient_id: PersonId,\n    read: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      notification::table\n        .filter(notification::id.eq(notification_id))\n        .filter(notification::recipient_id.eq(recipient_id)),\n    )\n    .set(notification::read.eq(read))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Only for tests\n  pub async fn delete(pool: &mut DbPool<'_>, id: NotificationId) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    delete(notification::table.filter(notification::id.eq(id)))\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/oauth_account.rs",
    "content": "use crate::{\n  newtypes::LocalUserId,\n  source::oauth_account::{OAuthAccount, OAuthAccountInsertForm},\n};\nuse diesel::{ExpressionMethods, QueryDsl, insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::{oauth_account, oauth_account::dsl::local_user_id};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl OAuthAccount {\n  pub async fn create(pool: &mut DbPool<'_>, form: &OAuthAccountInsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(oauth_account::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn delete_user_accounts(\n    pool: &mut DbPool<'_>,\n    for_local_user_id: LocalUserId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    diesel::delete(oauth_account::table.filter(local_user_id.eq(for_local_user_id)))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/oauth_provider.rs",
    "content": "use crate::{\n  newtypes::OAuthProviderId,\n  source::oauth_provider::{\n    AdminOAuthProvider,\n    OAuthProviderInsertForm,\n    OAuthProviderUpdateForm,\n    PublicOAuthProvider,\n  },\n};\nuse diesel::{QueryDsl, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::oauth_provider;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Crud for AdminOAuthProvider {\n  type InsertForm = OAuthProviderInsertForm;\n  type UpdateForm = OAuthProviderUpdateForm;\n  type IdType = OAuthProviderId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(oauth_provider::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    oauth_provider_id: OAuthProviderId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(oauth_provider::table.find(oauth_provider_id))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl AdminOAuthProvider {\n  pub async fn get_all(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    oauth_provider::table\n      .order(oauth_provider::id)\n      .select(oauth_provider::all_columns)\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub fn convert_providers_to_public(\n    oauth_providers: Vec<AdminOAuthProvider>,\n  ) -> Vec<PublicOAuthProvider> {\n    oauth_providers\n      .into_iter()\n      .filter(|x| x.enabled)\n      .map(|p| PublicOAuthProvider {\n        id: p.id,\n        display_name: p.display_name,\n        authorization_endpoint: p.authorization_endpoint,\n        client_id: p.client_id,\n        scopes: p.scopes,\n        use_pkce: p.use_pkce,\n      })\n      .collect()\n  }\n\n  pub async fn get_all_public(pool: &mut DbPool<'_>) -> LemmyResult<Vec<PublicOAuthProvider>> {\n    AdminOAuthProvider::get_all(pool)\n      .await\n      .map(Self::convert_providers_to_public)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/password_reset_request.rs",
    "content": "use crate::{\n  newtypes::LocalUserId,\n  source::password_reset_request::{PasswordResetRequest, PasswordResetRequestForm},\n};\nuse diesel::{\n  ExpressionMethods,\n  IntoSql,\n  delete,\n  dsl::{IntervalDsl, insert_into, now},\n  sql_types::Timestamptz,\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::password_reset_request;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl PasswordResetRequest {\n  pub async fn create(\n    pool: &mut DbPool<'_>,\n    local_user_id: LocalUserId,\n    token_: String,\n  ) -> LemmyResult<PasswordResetRequest> {\n    let form = PasswordResetRequestForm {\n      local_user_id,\n      token: token_.into(),\n    };\n    let conn = &mut get_conn(pool).await?;\n    insert_into(password_reset_request::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn read_and_delete(pool: &mut DbPool<'_>, token_: &str) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    delete(password_reset_request::table)\n      .filter(password_reset_request::token.eq(token_))\n      .filter(password_reset_request::published_at.gt(now.into_sql::<Timestamptz>() - 1.days()))\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::Deleted)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm},\n    password_reset_request::PasswordResetRequest,\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_password_reset() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // Setup\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"thommy prw\");\n    let inserted_person = Person::create(pool, &new_person).await?;\n    let new_local_user = LocalUserInsertForm::test_form(inserted_person.id);\n    let inserted_local_user = LocalUser::create(pool, &new_local_user, vec![]).await?;\n\n    // Create password reset token\n    let token = \"nope\";\n    let inserted_password_reset_request =\n      PasswordResetRequest::create(pool, inserted_local_user.id, token.to_string()).await?;\n\n    // Read it and verify\n    let read_password_reset_request = PasswordResetRequest::read_and_delete(pool, token).await?;\n    assert_eq!(\n      inserted_password_reset_request.id,\n      read_password_reset_request.id\n    );\n    assert_eq!(\n      inserted_password_reset_request.local_user_id,\n      read_password_reset_request.local_user_id\n    );\n    assert_eq!(\n      inserted_password_reset_request.token,\n      read_password_reset_request.token\n    );\n    assert_eq!(\n      inserted_password_reset_request.published_at,\n      read_password_reset_request.published_at\n    );\n\n    // Cannot reuse same token again\n    let read_password_reset_request = PasswordResetRequest::read_and_delete(pool, token).await;\n    assert!(read_password_reset_request.is_err());\n\n    // Cleanup\n    let num_deleted = Person::delete(pool, inserted_person.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n    assert_eq!(1, num_deleted);\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/person.rs",
    "content": "use crate::{\n  diesel::{BoolExpressionMethods, NullableExpressionMethods, OptionalExtension},\n  newtypes::{CommunityId, LocalUserId},\n  source::person::{\n    Person,\n    PersonActions,\n    PersonBlockForm,\n    PersonFollowerForm,\n    PersonInsertForm,\n    PersonNoteForm,\n    PersonUpdateForm,\n  },\n  traits::{ApubActor, Blockable, Followable},\n  utils::format_actor_url,\n};\nuse chrono::Utc;\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  QueryDsl,\n  dsl::{exists, insert_into, not, select},\n  expression::SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_uplete::{UpleteCount, uplete};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  schema::{instance, instance_actions, local_user, person, person_actions},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::lower,\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nimpl Crud for Person {\n  type InsertForm = PersonInsertForm;\n  type UpdateForm = PersonUpdateForm;\n  type IdType = PersonId;\n\n  // Override this, so that you don't get back deleted\n  async fn read(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    person::table\n      .filter(person::deleted.eq(false))\n      .find(person_id)\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  async fn create(pool: &mut DbPool<'_>, form: &PersonInsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(person::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n  async fn update(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    form: &PersonUpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(person::table.find(person_id))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Person {\n  /// Update or insert the person.\n  ///\n  /// This is necessary for federation, because Activitypub doesn't distinguish between these\n  /// actions.\n  pub async fn upsert(pool: &mut DbPool<'_>, form: &PersonInsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(person::table)\n      .values(form)\n      .on_conflict(person::ap_id)\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn delete_account(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    local_instance_id: InstanceId,\n  ) -> LemmyResult<Person> {\n    let conn = &mut get_conn(pool).await?;\n\n    // Set the local user email to none, only if they aren't banned locally.\n    let instance_actions_join = instance_actions::table.on(\n      instance_actions::person_id\n        .eq(person_id)\n        .and(instance_actions::instance_id.eq(local_instance_id)),\n    );\n\n    let not_banned_local_user_id = local_user::table\n      .left_join(instance_actions_join)\n      .filter(local_user::person_id.eq(person_id))\n      .filter(instance_actions::received_ban_at.nullable().is_null())\n      .select(local_user::id)\n      .first::<LocalUserId>(conn)\n      .await\n      .optional()?;\n\n    if let Some(local_user_id) = not_banned_local_user_id {\n      diesel::update(local_user::table.find(local_user_id))\n        .set(local_user::email.eq::<Option<String>>(None))\n        .execute(conn)\n        .await?;\n    };\n\n    diesel::update(person::table.find(person_id))\n      .set((\n        person::display_name.eq::<Option<String>>(None),\n        person::avatar.eq::<Option<String>>(None),\n        person::banner.eq::<Option<String>>(None),\n        person::bio.eq::<Option<String>>(None),\n        person::matrix_user_id.eq::<Option<String>>(None),\n        person::deleted.eq(true),\n        person::updated_at.eq(Utc::now()),\n      ))\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn check_username_taken(pool: &mut DbPool<'_>, username: &str) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(not(exists(\n      person::table\n        .filter(lower(person::name).eq(username.to_lowercase()))\n        .filter(person::local.eq(true)),\n    )))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::UsernameAlreadyTaken.into())\n  }\n}\n\nimpl PersonInsertForm {\n  pub fn test_form(instance_id: InstanceId, name: &str) -> Self {\n    Self::new(name.to_owned(), \"pubkey\".to_string(), instance_id)\n  }\n}\n\nimpl ApubActor for Person {\n  async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: &DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    person::table\n      .filter(lower(person::ap_id).eq(object_id.to_lowercase()))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  async fn read_from_name(\n    pool: &mut DbPool<'_>,\n    from_name: &str,\n    domain: Option<&str>,\n    include_deleted: bool,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let mut q = person::table\n      .inner_join(instance::table)\n      .into_boxed()\n      .filter(lower(person::name).eq(from_name.to_lowercase()))\n      .select(person::all_columns);\n    if !include_deleted {\n      q = q.filter(person::deleted.eq(false))\n    }\n    if let Some(domain) = domain {\n      q = q.filter(lower(instance::domain).eq(domain.to_lowercase()))\n    } else {\n      q = q.filter(person::local.eq(true))\n    }\n    q.first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  fn actor_url(&self, settings: &Settings) -> LemmyResult<Url> {\n    let domain = self\n      .ap_id\n      .inner()\n      .domain()\n      .ok_or(LemmyErrorType::NotFound)?;\n\n    format_actor_url(&self.name, domain, 'u', settings)\n  }\n\n  fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/u/{name}\"))?.into())\n  }\n}\n\nimpl Followable for PersonActions {\n  type Form = PersonFollowerForm;\n  type IdType = PersonId;\n\n  async fn follow(pool: &mut DbPool<'_>, form: &PersonFollowerForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(person_actions::table)\n      .values(form)\n      .on_conflict((person_actions::person_id, person_actions::target_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  /// Currently no user following\n  async fn follow_accepted(_: &mut DbPool<'_>, _: CommunityId, _: PersonId) -> LemmyResult<Self> {\n    Err(LemmyErrorType::NotFound.into())\n  }\n\n  async fn unfollow(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    target_id: Self::IdType,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(person_actions::table.find((person_id, target_id)))\n      .set_null(person_actions::followed_at)\n      .set_null(person_actions::follow_pending)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n}\n\nimpl Blockable for PersonActions {\n  type Form = PersonBlockForm;\n  type ObjectIdType = PersonId;\n  type ObjectType = Person;\n\n  async fn block(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(person_actions::table)\n      .values(form)\n      .on_conflict((person_actions::person_id, person_actions::target_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  async fn unblock(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(person_actions::table.find((form.person_id, form.target_id)))\n      .set_null(person_actions::blocked_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::AlreadyExists)\n  }\n\n  async fn read_block(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    recipient_id: Self::ObjectIdType,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = person_actions::table\n      .find((person_id, recipient_id))\n      .filter(person_actions::blocked_at.is_not_null());\n\n    select(not(exists(find_action)))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(LemmyErrorType::PersonIsBlocked.into())\n  }\n\n  async fn read_blocks_for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Vec<Self::ObjectType>> {\n    let conn = &mut get_conn(pool).await?;\n    let target_person_alias = diesel::alias!(person as person1);\n\n    person_actions::table\n      .filter(person_actions::blocked_at.is_not_null())\n      .inner_join(person::table.on(person_actions::person_id.eq(person::id)))\n      .inner_join(\n        target_person_alias.on(person_actions::target_id.eq(target_person_alias.field(person::id))),\n      )\n      .select(target_person_alias.fields(person::all_columns))\n      .filter(person_actions::person_id.eq(person_id))\n      .filter(target_person_alias.field(person::deleted).eq(false))\n      .order_by(person_actions::blocked_at)\n      .load::<Person>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl PersonActions {\n  pub async fn follower_inboxes(\n    pool: &mut DbPool<'_>,\n    for_person_id: PersonId,\n  ) -> LemmyResult<Vec<DbUrl>> {\n    let conn = &mut get_conn(pool).await?;\n    person_actions::table\n      .filter(person_actions::followed_at.is_not_null())\n      .inner_join(person::table.on(person_actions::person_id.eq(person::id)))\n      .filter(person_actions::target_id.eq(for_person_id))\n      .select(person::inbox_url)\n      .distinct()\n      .load(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn note(pool: &mut DbPool<'_>, form: &PersonNoteForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(person_actions::table)\n      .values(form)\n      .on_conflict((person_actions::person_id, person_actions::target_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn delete_note(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    target_id: PersonId,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(person_actions::table.find((person_id, target_id)))\n      .set_null(person_actions::note)\n      .set_null(person_actions::noted_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn like(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    target_id: PersonId,\n    previous_vote_is_upvote: Option<bool>,\n    current_vote_is_upvote: Option<bool>,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    // here\n    let (upvotes_inc, downvotes_inc) = match (previous_vote_is_upvote, current_vote_is_upvote) {\n      (None, Some(true)) => (1, 0),\n      (None, Some(false)) => (0, 1),\n      (Some(true), Some(false)) => (-1, 1),\n      (Some(false), Some(true)) => (1, -1),\n      (Some(true), None) => (-1, 0),\n      (Some(false), None) => (0, -1),\n      _ => (0, 0),\n    };\n\n    let voted_at = Utc::now();\n\n    insert_into(person_actions::table)\n      .values((\n        person_actions::person_id.eq(person_id),\n        person_actions::target_id.eq(target_id),\n        person_actions::voted_at.eq(voted_at),\n        person_actions::upvotes.eq(upvotes_inc),\n        person_actions::downvotes.eq(downvotes_inc),\n      ))\n      .on_conflict((person_actions::person_id, person_actions::target_id))\n      .do_update()\n      .set((\n        person_actions::person_id.eq(person_id),\n        person_actions::target_id.eq(target_id),\n        person_actions::voted_at.eq(voted_at),\n        person_actions::upvotes.eq(person_actions::upvotes + upvotes_inc),\n        person_actions::downvotes.eq(person_actions::downvotes + downvotes_inc),\n      ))\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::{\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm},\n      community::{Community, CommunityInsertForm},\n      person::{Person, PersonActions, PersonFollowerForm, PersonInsertForm, PersonUpdateForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    },\n    test_data::TestData,\n    traits::{Followable, Likeable},\n  };\n  use diesel_uplete::UpleteCount;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = TestData::create(pool).await?;\n\n    let expected_person = Person {\n      id: data.person.id,\n      name: \"holly\".into(),\n      display_name: None,\n      avatar: None,\n      banner: None,\n      deleted: false,\n      published_at: data.person.published_at,\n      updated_at: None,\n      ap_id: data.person.ap_id.clone(),\n      bio: None,\n      local: true,\n      bot_account: false,\n      private_key: None,\n      public_key: \"pubkey\".to_owned(),\n      last_refreshed_at: data.person.published_at,\n      inbox_url: data.person.inbox_url.clone(),\n      matrix_user_id: None,\n      instance_id: data.instance.id,\n      post_count: 0,\n      post_score: 0,\n      comment_count: 0,\n      comment_score: 0,\n    };\n\n    let read_person = Person::read(pool, data.person.id).await?;\n\n    let update_person_form = PersonUpdateForm {\n      ap_id: Some(data.person.ap_id.clone()),\n      ..Default::default()\n    };\n    let updated_person = Person::update(pool, data.person.id, &update_person_form).await?;\n\n    assert_eq!(expected_person, read_person);\n    assert_eq!(expected_person, data.person);\n    assert_eq!(expected_person, updated_person);\n\n    let num_deleted = Person::delete(pool, data.person.id).await?;\n\n    assert_eq!(1, num_deleted);\n\n    data.delete(pool).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn follow() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = TestData::create(pool).await?;\n\n    let person_form_2 = PersonInsertForm::test_form(data.instance.id, \"michele\");\n    let person_2 = Person::create(pool, &person_form_2).await?;\n\n    let follow_form = PersonFollowerForm::new(data.person.id, person_2.id, false);\n    let person_follower = PersonActions::follow(pool, &follow_form).await?;\n    assert_eq!(data.person.id, person_follower.target_id);\n    assert_eq!(person_2.id, person_follower.person_id);\n    assert!(person_follower.follow_pending.is_some_and(|x| !x));\n\n    let followers = PersonActions::follower_inboxes(pool, data.person.id).await?;\n    assert_eq!(vec![person_2.inbox_url], followers);\n\n    let unfollow =\n      PersonActions::unfollow(pool, follow_form.person_id, follow_form.target_id).await?;\n    assert_eq!(UpleteCount::only_deleted(1), unfollow);\n\n    data.delete(pool).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = TestData::create(pool).await?;\n\n    let another_person = PersonInsertForm::test_form(data.instance.id, \"jerry_user_agg\");\n    let another_inserted_person = Person::create(pool, &another_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      data.instance.id,\n      \"TIL_site_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\"A test post\".into(), data.person.id, inserted_community.id);\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let post_like = PostLikeForm::new(inserted_post.id, data.person.id, Some(true));\n    let _inserted_post_like = PostActions::like(pool, &post_like).await?;\n\n    let comment_form =\n      CommentInsertForm::new(data.person.id, inserted_post.id, \"A test comment\".into());\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let comment_like = CommentLikeForm::new(inserted_comment.id, data.person.id, Some(true));\n\n    CommentActions::like(pool, &comment_like).await?;\n\n    let child_comment_form =\n      CommentInsertForm::new(data.person.id, inserted_post.id, \"A test comment\".into());\n    let inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    let child_comment_like = CommentLikeForm::new(\n      inserted_child_comment.id,\n      another_inserted_person.id,\n      Some(true),\n    );\n\n    CommentActions::like(pool, &child_comment_like).await?;\n\n    let person_aggregates_before_delete = Person::read(pool, data.person.id).await?;\n\n    assert_eq!(1, person_aggregates_before_delete.post_count);\n    assert_eq!(1, person_aggregates_before_delete.post_score);\n    assert_eq!(2, person_aggregates_before_delete.comment_count);\n    assert_eq!(2, person_aggregates_before_delete.comment_score);\n\n    // Remove a post like\n    let form = PostLikeForm::new(inserted_post.id, data.person.id, None);\n    PostActions::like(pool, &form).await?;\n    let after_post_like_remove = Person::read(pool, data.person.id).await?;\n    assert_eq!(0, after_post_like_remove.post_score);\n\n    Comment::update(\n      pool,\n      inserted_comment.id,\n      &CommentUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n    Comment::update(\n      pool,\n      inserted_child_comment.id,\n      &CommentUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let after_parent_comment_removed = Person::read(pool, data.person.id).await?;\n    assert_eq!(0, after_parent_comment_removed.comment_count);\n    // TODO: fix person aggregate comment score calculation\n    // assert_eq!(0, after_parent_comment_removed.comment_score);\n\n    // Remove a parent comment (the scores should also be removed)\n    Comment::delete(pool, inserted_comment.id).await?;\n    Comment::delete(pool, inserted_child_comment.id).await?;\n    let after_parent_comment_delete = Person::read(pool, data.person.id).await?;\n    assert_eq!(0, after_parent_comment_delete.comment_count);\n    // TODO: fix person aggregate comment score calculation\n    // assert_eq!(0, after_parent_comment_delete.comment_score);\n\n    // Add in the two comments again, then delete the post.\n    let new_parent_comment = Comment::create(pool, &comment_form, None).await?;\n    let _new_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&new_parent_comment.path)).await?;\n    let comment_like = CommentLikeForm::new(new_parent_comment.id, data.person.id, Some(true));\n    CommentActions::like(pool, &comment_like).await?;\n    let after_comment_add = Person::read(pool, data.person.id).await?;\n    assert_eq!(2, after_comment_add.comment_count);\n    // TODO: fix person aggregate comment score calculation\n    // assert_eq!(1, after_comment_add.comment_score);\n\n    Post::delete(pool, inserted_post.id).await?;\n    let after_post_delete = Person::read(pool, data.person.id).await?;\n    // TODO: fix person aggregate comment score calculation\n    // assert_eq!(0, after_post_delete.comment_score);\n    assert_eq!(0, after_post_delete.comment_count);\n    assert_eq!(0, after_post_delete.post_score);\n    assert_eq!(0, after_post_delete.post_count);\n\n    // This should delete all the associated rows, and fire triggers\n    let person_num_deleted = Person::delete(pool, data.person.id).await?;\n    assert_eq!(1, person_num_deleted);\n    Person::delete(pool, another_inserted_person.id).await?;\n\n    // Delete the community\n    let community_num_deleted = Community::delete(pool, inserted_community.id).await?;\n    assert_eq!(1, community_num_deleted);\n\n    // Should be none found\n    let after_delete = Person::read(pool, data.person.id).await;\n    assert!(after_delete.is_err());\n\n    data.delete(pool).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn person_vote_counts() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let data = TestData::create(pool).await?;\n    let person_form = PersonInsertForm::test_form(data.instance.id, \"jerry_user_agg\");\n    let other_person = Person::create(pool, &person_form).await?;\n\n    // initial upvote\n    let res = PersonActions::like(pool, data.person.id, other_person.id, None, Some(true)).await?;\n    assert_eq!(Some(1), res.upvotes);\n    assert_eq!(Some(0), res.downvotes);\n\n    // change upvote to downvote\n    let res = PersonActions::like(\n      pool,\n      data.person.id,\n      other_person.id,\n      Some(true),\n      Some(false),\n    )\n    .await?;\n    assert_eq!(Some(0), res.upvotes);\n    assert_eq!(Some(1), res.downvotes);\n\n    // downvote a different item\n    let res = PersonActions::like(pool, data.person.id, other_person.id, None, Some(false)).await?;\n    assert_eq!(Some(0), res.upvotes);\n    assert_eq!(Some(2), res.downvotes);\n\n    // remove the downvote\n    let res = PersonActions::like(pool, data.person.id, other_person.id, Some(false), None).await?;\n    assert_eq!(Some(0), res.upvotes);\n    assert_eq!(Some(1), res.downvotes);\n\n    data.delete(pool).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/post.rs",
    "content": "use crate::{\n  newtypes::{CommunityId, PostId},\n  source::post::{\n    Post,\n    PostActions,\n    PostHideForm,\n    PostInsertForm,\n    PostLikeForm,\n    PostReadCommentsForm,\n    PostReadForm,\n    PostSavedForm,\n    PostUpdateForm,\n  },\n  traits::{Likeable, Saveable},\n  utils::{DELETED_REPLACEMENT_TEXT, FETCH_LIMIT_MAX, SITEMAP_DAYS, SITEMAP_LIMIT},\n};\nuse chrono::{DateTime, Utc};\nuse diesel::{\n  BoolExpressionMethods,\n  DecoratableTarget,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  OptionalExtension,\n  QueryDsl,\n  dsl::{count, insert_into, not, update},\n  expression::SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_uplete::{UpleteCount, uplete};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::PostNotificationsMode,\n  schema::{community, local_user, person, post, post_actions},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::{\n    functions::{coalesce, hot_rank, scaled_rank},\n    now,\n  },\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nimpl Crud for Post {\n  type InsertForm = PostInsertForm;\n  type UpdateForm = PostUpdateForm;\n  type IdType = PostId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(post::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    new_post: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(post::table.find(post_id))\n      .set(new_post)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  async fn read(pool: &mut DbPool<'_>, id: PostId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    post::table\n      .find(id)\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl Post {\n  pub async fn insert_apub(\n    pool: &mut DbPool<'_>,\n    timestamp: DateTime<Utc>,\n    form: &PostInsertForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(post::table)\n      .values(form)\n      .on_conflict(post::ap_id)\n      .filter_target(coalesce(post::updated_at, post::published_at).lt(timestamp))\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn list_featured_for_community(\n    pool: &mut DbPool<'_>,\n    the_community_id: CommunityId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    post::table\n      .filter(post::community_id.eq(the_community_id))\n      .filter(post::deleted.eq(false))\n      .filter(post::removed.eq(false))\n      .filter(post::featured_community.eq(true))\n      .then_order_by(post::published_at.desc())\n      .limit(FETCH_LIMIT_MAX.try_into()?)\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn list_for_sitemap(\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<(DbUrl, chrono::DateTime<Utc>)>> {\n    let conn = &mut get_conn(pool).await?;\n    post::table\n      .select((post::ap_id, coalesce(post::updated_at, post::published_at)))\n      .filter(post::local.eq(true))\n      .filter(post::deleted.eq(false))\n      .filter(post::removed.eq(false))\n      .filter(post::published_at.ge(Utc::now().naive_utc() - SITEMAP_DAYS))\n      .order(post::published_at.desc())\n      .limit(SITEMAP_LIMIT)\n      .load::<(DbUrl, chrono::DateTime<Utc>)>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn permadelete_for_creator(\n    pool: &mut DbPool<'_>,\n    for_creator_id: PersonId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    diesel::update(post::table.filter(post::creator_id.eq(for_creator_id)))\n      .set((\n        post::name.eq(DELETED_REPLACEMENT_TEXT),\n        post::url.eq(Option::<&str>::None),\n        post::body.eq(DELETED_REPLACEMENT_TEXT),\n        post::deleted.eq(true),\n        post::updated_at.eq(Utc::now()),\n      ))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn creator_post_ids_in_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n  ) -> LemmyResult<Vec<PostId>> {\n    let conn = &mut get_conn(pool).await?;\n\n    post::table\n      .filter(post::creator_id.eq(creator_id))\n      .filter(post::community_id.eq(community_id))\n      .select(post::id)\n      .load::<PostId>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Diesel can't update from join unfortunately, so you sometimes need to fetch a list of post_ids\n  /// for a creator.\n  async fn creator_post_ids_in_instance(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    instance_id: InstanceId,\n  ) -> LemmyResult<Vec<PostId>> {\n    let conn = &mut get_conn(pool).await?;\n\n    post::table\n      .inner_join(community::table)\n      .filter(post::creator_id.eq(creator_id))\n      .filter(community::instance_id.eq(instance_id))\n      .select(post::id)\n      .load::<PostId>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update_removed_for_creator_and_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    update(post::table)\n      .filter(post::creator_id.eq(creator_id))\n      .filter(post::community_id.eq(community_id))\n      .set((post::removed.eq(removed), post::updated_at.eq(Utc::now())))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn update_removed_for_creator_and_instance(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    instance_id: InstanceId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let post_ids = Self::creator_post_ids_in_instance(pool, creator_id, instance_id).await?;\n\n    let conn = &mut get_conn(pool).await?;\n\n    update(post::table)\n      .filter(post::id.eq_any(post_ids.clone()))\n      .set((post::removed.eq(removed), post::updated_at.eq(Utc::now())))\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn update_removed_for_creator(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    update(post::table)\n      .filter(post::creator_id.eq(creator_id))\n      .set((post::removed.eq(removed), post::updated_at.eq(Utc::now())))\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub fn is_post_creator(person_id: PersonId, post_creator_id: PersonId) -> bool {\n    person_id == post_creator_id\n  }\n\n  pub async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    post::table\n      .filter(post::ap_id.eq(object_id))\n      .filter(post::scheduled_publish_time_at.is_null())\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn delete_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: Url,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let object_id: DbUrl = object_id.into();\n\n    diesel::update(post::table.filter(post::ap_id.eq(object_id)))\n      .set(post::deleted.eq(true))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn user_scheduled_post_count(\n    person_id: PersonId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<i64> {\n    let conn = &mut get_conn(pool).await?;\n\n    post::table\n      .inner_join(person::table)\n      .inner_join(community::table)\n      // find all posts which have scheduled_publish_time that is in the  future\n      .filter(post::scheduled_publish_time_at.is_not_null())\n      .filter(coalesce(post::scheduled_publish_time_at, now()).gt(now()))\n      // make sure the post and community are still around\n      .filter(not(post::deleted.or(post::removed)))\n      .filter(not(community::removed.or(community::deleted)))\n      // only posts by specified user\n      .filter(post::creator_id.eq(person_id))\n      .select(count(post::id))\n      .first::<i64>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update_ranks(pool: &mut DbPool<'_>, post_id: PostId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    // Diesel can't update based on a join, which is necessary for the scaled_rank\n    // https://github.com/diesel-rs/diesel/issues/1478\n    // Just select the metrics we need manually, for now, since its a single post anyway\n\n    let interactions_month = community::table\n      .select(community::interactions_month)\n      .inner_join(post::table.on(community::id.eq(post::community_id)))\n      .filter(post::id.eq(post_id))\n      .first::<i32>(conn)\n      .await?;\n\n    diesel::update(post::table.find(post_id))\n      .set((\n        post::hot_rank.eq(hot_rank(post::score, post::published_at)),\n        post::hot_rank_active.eq(hot_rank(\n          post::score,\n          coalesce(post::newest_comment_time_necro_at, post::published_at),\n        )),\n        post::scaled_rank.eq(scaled_rank(\n          post::score,\n          post::published_at,\n          interactions_month,\n        )),\n      ))\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  pub fn local_url(&self, settings: &Settings) -> LemmyResult<Url> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/post/{}\", self.id))?)\n  }\n\n  /// The comment was created locally and sent back, indicating that the community accepted it\n  pub async fn set_not_pending(&self, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    if self.local && self.federation_pending {\n      let form = PostUpdateForm {\n        federation_pending: Some(false),\n        ..Default::default()\n      };\n      Post::update(pool, self.id, &form).await?;\n    }\n    Ok(())\n  }\n}\n\nimpl Likeable for PostActions {\n  type Form = PostLikeForm;\n  type IdType = PostId;\n\n  async fn like(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(post_actions::table)\n      .values(form)\n      .on_conflict((post_actions::post_id, post_actions::person_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn remove_all_likes(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(post_actions::table.filter(post_actions::person_id.eq(person_id)))\n      .set_null(post_actions::vote_is_upvote)\n      .set_null(post_actions::voted_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn remove_likes_in_community(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    community_id: CommunityId,\n  ) -> LemmyResult<UpleteCount> {\n    let post_ids = Post::creator_post_ids_in_community(pool, person_id, community_id).await?;\n\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(post_actions::table.filter(post_actions::post_id.eq_any(post_ids.clone())))\n      .set_null(post_actions::vote_is_upvote)\n      .set_null(post_actions::voted_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Saveable for PostActions {\n  type Form = PostSavedForm;\n  async fn save(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(post_actions::table)\n      .values(form)\n      .on_conflict((post_actions::post_id, post_actions::person_id))\n      .do_update()\n      .set(form)\n      .returning(Self::as_select())\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  async fn unsave(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n    uplete(post_actions::table.find((form.person_id, form.post_id)))\n      .set_null(post_actions::saved_at)\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PostActions {\n  pub async fn mark_as_unread(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    post_ids: &[PostId],\n  ) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n\n    let post_ids: Vec<_> = post_ids.to_vec();\n    uplete(\n      post_actions::table\n        .filter(post_actions::post_id.eq_any(post_ids))\n        .filter(post_actions::person_id.eq(person_id)),\n    )\n    .set_null(post_actions::read_at)\n    .get_result(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn mark_as_read(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    post_ids: &[PostId],\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n\n    let forms: Vec<_> = post_ids\n      .iter()\n      .map(|post_id| PostReadForm::new(*post_id, person_id))\n      .collect();\n\n    insert_into(post_actions::table)\n      .values(forms)\n      .on_conflict((post_actions::person_id, post_actions::post_id))\n      .do_update()\n      .set(post_actions::read_at.eq(now().nullable()))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PostActions {\n  pub async fn hide(pool: &mut DbPool<'_>, form: &PostHideForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(post_actions::table)\n      .values(form)\n      .on_conflict((post_actions::person_id, post_actions::post_id))\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  pub async fn unhide(pool: &mut DbPool<'_>, form: &PostHideForm) -> LemmyResult<UpleteCount> {\n    let conn = &mut get_conn(pool).await?;\n\n    uplete(\n      post_actions::table\n        .filter(post_actions::post_id.eq(form.post_id))\n        .filter(post_actions::person_id.eq(form.person_id)),\n    )\n    .set_null(post_actions::hidden_at)\n    .get_result(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PostActions {\n  pub async fn update_read_comments(\n    pool: &mut DbPool<'_>,\n    form: &PostReadCommentsForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    insert_into(post_actions::table)\n      .values(form)\n      .on_conflict((post_actions::person_id, post_actions::post_id))\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PostActions {\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    person_id: PersonId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    post_actions::table\n      .find((person_id, post_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn update_notification_state(\n    post_id: PostId,\n    person_id: PersonId,\n    new_state: PostNotificationsMode,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let form = (\n      post_actions::person_id.eq(person_id),\n      post_actions::post_id.eq(post_id),\n      post_actions::notifications.eq(new_state),\n    );\n\n    insert_into(post_actions::table)\n      .values(form.clone())\n      .on_conflict((post_actions::person_id, post_actions::post_id))\n      .do_update()\n      .set(form)\n      .execute(conn)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn list_subscribers(\n    post_id: PostId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<PersonId>> {\n    let conn = &mut get_conn(pool).await?;\n\n    post_actions::table\n      .inner_join(local_user::table.on(post_actions::person_id.eq(local_user::person_id)))\n      .filter(post_actions::post_id.eq(post_id))\n      .filter(post_actions::notifications.eq(PostNotificationsMode::AllComments))\n      .select(local_user::person_id)\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::{\n    source::{\n      comment::{Comment, CommentInsertForm, CommentUpdateForm},\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm, PostSavedForm, PostUpdateForm},\n    },\n    traits::{Likeable, Saveable},\n    utils::RANK_DEFAULT,\n  };\n  use chrono::DateTime;\n  use diesel_uplete::UpleteCount;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n  use url::Url;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"jim\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community_3\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let new_post2 = PostInsertForm::new(\n      \"A test post 2\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post2 = Post::create(pool, &new_post2).await?;\n\n    let new_scheduled_post = PostInsertForm {\n      scheduled_publish_time_at: Some(DateTime::from_timestamp_nanos(i64::MAX)),\n      ..PostInsertForm::new(\"beans\".into(), inserted_person.id, inserted_community.id)\n    };\n    let inserted_scheduled_post = Post::create(pool, &new_scheduled_post).await?;\n\n    let expected_post = Post {\n      id: inserted_post.id,\n      name: \"A test post\".into(),\n      url: None,\n      body: None,\n      alt_text: None,\n      creator_id: inserted_person.id,\n      community_id: inserted_community.id,\n      published_at: inserted_post.published_at,\n      removed: false,\n      locked: false,\n      nsfw: false,\n      deleted: false,\n      updated_at: None,\n      embed_title: None,\n      embed_description: None,\n      embed_video_url: None,\n      embed_video_width: None,\n      embed_video_height: None,\n      thumbnail_url: None,\n      ap_id: Url::parse(&format!(\"https://lemmy-alpha/post/{}\", inserted_post.id))?.into(),\n      local: true,\n      language_id: Default::default(),\n      featured_community: false,\n      featured_local: false,\n      url_content_type: None,\n      scheduled_publish_time_at: None,\n      comments: 0,\n      controversy_rank: 0.0,\n      downvotes: 0,\n      upvotes: 1,\n      score: 1,\n      hot_rank: RANK_DEFAULT,\n      hot_rank_active: RANK_DEFAULT,\n      newest_comment_time_at: None,\n      newest_comment_time_necro_at: None,\n      report_count: 0,\n      scaled_rank: RANK_DEFAULT,\n      unresolved_report_count: 0,\n      federation_pending: false,\n    };\n\n    // Post Like\n    let post_like_form = PostLikeForm::new(inserted_post.id, inserted_person.id, Some(true));\n\n    let inserted_post_like = PostActions::like(pool, &post_like_form).await?;\n    assert_eq!(Some(true), inserted_post_like.vote_is_upvote);\n\n    // Post Save\n    let post_saved_form = PostSavedForm::new(inserted_post.id, inserted_person.id);\n\n    let inserted_post_saved = PostActions::save(pool, &post_saved_form).await?;\n    assert!(inserted_post_saved.saved_at.is_some());\n\n    // Mark 2 posts as read\n    PostActions::mark_as_read(pool, inserted_person.id, &[inserted_post.id]).await?;\n    PostActions::mark_as_read(pool, inserted_person.id, &[inserted_post2.id]).await?;\n\n    let read_post = Post::read(pool, inserted_post.id).await?;\n\n    let new_post_update = PostUpdateForm {\n      name: Some(\"A test post\".into()),\n      ..Default::default()\n    };\n    let updated_post = Post::update(pool, inserted_post.id, &new_post_update).await?;\n\n    // Scheduled post count\n    let scheduled_post_count = Post::user_scheduled_post_count(inserted_person.id, pool).await?;\n    assert_eq!(1, scheduled_post_count);\n\n    let form = PostLikeForm::new(inserted_post.id, inserted_person.id, None);\n    PostActions::like(pool, &form).await?;\n\n    let saved_removed = PostActions::unsave(pool, &post_saved_form).await?;\n    assert_eq!(UpleteCount::only_updated(1), saved_removed);\n\n    let read_removed_1 =\n      PostActions::mark_as_unread(pool, inserted_person.id, &[inserted_post.id]).await?;\n    assert_eq!(UpleteCount::only_deleted(1), read_removed_1);\n\n    let read_removed_2 =\n      PostActions::mark_as_unread(pool, inserted_person.id, &[inserted_post2.id]).await?;\n    assert_eq!(UpleteCount::only_deleted(1), read_removed_2);\n\n    let num_deleted = Post::delete(pool, inserted_post.id).await?\n      + Post::delete(pool, inserted_post2.id).await?\n      + Post::delete(pool, inserted_scheduled_post.id).await?;\n\n    assert_eq!(3, num_deleted);\n    Community::delete(pool, inserted_community.id).await?;\n    Person::delete(pool, inserted_person.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    assert_eq!(expected_post, read_post);\n    assert_eq!(expected_post, updated_post);\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"thommy_community_agg\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let another_person = PersonInsertForm::test_form(inserted_instance.id, \"jerry_community_agg\");\n\n    let another_inserted_person = Person::create(pool, &another_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL_community_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let child_comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n    let inserted_child_comment =\n      Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?;\n\n    let post_like = PostLikeForm::new(inserted_post.id, inserted_person.id, Some(true));\n\n    PostActions::like(pool, &post_like).await?;\n\n    let post_aggs_before_delete = Post::read(pool, inserted_post.id).await?;\n\n    assert_eq!(2, post_aggs_before_delete.comments);\n    assert_eq!(1, post_aggs_before_delete.score);\n    assert_eq!(1, post_aggs_before_delete.upvotes);\n    assert_eq!(0, post_aggs_before_delete.downvotes);\n\n    // Add a post dislike from the other person\n    let post_dislike = PostLikeForm::new(inserted_post.id, another_inserted_person.id, Some(false));\n\n    PostActions::like(pool, &post_dislike).await?;\n\n    let post_aggs_after_dislike = Post::read(pool, inserted_post.id).await?;\n\n    assert_eq!(2, post_aggs_after_dislike.comments);\n    assert_eq!(0, post_aggs_after_dislike.score);\n    assert_eq!(1, post_aggs_after_dislike.upvotes);\n    assert_eq!(1, post_aggs_after_dislike.downvotes);\n\n    // Remove the comments\n    Comment::delete(pool, inserted_comment.id).await?;\n    Comment::delete(pool, inserted_child_comment.id).await?;\n    let after_comment_delete = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(0, after_comment_delete.comments);\n    assert_eq!(0, after_comment_delete.score);\n    assert_eq!(1, after_comment_delete.upvotes);\n    assert_eq!(1, after_comment_delete.downvotes);\n\n    // Remove the first post like\n    let form = PostLikeForm::new(inserted_post.id, inserted_person.id, None);\n    PostActions::like(pool, &form).await?;\n    let after_like_remove = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(0, after_like_remove.comments);\n    assert_eq!(-1, after_like_remove.score);\n    assert_eq!(0, after_like_remove.upvotes);\n    assert_eq!(1, after_like_remove.downvotes);\n\n    // This should delete all the associated rows, and fire triggers\n    Person::delete(pool, another_inserted_person.id).await?;\n    let person_num_deleted = Person::delete(pool, inserted_person.id).await?;\n    assert_eq!(1, person_num_deleted);\n\n    // Delete the community\n    let community_num_deleted = Community::delete(pool, inserted_community.id).await?;\n    assert_eq!(1, community_num_deleted);\n\n    // Should be none found, since the creator was deleted\n    let after_delete = Post::read(pool, inserted_post.id).await;\n    assert!(after_delete.is_err());\n\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_aggregates_soft_delete() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"thommy_community_agg\");\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"TIL_community_agg\".into(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post\".into(),\n      inserted_person.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_person.id,\n      inserted_post.id,\n      \"A test comment\".into(),\n    );\n\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    let post_aggregates_before = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(1, post_aggregates_before.comments);\n\n    Comment::update(\n      pool,\n      inserted_comment.id,\n      &CommentUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let post_aggregates_after_remove = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(0, post_aggregates_after_remove.comments);\n\n    Comment::update(\n      pool,\n      inserted_comment.id,\n      &CommentUpdateForm {\n        removed: Some(false),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    Comment::update(\n      pool,\n      inserted_comment.id,\n      &CommentUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let post_aggregates_after_delete = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(0, post_aggregates_after_delete.comments);\n\n    Comment::update(\n      pool,\n      inserted_comment.id,\n      &CommentUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let post_aggregates_after_delete_remove = Post::read(pool, inserted_post.id).await?;\n    assert_eq!(0, post_aggregates_after_delete_remove.comments);\n\n    Comment::delete(pool, inserted_comment.id).await?;\n    Post::delete(pool, inserted_post.id).await?;\n    Person::delete(pool, inserted_person.id).await?;\n    Community::delete(pool, inserted_community.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/post_report.rs",
    "content": "use crate::{\n  newtypes::{PostId, PostReportId},\n  source::post_report::{PostReport, PostReportForm},\n  traits::Reportable,\n};\nuse chrono::Utc;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  QueryDsl,\n  dsl::{insert_into, update},\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{PersonId, schema::post_report};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Reportable for PostReport {\n  type Form = PostReportForm;\n  type IdType = PostReportId;\n  type ObjectIdType = PostId;\n\n  async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(post_report::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update_resolved(\n    pool: &mut DbPool<'_>,\n    report_id: Self::IdType,\n    by_resolver_id: PersonId,\n    is_resolved: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(post_report::table.find(report_id))\n      .set((\n        post_report::resolved.eq(is_resolved),\n        post_report::resolver_id.eq(by_resolver_id),\n        post_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_apub(\n    pool: &mut DbPool<'_>,\n    object_id: Self::ObjectIdType,\n    report_creator_id: PersonId,\n    resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(\n      post_report::table.filter(\n        post_report::post_id\n          .eq(object_id)\n          .and(post_report::creator_id.eq(report_creator_id)),\n      ),\n    )\n    .set((\n      post_report::resolved.eq(true),\n      post_report::resolver_id.eq(resolver_id),\n      post_report::updated_at.eq(Utc::now()),\n    ))\n    .execute(conn)\n    .await\n    .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  async fn resolve_all_for_object(\n    pool: &mut DbPool<'_>,\n    post_id_: PostId,\n    by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(post_report::table.filter(post_report::post_id.eq(post_id_)))\n      .set((\n        post_report::resolved.eq(true),\n        post_report::resolver_id.eq(by_resolver_id),\n        post_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use crate::source::{\n    community::{Community, CommunityInsertForm},\n    instance::Instance,\n    person::{Person, PersonInsertForm},\n    post::{Post, PostInsertForm},\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use serial_test::serial;\n\n  async fn init(pool: &mut DbPool<'_>) -> LemmyResult<(Person, PostReport)> {\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n    let person_form = PersonInsertForm::test_form(inserted_instance.id, \"jim\");\n    let person = Person::create(pool, &person_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community_4\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let form = PostInsertForm::new(\"A test post\".into(), person.id, community.id);\n    let post = Post::create(pool, &form).await?;\n\n    let report_form = PostReportForm {\n      post_id: post.id,\n      creator_id: person.id,\n      reason: \"my reason\".to_string(),\n      ..Default::default()\n    };\n    let report = PostReport::report(pool, &report_form).await?;\n\n    Ok((person, report))\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_resolve_post_report() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let (person, report) = init(pool).await?;\n\n    let resolved_count = PostReport::update_resolved(pool, report.id, person.id, true).await?;\n    assert_eq!(resolved_count, 1);\n\n    let unresolved_count = PostReport::update_resolved(pool, report.id, person.id, false).await?;\n    assert_eq!(unresolved_count, 1);\n\n    Person::delete(pool, person.id).await?;\n    Post::delete(pool, report.post_id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_resolve_all_post_reports() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let (person, report) = init(pool).await?;\n\n    let resolved_count =\n      PostReport::resolve_all_for_object(pool, report.post_id, person.id).await?;\n    assert_eq!(resolved_count, 1);\n\n    Person::delete(pool, person.id).await?;\n    Post::delete(pool, report.post_id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/private_message.rs",
    "content": "use crate::{\n  diesel::{DecoratableTarget, OptionalExtension},\n  newtypes::PrivateMessageId,\n  source::{\n    person::Person,\n    private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm},\n  },\n};\nuse chrono::{DateTime, Utc};\nuse diesel::{ExpressionMethods, QueryDsl, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{PersonId, schema::private_message};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::coalesce,\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nimpl Crud for PrivateMessage {\n  type InsertForm = PrivateMessageInsertForm;\n  type UpdateForm = PrivateMessageUpdateForm;\n  type IdType = PrivateMessageId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(private_message::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    private_message_id: PrivateMessageId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(private_message::table.find(private_message_id))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PrivateMessage {\n  pub async fn insert_apub(\n    pool: &mut DbPool<'_>,\n    timestamp: DateTime<Utc>,\n    form: &PrivateMessageInsertForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(private_message::table)\n      .values(form)\n      .on_conflict(private_message::ap_id)\n      .filter_target(\n        coalesce(private_message::updated_at, private_message::published_at).lt(timestamp),\n      )\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  pub async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    private_message::table\n      .filter(private_message::ap_id.eq(object_id))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n  pub fn local_url(&self, settings: &Settings) -> LemmyResult<DbUrl> {\n    let domain = settings.get_protocol_and_hostname();\n    Ok(Url::parse(&format!(\"{domain}/private_message/{}\", self.id))?.into())\n  }\n\n  pub async fn update_removed_for_creator(\n    pool: &mut DbPool<'_>,\n    for_creator_id: PersonId,\n    removed: bool,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(private_message::table.filter(private_message::creator_id.eq(for_creator_id)))\n      .set((\n        private_message::removed.eq(removed),\n        private_message::updated_at.eq(Utc::now()),\n      ))\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n\n  /// Dont let creator know that recipient deleted the message\n  pub fn clear_deleted_by_recipient(&mut self, my_person: Option<&Person>) {\n    if Some(self.creator_id) == my_person.map(|p| p.id) {\n      self.deleted_by_recipient = false;\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::source::{\n    instance::Instance,\n    person::{Person, PersonInsertForm},\n    private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm},\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n  use url::Url;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let creator_form = PersonInsertForm::test_form(inserted_instance.id, \"creator_pm\");\n\n    let inserted_creator = Person::create(pool, &creator_form).await?;\n\n    let recipient_form = PersonInsertForm::test_form(inserted_instance.id, \"recipient_pm\");\n\n    let inserted_recipient = Person::create(pool, &recipient_form).await?;\n\n    let private_message_form = PrivateMessageInsertForm::new(\n      inserted_creator.id,\n      inserted_recipient.id,\n      \"A test private message\".into(),\n    );\n\n    let inserted_private_message = PrivateMessage::create(pool, &private_message_form).await?;\n\n    let expected_private_message = PrivateMessage {\n      id: inserted_private_message.id,\n      content: \"A test private message\".into(),\n      creator_id: inserted_creator.id,\n      recipient_id: inserted_recipient.id,\n      deleted: false,\n      updated_at: None,\n      published_at: inserted_private_message.published_at,\n      ap_id: Url::parse(&format!(\n        \"https://lemmy-alpha/private_message/{}\",\n        inserted_private_message.id\n      ))?\n      .into(),\n      local: true,\n      removed: false,\n      deleted_by_recipient: false,\n    };\n\n    let read_private_message = PrivateMessage::read(pool, inserted_private_message.id).await?;\n\n    let private_message_update_form = PrivateMessageUpdateForm {\n      content: Some(\"A test private message\".into()),\n      ..Default::default()\n    };\n    let updated_private_message = PrivateMessage::update(\n      pool,\n      inserted_private_message.id,\n      &private_message_update_form,\n    )\n    .await?;\n\n    let deleted_private_message = PrivateMessage::update(\n      pool,\n      inserted_private_message.id,\n      &PrivateMessageUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n    Person::delete(pool, inserted_creator.id).await?;\n    Person::delete(pool, inserted_recipient.id).await?;\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    assert_eq!(expected_private_message, read_private_message);\n    assert_eq!(expected_private_message, updated_private_message);\n    assert_eq!(expected_private_message, inserted_private_message);\n    assert!(deleted_private_message.deleted);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/private_message_report.rs",
    "content": "use crate::{\n  newtypes::{PrivateMessageId, PrivateMessageReportId},\n  source::private_message_report::{PrivateMessageReport, PrivateMessageReportForm},\n  traits::Reportable,\n};\nuse chrono::Utc;\nuse diesel::{\n  ExpressionMethods,\n  QueryDsl,\n  dsl::{insert_into, update},\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{PersonId, schema::private_message_report};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError};\n\nimpl Reportable for PrivateMessageReport {\n  type Form = PrivateMessageReportForm;\n  type IdType = PrivateMessageReportId;\n  type ObjectIdType = PrivateMessageId;\n\n  async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(private_message_report::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update_resolved(\n    pool: &mut DbPool<'_>,\n    report_id: Self::IdType,\n    by_resolver_id: PersonId,\n    is_resolved: bool,\n  ) -> LemmyResult<usize> {\n    let conn = &mut get_conn(pool).await?;\n    update(private_message_report::table.find(report_id))\n      .set((\n        private_message_report::resolved.eq(is_resolved),\n        private_message_report::resolver_id.eq(by_resolver_id),\n        private_message_report::updated_at.eq(Utc::now()),\n      ))\n      .execute(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n  async fn resolve_apub(\n    _pool: &mut DbPool<'_>,\n    _object_id: Self::ObjectIdType,\n    _report_creator_id: PersonId,\n    _resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    Err(UntranslatedError::Unreachable.into())\n  }\n\n  // This is unused because private message doesn't have remove handler\n  async fn resolve_all_for_object(\n    _pool: &mut DbPool<'_>,\n    _pm_id_: PrivateMessageId,\n    _by_resolver_id: PersonId,\n  ) -> LemmyResult<usize> {\n    Err(LemmyErrorType::NotFound.into())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/registration_application.rs",
    "content": "use crate::{\n  newtypes::{LocalUserId, RegistrationApplicationId},\n  source::registration_application::{\n    RegistrationApplication,\n    RegistrationApplicationInsertForm,\n    RegistrationApplicationUpdateForm,\n  },\n};\nuse diesel::{ExpressionMethods, QueryDsl, insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::registration_application;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Crud for RegistrationApplication {\n  type InsertForm = RegistrationApplicationInsertForm;\n  type UpdateForm = RegistrationApplicationUpdateForm;\n  type IdType = RegistrationApplicationId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(registration_application::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    id_: Self::IdType,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(registration_application::table.find(id_))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl RegistrationApplication {\n  pub async fn find_by_local_user_id(\n    pool: &mut DbPool<'_>,\n    local_user_id_: LocalUserId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    registration_application::table\n      .filter(registration_application::local_user_id.eq(local_user_id_))\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Fetches the most recent updated application.\n  pub async fn last_updated(pool: &mut DbPool<'_>) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    registration_application::table\n      .filter(registration_application::updated_at.is_not_null())\n      .order_by(registration_application::updated_at.desc())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// The duration between the last application creation, and its approval / denial time.\n  ///\n  /// Useful for estimating when your application will be approved.\n  pub fn updated_published_duration(&self) -> Option<i64> {\n    self\n      .updated_at\n      .map(|updated| (updated - self.published_at).num_seconds())\n  }\n\n  /// A missing admin id, means the application is unread\n  #[diesel::dsl::auto_type(no_type_alias)]\n  pub fn is_unread() -> _ {\n    registration_application::admin_id.is_null()\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/secret.rs",
    "content": "use crate::source::secret::Secret;\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::schema::secret::dsl::secret;\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Secret {\n  /// Initialize the Secrets from the DB.\n  /// Warning: You should only call this once.\n  pub async fn init(pool: &mut DbPool<'_>) -> LemmyResult<Secret> {\n    Self::read_secrets(pool).await\n  }\n\n  async fn read_secrets(pool: &mut DbPool<'_>) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    secret\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/site.rs",
    "content": "use crate::{\n  newtypes::SiteId,\n  source::{\n    actor_language::SiteLanguage,\n    site::{Site, SiteInsertForm, SiteUpdateForm},\n  },\n};\nuse diesel::{ExpressionMethods, OptionalExtension, QueryDsl, dsl::insert_into};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema_file::{InstanceId, schema::site};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  traits::Crud,\n  utils::functions::lower,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse url::Url;\n\nimpl Crud for Site {\n  type InsertForm = SiteInsertForm;\n  type UpdateForm = SiteUpdateForm;\n  type IdType = SiteId;\n\n  /// Use SiteView::read_local, or Site::read_from_apub_id instead\n  async fn read(_pool: &mut DbPool<'_>, _site_id: SiteId) -> LemmyResult<Self> {\n    Err(LemmyErrorType::NotFound.into())\n  }\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let is_new_site = match &form.ap_id {\n      Some(id) => Site::read_from_apub_id(pool, id).await?.is_none(),\n      None => true,\n    };\n    let conn = &mut get_conn(pool).await?;\n\n    // Can't do separate insert/update commands because InsertForm/UpdateForm aren't convertible\n    let site = insert_into(site::table)\n      .values(form)\n      .on_conflict(site::ap_id)\n      .do_update()\n      .set(form)\n      .get_result::<Self>(conn)\n      .await?;\n\n    // initialize languages if site is newly created\n    if is_new_site {\n      // initialize with all languages\n      SiteLanguage::update(pool, vec![], &site).await?;\n    }\n    Ok(site)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    site_id: SiteId,\n    new_site: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(site::table.find(site_id))\n      .set(new_site)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl Site {\n  pub async fn read_from_instance_id(\n    pool: &mut DbPool<'_>,\n    instance_id: InstanceId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    site::table\n      .filter(site::instance_id.eq(instance_id))\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n  pub async fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: &DbUrl,\n  ) -> LemmyResult<Option<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    site::table\n      .filter(lower(site::ap_id).eq(object_id.to_lowercase()))\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read_remote_sites(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    site::table\n      .order_by(site::id)\n      .offset(1)\n      .get_results::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Instance actor is at the root path, so we simply need to clear the path and other unnecessary\n  /// parts of the url.\n  pub fn instance_ap_id_from_url(mut url: Url) -> Url {\n    url.set_fragment(None);\n    url.set_path(\"\");\n    url.set_query(None);\n    url\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/impls/tagline.rs",
    "content": "use crate::{\n  newtypes::TaglineId,\n  source::tagline::{Tagline, TaglineInsertForm, TaglineUpdateForm, tagline_keys as key},\n  utils::limit_fetch,\n};\nuse diesel::{QueryDsl, insert_into};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema_file::schema::tagline;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n  utils::functions::random,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl Crud for Tagline {\n  type InsertForm = TaglineInsertForm;\n  type UpdateForm = TaglineUpdateForm;\n  type IdType = TaglineId;\n\n  async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    insert_into(tagline::table)\n      .values(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntCreate)\n  }\n\n  async fn update(\n    pool: &mut DbPool<'_>,\n    tagline_id: TaglineId,\n    form: &Self::UpdateForm,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    diesel::update(tagline::table.find(tagline_id))\n      .set(form)\n      .get_result::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::CouldntUpdate)\n  }\n}\n\nimpl PaginationCursorConversion for Tagline {\n  type PaginatedType = Tagline;\n\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Tagline::read(pool, TaglineId(cursor.id()?)).await\n  }\n}\n\nimpl Tagline {\n  pub async fn list(\n    pool: &mut DbPool<'_>,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    let limit = limit_fetch(limit, None)?;\n    let query = tagline::table.limit(limit).into_boxed();\n    let paginated_query = Self::paginate(query, &page_cursor, SortDirection::Desc, pool, None)\n      .await?\n      .then_order_by(key::published_at)\n      .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, page_cursor)\n  }\n\n  pub async fn get_random(pool: &mut DbPool<'_>) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    tagline::table\n      .order(random())\n      .limit(1)\n      .first::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\n#[macro_use]\nextern crate diesel;\n#[cfg(feature = \"full\")]\n#[macro_use]\nextern crate diesel_derive_newtype;\n\n#[cfg(feature = \"full\")]\npub mod impls;\npub mod newtypes;\npub mod source;\n#[cfg(feature = \"full\")]\npub mod test_data;\n#[cfg(feature = \"full\")]\npub mod traits;\n#[cfg(feature = \"full\")]\npub mod utils;\n\nuse lemmy_db_schema_file::enums::{ModlogKind, NotificationType};\nuse serde::{Deserialize, Serialize};\nuse strum::{Display, EnumString};\n#[cfg(feature = \"full\")]\nuse {\n  diesel::query_source::AliasedField,\n  lemmy_db_schema_file::{\n    aliases,\n    schema::{instance_actions, person},\n  },\n};\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The search sort types.\npub enum SearchSortType {\n  #[default]\n  New,\n  Top,\n  Old,\n}\n\n/// The community sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub enum CommunitySortType {\n  ActiveSixMonths,\n  #[default]\n  ActiveMonthly,\n  ActiveWeekly,\n  ActiveDaily,\n  Hot,\n  New,\n  Old,\n  NameAsc,\n  NameDesc,\n  Comments,\n  Posts,\n  Subscribers,\n  SubscribersLocal,\n}\n\n/// The local user sort type.\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub enum LocalUserSortType {\n  #[default]\n  New,\n  Old,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub enum MultiCommunitySortType {\n  New,\n  Old,\n  NameAsc,\n  NameDesc,\n  Communities,\n  #[default]\n  Subscribers,\n  SubscribersLocal,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A listing type for multi-community fetches.\npub enum MultiCommunityListingType {\n  /// Content from your own site, as well as all connected / federated sites.\n  All,\n  /// Content from your site only.\n  #[default]\n  Local,\n  /// Content only from communities you've subscribed to.\n  Subscribed,\n}\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The type of content returned from a search.\npub enum SearchType {\n  #[default]\n  All,\n  Comments,\n  Posts,\n  Communities,\n  Users,\n  MultiCommunities,\n}\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A list of possible types for the inbox.\npub enum NotificationTypeFilter {\n  #[default]\n  All,\n  #[serde(untagged)]\n  Other(NotificationType),\n}\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A list of possible types for the various modlog actions.\npub enum ModlogKindFilter {\n  #[default]\n  All,\n  #[serde(untagged)]\n  Other(ModlogKind),\n}\n\n#[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A list of possible types for a person's content.\npub enum PersonContentType {\n  All,\n  Comments,\n  Posts,\n}\n\n#[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A list of possible types for reports.\npub enum ReportType {\n  All,\n  Posts,\n  Comments,\n  PrivateMessages,\n  Communities,\n}\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The feature type for a post.\npub enum PostFeatureType {\n  #[default]\n  /// Features to the top of your site.\n  Local,\n  /// Features to the top of the community.\n  Community,\n}\n\n#[derive(\n  EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash,\n)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The like_type for a persons liked content.\npub enum LikeType {\n  #[default]\n  All,\n  LikedOnly,\n  DislikedOnly,\n}\n\n/// Wrapper for assert_eq! macro. Checks that vec matches the given length, and prints the\n/// vec on failure.\n#[macro_export]\nmacro_rules! assert_length {\n  ($len:expr, $vec:expr) => {{ assert_eq!($len, $vec.len(), \"Vec has wrong length: {:?}\", $vec) }};\n}\n\n#[cfg(feature = \"full\")]\n/// A helper tuple for person 1 alias columns\npub type Person1AliasAllColumnsTuple = (\n  AliasedField<aliases::Person1, person::id>,\n  AliasedField<aliases::Person1, person::name>,\n  AliasedField<aliases::Person1, person::display_name>,\n  AliasedField<aliases::Person1, person::avatar>,\n  AliasedField<aliases::Person1, person::published_at>,\n  AliasedField<aliases::Person1, person::updated_at>,\n  AliasedField<aliases::Person1, person::ap_id>,\n  AliasedField<aliases::Person1, person::bio>,\n  AliasedField<aliases::Person1, person::local>,\n  AliasedField<aliases::Person1, person::private_key>,\n  AliasedField<aliases::Person1, person::public_key>,\n  AliasedField<aliases::Person1, person::last_refreshed_at>,\n  AliasedField<aliases::Person1, person::banner>,\n  AliasedField<aliases::Person1, person::deleted>,\n  AliasedField<aliases::Person1, person::inbox_url>,\n  AliasedField<aliases::Person1, person::matrix_user_id>,\n  AliasedField<aliases::Person1, person::bot_account>,\n  AliasedField<aliases::Person1, person::instance_id>,\n  AliasedField<aliases::Person1, person::post_count>,\n  AliasedField<aliases::Person1, person::post_score>,\n  AliasedField<aliases::Person1, person::comment_count>,\n  AliasedField<aliases::Person1, person::comment_score>,\n);\n\n#[cfg(feature = \"full\")]\n/// A helper tuple for person 2 alias columns\npub type Person2AliasAllColumnsTuple = (\n  AliasedField<aliases::Person2, person::id>,\n  AliasedField<aliases::Person2, person::name>,\n  AliasedField<aliases::Person2, person::display_name>,\n  AliasedField<aliases::Person2, person::avatar>,\n  AliasedField<aliases::Person2, person::published_at>,\n  AliasedField<aliases::Person2, person::updated_at>,\n  AliasedField<aliases::Person2, person::ap_id>,\n  AliasedField<aliases::Person2, person::bio>,\n  AliasedField<aliases::Person2, person::local>,\n  AliasedField<aliases::Person2, person::private_key>,\n  AliasedField<aliases::Person2, person::public_key>,\n  AliasedField<aliases::Person2, person::last_refreshed_at>,\n  AliasedField<aliases::Person2, person::banner>,\n  AliasedField<aliases::Person2, person::deleted>,\n  AliasedField<aliases::Person2, person::inbox_url>,\n  AliasedField<aliases::Person2, person::matrix_user_id>,\n  AliasedField<aliases::Person2, person::bot_account>,\n  AliasedField<aliases::Person2, person::instance_id>,\n  AliasedField<aliases::Person2, person::post_count>,\n  AliasedField<aliases::Person2, person::post_score>,\n  AliasedField<aliases::Person2, person::comment_count>,\n  AliasedField<aliases::Person2, person::comment_score>,\n);\n\n#[cfg(feature = \"full\")]\n/// A helper tuple for more my instance persons actions\npub type MyInstancePersonsActionsAllColumnsTuple = (\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::blocked_communities_at>,\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::person_id>,\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::instance_id>,\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::received_ban_at>,\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::ban_expires_at>,\n  AliasedField<aliases::MyInstancePersonsActions, instance_actions::blocked_persons_at>,\n);\n"
  },
  {
    "path": "crates/db_schema/src/newtypes.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel_ltree::Ltree;\nuse serde::{Deserialize, Serialize};\nuse std::fmt;\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The post id.\npub struct PostId(pub i32);\n\nimpl fmt::Display for PostId {\n  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n    write!(f, \"{}\", self.0)\n  }\n}\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The comment id.\npub struct CommentId(pub i32);\n\nimpl fmt::Display for CommentId {\n  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n    write!(f, \"{}\", self.0)\n  }\n}\n\npub enum PostOrCommentId {\n  Post(PostId),\n  Comment(CommentId),\n}\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The community id.\npub struct CommunityId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The local user id.\npub struct LocalUserId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The private message id.\npub struct PrivateMessageId(pub i32);\n\nimpl fmt::Display for PrivateMessageId {\n  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n    write!(f, \"{}\", self.0)\n  }\n}\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct NotificationId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The comment report id.\npub struct CommentReportId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The community report id.\npub struct CommunityReportId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The post report id.\npub struct PostReportId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The private message report id.\npub struct PrivateMessageReportId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The site id.\npub struct SiteId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The language id.\npub struct LanguageId(pub i32);\n\n#[derive(\n  Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, PartialOrd, Ord,\n)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ActivityId(pub i64);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The local site id.\npub struct LocalSiteId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The custom emoji id.\npub struct CustomEmojiId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The tagline id.\npub struct TaglineId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The registration application id.\npub struct RegistrationApplicationId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The oauth provider id.\npub struct OAuthProviderId(pub i32);\n\n#[cfg(feature = \"full\")]\n#[derive(Serialize, Deserialize)]\n#[serde(remote = \"Ltree\")]\n/// Do remote derivation for the Ltree struct\npub struct LtreeDef(pub String);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n/// The report combined id\npub struct ReportCombinedId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n/// The person content combined id\npub struct PersonContentCombinedId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n/// The person saved combined id\npub struct PersonSavedCombinedId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n/// The person liked combined id\npub struct PersonLikedCombinedId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n/// The search combined id\npub struct SearchCombinedId(i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ModlogId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunityId(pub i32);\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The community tag id\npub struct CommunityTagId(pub i32);\n"
  },
  {
    "path": "crates/db_schema/src/source/activity.rs",
    "content": "use crate::newtypes::{ActivityId, CommunityId};\nuse chrono::{DateTime, Utc};\nuse diesel::Queryable;\nuse lemmy_db_schema_file::{\n  enums::ActorType,\n  schema::{received_activity, sent_activity},\n};\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde_json::Value;\nuse std::{collections::HashSet, fmt::Debug};\nuse url::Url;\n\n#[derive(Debug, Default, Clone)]\n/// describes where an activity should be sent\npub struct ActivitySendTargets {\n  /// send to these inboxes explicitly\n  pub inboxes: HashSet<Url>,\n  /// send to all followers of these local communities\n  pub community_followers_of: Option<CommunityId>,\n  /// send to all remote instances\n  pub all_instances: bool,\n}\n\n// todo: in different file?\nimpl ActivitySendTargets {\n  pub fn empty() -> ActivitySendTargets {\n    ActivitySendTargets::default()\n  }\n  pub fn to_inbox(url: Url) -> ActivitySendTargets {\n    let mut a = ActivitySendTargets::empty();\n    a.inboxes.insert(url);\n    a\n  }\n  pub fn to_local_community_followers(id: CommunityId) -> ActivitySendTargets {\n    let mut a = ActivitySendTargets::empty();\n    a.community_followers_of = Some(id);\n    a\n  }\n  pub fn to_all_instances() -> ActivitySendTargets {\n    let mut a = ActivitySendTargets::empty();\n    a.all_instances = true;\n    a\n  }\n  pub fn set_all_instances(&mut self) {\n    self.all_instances = true;\n  }\n\n  pub fn add_inbox(&mut self, inbox: Url) {\n    self.inboxes.insert(inbox);\n  }\n  pub fn add_inboxes(&mut self, inboxes: Vec<DbUrl>) {\n    self.inboxes.extend(inboxes.into_iter().map(Into::into));\n  }\n}\n\n#[derive(PartialEq, Eq, Debug)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = sent_activity))]\npub struct SentActivity {\n  pub id: ActivityId,\n  pub ap_id: DbUrl,\n  pub data: Value,\n  pub sensitive: bool,\n  pub published_at: DateTime<Utc>,\n  pub send_inboxes: Vec<Option<DbUrl>>,\n  pub send_community_followers_of: Option<CommunityId>,\n  pub send_all_instances: bool,\n  pub actor_type: ActorType,\n  pub actor_apub_id: Option<DbUrl>,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = sent_activity))]\npub struct SentActivityForm {\n  pub ap_id: DbUrl,\n  pub data: Value,\n  pub sensitive: bool,\n  pub send_inboxes: Vec<Option<DbUrl>>,\n  pub send_community_followers_of: Option<i32>,\n  pub send_all_instances: bool,\n  pub actor_type: ActorType,\n  pub actor_apub_id: DbUrl,\n}\n\n#[derive(PartialEq, Eq, Debug)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(ap_id)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = received_activity))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct ReceivedActivity {\n  pub ap_id: DbUrl,\n  pub published_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/actor_language.rs",
    "content": "use crate::newtypes::{CommunityId, LanguageId, LocalUserId, SiteId};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_user_language;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user_language))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(local_user_id, language_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct LocalUserLanguage {\n  pub local_user_id: LocalUserId,\n  pub language_id: LanguageId,\n}\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user_language))]\npub struct LocalUserLanguageForm {\n  pub local_user_id: LocalUserId,\n  pub language_id: LanguageId,\n}\n\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::community_language;\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_language))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(community_id, language_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct CommunityLanguage {\n  pub community_id: CommunityId,\n  pub language_id: LanguageId,\n}\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_language))]\npub struct CommunityLanguageForm {\n  pub community_id: CommunityId,\n  pub language_id: LanguageId,\n}\n\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::site_language;\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = site_language))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(site_id, language_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct SiteLanguage {\n  pub site_id: SiteId,\n  pub language_id: LanguageId,\n}\n\n#[derive(Clone, Debug)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = site_language))]\npub struct SiteLanguageForm {\n  pub site_id: SiteId,\n  pub language_id: LanguageId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/mod.rs",
    "content": "pub mod person_content;\npub mod person_liked;\npub mod person_saved;\npub mod report;\npub mod search;\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/person_content.rs",
    "content": "use crate::newtypes::{CommentId, PersonContentCombinedId, PostId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::person_content_combined;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_content_combined))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = person_content_combined_keys))]\n/// A combined table for a persons contents (posts and comments)\npub struct PersonContentCombined {\n  pub published_at: DateTime<Utc>,\n  pub creator_id: PersonId,\n  pub post_id: Option<PostId>,\n  pub comment_id: Option<CommentId>,\n  pub id: PersonContentCombinedId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/person_liked.rs",
    "content": "use crate::newtypes::{CommentId, PersonLikedCombinedId, PostId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::person_liked_combined;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_liked_combined))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = person_liked_combined_keys))]\n/// A combined person_liked table.\npub struct PersonLikedCombined {\n  pub voted_at: DateTime<Utc>,\n  pub id: PersonLikedCombinedId,\n  pub person_id: PersonId,\n  pub creator_id: PersonId,\n  pub post_id: Option<PostId>,\n  pub comment_id: Option<CommentId>,\n  pub vote_is_upvote: bool,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/person_saved.rs",
    "content": "use crate::newtypes::{CommentId, PersonSavedCombinedId, PostId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::person_saved_combined;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_saved_combined))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = person_saved_combined_keys))]\n/// A combined person_saved table.\npub struct PersonSavedCombined {\n  pub saved_at: DateTime<Utc>,\n  pub person_id: PersonId,\n  pub creator_id: PersonId,\n  pub post_id: Option<PostId>,\n  pub comment_id: Option<CommentId>,\n  pub id: PersonSavedCombinedId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/report.rs",
    "content": "use crate::newtypes::{\n  CommentReportId,\n  CommunityReportId,\n  PostReportId,\n  PrivateMessageReportId,\n  ReportCombinedId,\n};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::report_combined;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = report_combined))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = report_combined_keys))]\n/// A combined reports table.\npub struct ReportCombined {\n  pub id: ReportCombinedId,\n  pub published_at: DateTime<Utc>,\n  pub post_report_id: Option<PostReportId>,\n  pub comment_report_id: Option<CommentReportId>,\n  pub private_message_report_id: Option<PrivateMessageReportId>,\n  pub community_report_id: Option<CommunityReportId>,\n  pub resolved: bool,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/combined/search.rs",
    "content": "use crate::newtypes::{CommentId, CommunityId, MultiCommunityId, PostId, SearchCombinedId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::search_combined;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = search_combined))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = search_combined_keys))]\n/// A combined table for a search (posts, comments, communities, persons)\npub struct SearchCombined {\n  pub published_at: DateTime<Utc>,\n  pub score: i32,\n  pub post_id: Option<PostId>,\n  pub comment_id: Option<CommentId>,\n  pub community_id: Option<CommunityId>,\n  pub person_id: Option<PersonId>,\n  pub id: SearchCombinedId,\n  pub multi_community_id: Option<MultiCommunityId>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/comment.rs",
    "content": "use crate::newtypes::{CommentId, LanguageId, PostId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  crate::newtypes::LtreeDef,\n  diesel_ltree::Ltree,\n  i_love_jesus::CursorKeysModule,\n  lemmy_db_schema_file::schema::{comment, comment_actions},\n};\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::post::Post)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = comment_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment.\npub struct Comment {\n  pub id: CommentId,\n  pub creator_id: PersonId,\n  pub post_id: PostId,\n  pub content: String,\n  /// Whether the comment has been removed.\n  pub removed: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// Whether the comment has been deleted by its creator.\n  pub deleted: bool,\n  /// The federated activity id / ap_id.\n  pub ap_id: DbUrl,\n  /// Whether the comment is local.\n  pub local: bool,\n  #[cfg(feature = \"full\")]\n  #[cfg_attr(feature = \"full\", serde(with = \"LtreeDef\"))]\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  /// The path / tree location of a comment, separated by dots, ending with the comment's id. Ex:\n  /// 0.24.27\n  pub path: Ltree,\n  #[cfg(not(feature = \"full\"))]\n  pub path: String,\n  /// Whether the comment has been distinguished(speaking officially) by a mod.\n  pub distinguished: bool,\n  pub language_id: LanguageId,\n  pub score: i32,\n  pub upvotes: i32,\n  pub downvotes: i32,\n  /// The total number of children in this comment branch.\n  pub child_count: i32,\n  #[serde(skip)]\n  pub hot_rank: f32,\n  #[serde(skip)]\n  pub controversy_rank: f32,\n  pub report_count: i16,\n  pub unresolved_report_count: i16,\n  /// If a local user comments in a remote community, the comment is hidden until it is confirmed\n  /// accepted by the community (by receiving it back via federation).\n  pub federation_pending: bool,\n  /// Whether the comment is locked.\n  pub locked: bool,\n}\n\n#[derive(Debug, Clone, derive_new::new, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset,))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment))]\npub struct CommentInsertForm {\n  pub creator_id: PersonId,\n  pub post_id: PostId,\n  pub content: String,\n  #[new(default)]\n  pub removed: Option<bool>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub deleted: Option<bool>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub local: Option<bool>,\n  #[new(default)]\n  pub distinguished: Option<bool>,\n  #[new(default)]\n  pub language_id: Option<LanguageId>,\n  #[new(default)]\n  pub federation_pending: Option<bool>,\n  #[new(default)]\n  pub locked: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset, Serialize, Deserialize))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment))]\npub struct CommentUpdateForm {\n  pub content: Option<String>,\n  pub removed: Option<bool>,\n  // Don't use a default Utc::now here, because the create function does a lot of comment updates\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub deleted: Option<bool>,\n  pub ap_id: Option<DbUrl>,\n  pub local: Option<bool>,\n  pub distinguished: Option<bool>,\n  pub language_id: Option<LanguageId>,\n  pub federation_pending: Option<bool>,\n  pub locked: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::comment::Comment)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment_actions))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(person_id, comment_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = comment_actions_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CommentActions {\n  /// When the comment was upvoted or downvoted.\n  pub voted_at: Option<DateTime<Utc>>,\n  /// When the comment was saved.\n  pub saved_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub comment_id: CommentId,\n  /// True if upvoted, false if downvoted. Upvote is greater than downvote.\n  pub vote_is_upvote: Option<bool>,\n}\n\n#[derive(Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Insertable, AsChangeset, Serialize, Deserialize)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment_actions))]\npub struct CommentLikeForm {\n  person_id: PersonId,\n  comment_id: CommentId,\n  vote_is_upvote: Option<Option<bool>>,\n  voted_at: Option<Option<DateTime<Utc>>>,\n}\n\nimpl CommentLikeForm {\n  /// Pass `is_upvote: None` to remove an existing vote for this comment\n  pub fn new(comment_id: CommentId, person_id: PersonId, is_upvote: Option<bool>) -> Self {\n    let voted_at = if is_upvote.is_some() {\n      Some(Some(Utc::now()))\n    } else {\n      Some(None)\n    };\n\n    Self {\n      comment_id,\n      person_id,\n      vote_is_upvote: Some(is_upvote),\n      voted_at,\n    }\n  }\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment_actions))]\npub struct CommentSavedForm {\n  pub person_id: PersonId,\n  pub comment_id: CommentId,\n  #[new(value = \"Utc::now()\")]\n  pub saved_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/comment_report.rs",
    "content": "use crate::newtypes::{CommentId, CommentReportId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::comment_report;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::comment::Comment)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment_report))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment report.\npub struct CommentReport {\n  pub id: CommentReportId,\n  pub creator_id: PersonId,\n  pub comment_id: CommentId,\n  pub original_comment_text: String,\n  pub reason: String,\n  pub resolved: bool,\n  pub resolver_id: Option<PersonId>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub violates_instance_rules: bool,\n}\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = comment_report))]\npub struct CommentReportForm {\n  pub creator_id: PersonId,\n  pub comment_id: CommentId,\n  pub original_comment_text: String,\n  pub reason: String,\n  pub violates_instance_rules: bool,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/community.rs",
    "content": "use crate::{newtypes::CommunityId, source::placeholder_apub_url};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{CommunityFollowerState, CommunityNotificationsMode, CommunityVisibility},\n};\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  i_love_jesus::CursorKeysModule,\n  lemmy_db_schema_file::schema::{community, community_actions},\n};\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = community))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = community_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community.\npub struct Community {\n  pub id: CommunityId,\n  pub name: String,\n  /// A longer title, that can contain other characters, and doesn't have to be unique.\n  pub title: String,\n  /// A sidebar for the community in markdown.\n  pub sidebar: Option<String>,\n  /// Whether the community is removed by a mod.\n  pub removed: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// Whether the community has been deleted by its creator.\n  pub deleted: bool,\n  /// Whether its an NSFW community.\n  pub nsfw: bool,\n  /// The federated ap_id.\n  pub ap_id: DbUrl,\n  /// Whether the community is local.\n  pub local: bool,\n  #[serde(skip)]\n  pub private_key: Option<SensitiveString>,\n  #[serde(skip)]\n  pub public_key: String,\n  pub last_refreshed_at: DateTime<Utc>,\n  /// A URL for an icon.\n  pub icon: Option<DbUrl>,\n  /// A URL for a banner.\n  pub banner: Option<DbUrl>,\n  #[cfg_attr(feature = \"ts-rs\", ts(skip))]\n  #[serde(skip)]\n  pub followers_url: Option<DbUrl>,\n  #[cfg_attr(feature = \"ts-rs\", ts(skip))]\n  #[serde(skip, default = \"placeholder_apub_url\")]\n  pub inbox_url: DbUrl,\n  /// Whether posting is restricted to mods only.\n  pub posting_restricted_to_mods: bool,\n  pub instance_id: InstanceId,\n  /// Url where moderators collection is served over Activitypub\n  #[serde(skip)]\n  pub moderators_url: Option<DbUrl>,\n  /// Url where featured posts collection is served over Activitypub\n  #[serde(skip)]\n  pub featured_url: Option<DbUrl>,\n  pub visibility: CommunityVisibility,\n  /// A shorter, one-line summary.\n  pub summary: Option<String>,\n  #[serde(skip)]\n  pub random_number: i16,\n  pub subscribers: i32,\n  pub posts: i32,\n  pub comments: i32,\n  /// The number of users with any activity in the last day.\n  pub users_active_day: i32,\n  /// The number of users with any activity in the last week.\n  pub users_active_week: i32,\n  /// The number of users with any activity in the last month.\n  pub users_active_month: i32,\n  /// The number of users with any activity in the last year.\n  pub users_active_half_year: i32,\n  #[serde(skip)]\n  pub hot_rank: f32,\n  pub subscribers_local: i32,\n  /// Number of any interactions over the last month.\n  #[serde(skip)]\n  pub interactions_month: i32,\n  pub report_count: i16,\n  pub unresolved_report_count: i16,\n  pub local_removed: bool,\n}\n\n#[derive(Debug, Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community))]\npub struct CommunityInsertForm {\n  pub instance_id: InstanceId,\n  pub name: String,\n  pub title: String,\n  pub public_key: String,\n  #[new(default)]\n  pub sidebar: Option<String>,\n  #[new(default)]\n  pub removed: Option<bool>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub deleted: Option<bool>,\n  #[new(default)]\n  pub nsfw: Option<bool>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub local: Option<bool>,\n  #[new(default)]\n  pub private_key: Option<String>,\n  #[new(default)]\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub icon: Option<DbUrl>,\n  #[new(default)]\n  pub banner: Option<DbUrl>,\n  #[new(default)]\n  pub followers_url: Option<DbUrl>,\n  #[new(default)]\n  pub inbox_url: Option<DbUrl>,\n  #[new(default)]\n  pub moderators_url: Option<DbUrl>,\n  #[new(default)]\n  pub featured_url: Option<DbUrl>,\n  #[new(default)]\n  pub posting_restricted_to_mods: Option<bool>,\n  #[new(default)]\n  pub visibility: Option<CommunityVisibility>,\n  #[new(default)]\n  pub summary: Option<String>,\n  #[new(default)]\n  pub local_removed: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community))]\npub struct CommunityUpdateForm {\n  pub title: Option<String>,\n  pub sidebar: Option<Option<String>>,\n  pub removed: Option<bool>,\n  pub published_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub deleted: Option<bool>,\n  pub nsfw: Option<bool>,\n  pub ap_id: Option<DbUrl>,\n  pub local: Option<bool>,\n  pub public_key: Option<String>,\n  pub private_key: Option<Option<String>>,\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  pub icon: Option<Option<DbUrl>>,\n  pub banner: Option<Option<DbUrl>>,\n  pub followers_url: Option<DbUrl>,\n  pub inbox_url: Option<DbUrl>,\n  pub moderators_url: Option<Option<DbUrl>>,\n  pub featured_url: Option<Option<DbUrl>>,\n  pub posting_restricted_to_mods: Option<bool>,\n  pub visibility: Option<CommunityVisibility>,\n  pub summary: Option<Option<String>>,\n  pub local_removed: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::community::Community))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_actions))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(person_id, community_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = community_actions_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CommunityActions {\n  /// When the community was followed.\n  pub followed_at: Option<DateTime<Utc>>,\n  /// When the community was blocked.\n  pub blocked_at: Option<DateTime<Utc>>,\n  /// When this user became a moderator.\n  pub became_moderator_at: Option<DateTime<Utc>>,\n  /// When this user received a ban.\n  pub received_ban_at: Option<DateTime<Utc>>,\n  /// When their ban expires.\n  pub ban_expires_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub community_id: CommunityId,\n  /// The state of the community follow.\n  pub follow_state: Option<CommunityFollowerState>,\n  /// The approver of the community follow.\n  #[serde(skip)]\n  pub follow_approver_id: Option<PersonId>,\n  pub notifications: Option<CommunityNotificationsMode>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_actions))]\npub struct CommunityModeratorForm {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub became_moderator_at: DateTime<Utc>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_actions))]\npub struct CommunityPersonBanForm {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  #[new(default)]\n  pub ban_expires_at: Option<Option<DateTime<Utc>>>,\n  #[new(value = \"Utc::now()\")]\n  pub received_ban_at: DateTime<Utc>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_actions))]\npub struct CommunityFollowerForm {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  pub follow_state: CommunityFollowerState,\n  #[new(default)]\n  pub follow_approver_id: Option<PersonId>,\n  #[new(value = \"Utc::now()\")]\n  pub followed_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_actions))]\npub struct CommunityBlockForm {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub blocked_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/community_community_follow.rs",
    "content": "use crate::newtypes::CommunityId;\nuse lemmy_db_schema_file::schema::community_community_follow;\n\n#[derive(Clone, Debug, PartialEq, Queryable, Selectable)]\n#[diesel(belongs_to(crate::source::community::Community))]\n#[ diesel(table_name = community_community_follow)]\n#[diesel(check_for_backend(diesel::pg::Pg))]\npub struct CommunityCommunityFollow {\n  pub target_id: CommunityId,\n  pub community_id: CommunityId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/community_report.rs",
    "content": "use crate::newtypes::{CommunityId, CommunityReportId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::community_report;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::community::Community))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_report))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment report.\npub struct CommunityReport {\n  pub id: CommunityReportId,\n  pub creator_id: PersonId,\n  pub community_id: CommunityId,\n  pub original_community_name: String,\n  pub original_community_title: String,\n  pub original_community_summary: Option<String>,\n  pub original_community_sidebar: Option<String>,\n  pub original_community_icon: Option<String>,\n  pub original_community_banner: Option<String>,\n  pub reason: String,\n  pub resolved: bool,\n  pub resolver_id: Option<PersonId>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_report))]\npub struct CommunityReportForm {\n  pub creator_id: PersonId,\n  pub community_id: CommunityId,\n  pub original_community_name: String,\n  pub original_community_title: String,\n  pub original_community_summary: Option<String>,\n  pub original_community_sidebar: Option<String>,\n  pub original_community_icon: Option<DbUrl>,\n  pub original_community_banner: Option<DbUrl>,\n  pub reason: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/community_tag.rs",
    "content": "use crate::newtypes::{CommunityId, CommunityTagId, PostId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse diesel::{AsExpression, FromSqlRow, sql_types::Nullable};\nuse lemmy_db_schema_file::enums::TagColor;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::{community_tag, post_community_tag};\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n/// A tag that is created by community moderators, and assigned to posts by the creator\n/// or by mods.\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_tag))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CommunityTag {\n  pub id: CommunityTagId,\n  pub ap_id: DbUrl,\n  pub name: String,\n  pub display_name: Option<String>,\n  pub summary: Option<String>,\n  /// The community that this tag belongs to\n  pub community_id: CommunityId,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub deleted: bool,\n  pub color: TagColor,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_tag))]\npub struct CommunityTagInsertForm {\n  pub ap_id: DbUrl,\n  pub name: String,\n  pub display_name: Option<String>,\n  pub summary: Option<String>,\n  pub community_id: CommunityId,\n  pub deleted: Option<bool>,\n  pub color: Option<TagColor>,\n}\n\n#[derive(Debug, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = community_tag))]\npub struct CommunityTagUpdateForm {\n  pub display_name: Option<Option<String>>,\n  pub summary: Option<Option<String>>,\n  pub community_id: Option<CommunityId>,\n  pub published_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub deleted: Option<bool>,\n  pub color: Option<TagColor>,\n}\n\n/// We wrap this in a struct so we can implement FromSqlRow<Json> for it\n#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)]\n#[serde(transparent)]\n#[cfg_attr(feature = \"full\", derive(FromSqlRow, AsExpression))]\n#[cfg_attr(feature = \"full\", diesel(sql_type = Nullable<diesel::sql_types::Json>))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CommunityTagsView(pub Vec<CommunityTag>);\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::post::Post)))]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::community_tag::CommunityTag))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_community_tag))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(post_id, community_tag_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n/// An association between a post and a tag. Created/updated by the post author or mods of a\n/// community.\npub struct PostCommunityTag {\n  pub post_id: PostId,\n  pub community_tag_id: CommunityTagId,\n  pub published_at: DateTime<Utc>,\n}\n\n#[derive(Clone, Debug)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_community_tag))]\npub struct PostCommunityTagForm {\n  pub post_id: PostId,\n  pub community_tag_id: CommunityTagId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/custom_emoji.rs",
    "content": "use crate::newtypes::CustomEmojiId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::custom_emoji;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = custom_emoji))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A custom emoji.\npub struct CustomEmoji {\n  pub id: CustomEmojiId,\n  pub shortcode: String,\n  pub image_url: DbUrl,\n  pub alt_text: String,\n  pub category: String,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = custom_emoji))]\npub struct CustomEmojiInsertForm {\n  pub shortcode: String,\n  pub image_url: DbUrl,\n  pub alt_text: String,\n  pub category: String,\n}\n\n#[derive(Debug, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = custom_emoji))]\npub struct CustomEmojiUpdateForm {\n  pub shortcode: Option<String>,\n  pub image_url: Option<DbUrl>,\n  pub alt_text: Option<String>,\n  pub category: Option<String>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/custom_emoji_keyword.rs",
    "content": "use crate::newtypes::CustomEmojiId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::custom_emoji_keyword;\nuse serde::{Deserialize, Serialize};\n\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = custom_emoji_keyword))]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::custom_emoji::CustomEmoji))\n)]\n#[cfg_attr(feature = \"full\", diesel(primary_key(custom_emoji_id, keyword)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A custom keyword for an emoji.\npub struct CustomEmojiKeyword {\n  pub custom_emoji_id: CustomEmojiId,\n  pub keyword: String,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = custom_emoji_keyword))]\npub struct CustomEmojiKeywordInsertForm {\n  pub custom_emoji_id: CustomEmojiId,\n  pub keyword: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/email_verification.rs",
    "content": "use crate::newtypes::LocalUserId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::email_verification;\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = email_verification))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct EmailVerification {\n  pub id: i32,\n  pub local_user_id: LocalUserId,\n  pub email: String,\n  pub verification_token: String,\n  pub published_at: DateTime<Utc>,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = email_verification))]\npub struct EmailVerificationForm {\n  pub local_user_id: LocalUserId,\n  pub email: String,\n  pub verification_token: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/federation_allowlist.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::InstanceId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::federation_allowlist;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::Debug;\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::instance::Instance))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = federation_allowlist))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(instance_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct FederationAllowList {\n  #[serde(skip)]\n  pub instance_id: InstanceId,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, Default, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = federation_allowlist))]\npub struct FederationAllowListForm {\n  pub instance_id: InstanceId,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/federation_blocklist.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::InstanceId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::federation_blocklist;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::Debug;\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::instance::Instance))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = federation_blocklist))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(instance_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct FederationBlockList {\n  #[serde(skip)]\n  pub instance_id: InstanceId,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub expires_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, Default, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = federation_blocklist))]\npub struct FederationBlockListForm {\n  pub instance_id: InstanceId,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  pub expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/federation_queue_state.rs",
    "content": "use crate::newtypes::ActivityId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse diesel::prelude::*;\nuse lemmy_db_schema_file::InstanceId;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Insertable, AsChangeset)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = lemmy_db_schema_file::schema::federation_queue_state))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct FederationQueueState {\n  pub instance_id: InstanceId,\n  /// the last successfully sent activity id\n  pub last_successful_id: Option<ActivityId>,\n  pub last_successful_published_time_at: Option<DateTime<Utc>>,\n  /// how many failed attempts have been made to send the next activity\n  pub fail_count: i32,\n  /// timestamp of the last retry attempt (when the last failing activity was resent)\n  pub last_retry_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/images.rs",
    "content": "use crate::newtypes::PostId;\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse std::fmt::Debug;\n#[cfg(feature = \"full\")]\nuse {\n  i_love_jesus::CursorKeysModule,\n  lemmy_db_schema_file::schema::{image_details, local_image, remote_image},\n};\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, Associations, CursorKeysModule,)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_image))]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::person::Person)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(pictrs_alias)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = local_image_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct LocalImage {\n  pub pictrs_alias: String,\n  pub published_at: DateTime<Utc>,\n  pub person_id: Option<PersonId>,\n  /// This means the image is an auto-generated thumbnail, for a post.\n  pub thumbnail_for_post_id: Option<PostId>,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_image))]\npub struct LocalImageForm {\n  pub pictrs_alias: String,\n  pub person_id: PersonId,\n  pub thumbnail_for_post_id: Option<Option<PostId>>,\n}\n\n/// Stores all images which are hosted on remote domains. When attempting to proxy an image, it\n/// is checked against this table to avoid Lemmy being used as a general purpose proxy.\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = remote_image))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(link)))]\npub struct RemoteImage {\n  pub link: DbUrl,\n  pub published_at: DateTime<Utc>,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = image_details))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(link)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ImageDetails {\n  pub link: DbUrl,\n  pub width: i32,\n  pub height: i32,\n  pub content_type: String,\n  pub blurhash: Option<String>,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = image_details))]\npub struct ImageDetailsInsertForm {\n  pub link: DbUrl,\n  pub width: i32,\n  pub height: i32,\n  pub content_type: String,\n  pub blurhash: Option<String>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/instance.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::{InstanceId, PersonId};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse std::fmt::Debug;\n#[cfg(feature = \"full\")]\nuse {\n  i_love_jesus::CursorKeysModule,\n  lemmy_db_schema_file::schema::{instance, instance_actions},\n};\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = instance_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Basic data about a Fediverse instance which is available for every known domain. Additional\n/// data may be available in [[Site]].\npub struct Instance {\n  pub id: InstanceId,\n  pub domain: String,\n  pub published_at: DateTime<Utc>,\n  /// When the instance was updated.\n  pub updated_at: Option<DateTime<Utc>>,\n  /// The software of the instance.\n  pub software: Option<String>,\n  /// The version of the instance's software.\n  pub version: Option<String>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance))]\npub struct InstanceForm {\n  pub domain: String,\n  #[new(default)]\n  pub software: Option<String>,\n  #[new(default)]\n  pub version: Option<String>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::instance::Instance))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance_actions))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(person_id, instance_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct InstanceActions {\n  /// When the instance's communities were blocked.\n  pub blocked_communities_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub instance_id: InstanceId,\n  /// When this user received a site ban.\n  pub received_ban_at: Option<DateTime<Utc>>,\n  /// When their ban expires.\n  pub ban_expires_at: Option<DateTime<Utc>>,\n  /// When the instance's persons were blocked.\n  pub blocked_persons_at: Option<DateTime<Utc>>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance_actions))]\npub struct InstanceCommunitiesBlockForm {\n  pub person_id: PersonId,\n  pub instance_id: InstanceId,\n  #[new(value = \"Utc::now()\")]\n  pub blocked_communities_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance_actions))]\npub struct InstancePersonsBlockForm {\n  pub person_id: PersonId,\n  pub instance_id: InstanceId,\n  #[new(value = \"Utc::now()\")]\n  pub blocked_persons_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = instance_actions))]\npub struct InstanceBanForm {\n  pub person_id: PersonId,\n  pub instance_id: InstanceId,\n  #[new(value = \"Utc::now()\")]\n  pub received_ban_at: DateTime<Utc>,\n  pub ban_expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/keyword_block.rs",
    "content": "use crate::newtypes::LocalUserId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_user_keyword_block;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user_keyword_block))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(local_user_id, keyword)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct LocalUserKeywordBlock {\n  pub local_user_id: LocalUserId,\n  pub keyword: String,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user_keyword_block))]\npub struct LocalUserKeywordBlockForm {\n  pub local_user_id: LocalUserId,\n  pub keyword: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/language.rs",
    "content": "use crate::newtypes::LanguageId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::language;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = language))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A language.\npub struct Language {\n  pub id: LanguageId,\n  pub code: String,\n  pub name: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/local_site.rs",
    "content": "use crate::newtypes::{LocalSiteId, MultiCommunityId, SiteId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_site;\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::{\n    CommentSortType,\n    FederationMode,\n    ImageMode,\n    ListingType,\n    PostListingMode,\n    PostSortType,\n    RegistrationMode,\n  },\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site))]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::site::Site)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The local site.\npub struct LocalSite {\n  pub id: LocalSiteId,\n  pub site_id: SiteId,\n  /// True if the site is set up.\n  pub site_setup: bool,\n  /// Whether only admins can create communities.\n  pub community_creation_admin_only: bool,\n  /// Whether emails are required.\n  pub require_email_verification: bool,\n  /// An optional registration application questionnaire in markdown.\n  pub application_question: Option<String>,\n  /// Whether the instance is private or public.\n  pub private_instance: bool,\n  /// The default front-end theme.\n  pub default_theme: String,\n  pub default_post_listing_type: ListingType,\n  /// An optional legal disclaimer page.\n  pub legal_information: Option<String>,\n  /// Whether new applications email admins.\n  pub application_email_admins: bool,\n  /// An optional regex to filter words.\n  pub slur_filter_regex: Option<String>,\n  /// Whether federation is enabled.\n  pub federation_enabled: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub registration_mode: RegistrationMode,\n  /// Whether to email admins on new reports.\n  pub reports_email_admins: bool,\n  /// Whether to sign outgoing Activitypub fetches with private key of local instance. Some\n  /// Fediverse instances and platforms require this.\n  pub federation_signed_fetch: bool,\n  /// Default value for [LocalSite.post_listing_mode]\n  pub default_post_listing_mode: PostListingMode,\n  /// Default value for [LocalUser.post_sort_type]\n  pub default_post_sort_type: PostSortType,\n  /// Default value for [LocalUser.comment_sort_type]\n  pub default_comment_sort_type: CommentSortType,\n  /// Whether or not external auth methods can auto-register users.\n  pub oauth_registration: bool,\n  /// What kind of post upvotes your site allows.\n  pub post_upvotes: FederationMode,\n  /// What kind of post downvotes your site allows.\n  pub post_downvotes: FederationMode,\n  /// What kind of comment upvotes your site allows.\n  pub comment_upvotes: FederationMode,\n  /// What kind of comment downvotes your site allows.\n  pub comment_downvotes: FederationMode,\n  /// A default time range limit to apply to post sorts, in seconds.\n  pub default_post_time_range_seconds: Option<i32>,\n  /// Block NSFW content being created\n  pub disallow_nsfw_content: bool,\n  pub users: i32,\n  pub posts: i32,\n  pub comments: i32,\n  pub communities: i32,\n  /// The number of users with any activity in the last day.\n  pub users_active_day: i32,\n  /// The number of users with any activity in the last week.\n  pub users_active_week: i32,\n  /// The number of users with any activity in the last month.\n  pub users_active_month: i32,\n  /// The number of users with any activity in the last half year.\n  pub users_active_half_year: i32,\n  /// Dont send email notifications to users for new replies, mentions etc\n  pub disable_email_notifications: bool,\n  pub suggested_multi_community_id: Option<MultiCommunityId>,\n  #[serde(skip)]\n  pub system_account: PersonId,\n  pub default_items_per_page: i32,\n  /// A mode for setting how pictrs handles images.\n  pub image_mode: ImageMode,\n  /// Allows bypassing proxy for specific image hosts when using [[ImageMode.ProxyAllImages]]. Use\n  /// a comma-delimited string.\n  ///\n  /// Example: i.imgur.com,postimg.cc\n  pub image_proxy_bypass_domains: Option<String>,\n  pub image_upload_timeout_seconds: i32,\n  /// These are pixel sizes. Larger images are automatically downscaled.\n  pub image_max_thumbnail_size: i32,\n  pub image_max_avatar_size: i32,\n  pub image_max_banner_size: i32,\n  /// This affects post and comment images, but not avatar and banner sizes.\n  pub image_max_upload_size: i32,\n  /// This affects post and comment images, but not avatars and banners.\n  pub image_allow_video_uploads: bool,\n  pub image_upload_disabled: bool,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site))]\npub struct LocalSiteInsertForm {\n  pub site_id: SiteId,\n  #[new(default)]\n  pub site_setup: Option<bool>,\n  #[new(default)]\n  pub community_creation_admin_only: Option<bool>,\n  #[new(default)]\n  pub require_email_verification: Option<bool>,\n  #[new(default)]\n  pub application_question: Option<String>,\n  #[new(default)]\n  pub private_instance: Option<bool>,\n  #[new(default)]\n  pub default_theme: Option<String>,\n  #[new(default)]\n  pub default_post_listing_type: Option<ListingType>,\n  #[new(default)]\n  pub legal_information: Option<String>,\n  #[new(default)]\n  pub application_email_admins: Option<bool>,\n  #[new(default)]\n  pub slur_filter_regex: Option<String>,\n  #[new(default)]\n  pub federation_enabled: Option<bool>,\n  #[new(default)]\n  pub registration_mode: Option<RegistrationMode>,\n  #[new(default)]\n  pub reports_email_admins: Option<bool>,\n  #[new(default)]\n  pub federation_signed_fetch: Option<bool>,\n  #[new(default)]\n  pub default_post_listing_mode: Option<PostListingMode>,\n  #[new(default)]\n  pub default_post_sort_type: Option<PostSortType>,\n  #[new(default)]\n  pub default_comment_sort_type: Option<CommentSortType>,\n  #[new(default)]\n  pub oauth_registration: Option<bool>,\n  #[new(default)]\n  pub post_upvotes: Option<FederationMode>,\n  #[new(default)]\n  pub post_downvotes: Option<FederationMode>,\n  #[new(default)]\n  pub comment_upvotes: Option<FederationMode>,\n  #[new(default)]\n  pub comment_downvotes: Option<FederationMode>,\n  #[new(default)]\n  pub default_post_time_range_seconds: Option<i32>,\n  #[new(default)]\n  pub disallow_nsfw_content: bool,\n  #[new(default)]\n  pub disable_email_notifications: bool,\n  #[new(default)]\n  pub suggested_multi_community_id: Option<MultiCommunityId>,\n  #[new(default)]\n  pub system_account: Option<PersonId>,\n  #[new(default)]\n  pub image_mode: Option<ImageMode>,\n  #[new(default)]\n  pub image_proxy_bypass_domains: Option<String>,\n  #[new(default)]\n  pub image_upload_timeout_seconds: Option<i32>,\n  #[new(default)]\n  pub image_max_thumbnail_size: Option<i32>,\n  #[new(default)]\n  pub image_max_avatar_size: Option<i32>,\n  #[new(default)]\n  pub image_max_banner_size: Option<i32>,\n  #[new(default)]\n  pub image_max_upload_size: Option<i32>,\n  #[new(default)]\n  pub image_allow_video_uploads: Option<bool>,\n  #[new(default)]\n  pub image_upload_disabled: Option<bool>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site))]\npub struct LocalSiteUpdateForm {\n  pub site_setup: Option<bool>,\n  pub community_creation_admin_only: Option<bool>,\n  pub require_email_verification: Option<bool>,\n  pub application_question: Option<Option<String>>,\n  pub private_instance: Option<bool>,\n  pub default_theme: Option<String>,\n  pub default_post_listing_type: Option<ListingType>,\n  pub legal_information: Option<Option<String>>,\n  pub application_email_admins: Option<bool>,\n  pub slur_filter_regex: Option<Option<String>>,\n  pub federation_enabled: Option<bool>,\n  pub registration_mode: Option<RegistrationMode>,\n  pub reports_email_admins: Option<bool>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub federation_signed_fetch: Option<bool>,\n  pub default_post_listing_mode: Option<PostListingMode>,\n  pub default_post_sort_type: Option<PostSortType>,\n  pub default_comment_sort_type: Option<CommentSortType>,\n  pub oauth_registration: Option<bool>,\n  pub post_upvotes: Option<FederationMode>,\n  pub post_downvotes: Option<FederationMode>,\n  pub comment_upvotes: Option<FederationMode>,\n  pub comment_downvotes: Option<FederationMode>,\n  pub default_post_time_range_seconds: Option<Option<i32>>,\n  pub disallow_nsfw_content: Option<bool>,\n  pub disable_email_notifications: Option<bool>,\n  pub suggested_multi_community_id: Option<Option<MultiCommunityId>>,\n  pub default_items_per_page: Option<i32>,\n  pub image_mode: Option<ImageMode>,\n  pub image_proxy_bypass_domains: Option<Option<String>>,\n  pub image_upload_timeout_seconds: Option<i32>,\n  pub image_max_thumbnail_size: Option<i32>,\n  pub image_max_avatar_size: Option<i32>,\n  pub image_max_banner_size: Option<i32>,\n  pub image_max_upload_size: Option<i32>,\n  pub image_allow_video_uploads: Option<bool>,\n  pub image_upload_disabled: Option<bool>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/local_site_rate_limit.rs",
    "content": "use crate::newtypes::LocalSiteId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_site_rate_limit;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site_rate_limit))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(local_site_id)))]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::local_site::LocalSite))\n)]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Rate limits for your site. Given in count / length of time.\npub struct LocalSiteRateLimit {\n  pub local_site_id: LocalSiteId,\n  pub message_max_requests: i32,\n  pub message_interval_seconds: i32,\n  pub post_max_requests: i32,\n  pub post_interval_seconds: i32,\n  pub register_max_requests: i32,\n  pub register_interval_seconds: i32,\n  pub image_max_requests: i32,\n  pub image_interval_seconds: i32,\n  pub comment_max_requests: i32,\n  pub comment_interval_seconds: i32,\n  pub search_max_requests: i32,\n  pub search_interval_seconds: i32,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub import_user_settings_max_requests: i32,\n  pub import_user_settings_interval_seconds: i32,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site_rate_limit))]\npub struct LocalSiteRateLimitInsertForm {\n  pub local_site_id: LocalSiteId,\n  #[new(default)]\n  pub message_max_requests: Option<i32>,\n  #[new(default)]\n  pub message_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub post_max_requests: Option<i32>,\n  #[new(default)]\n  pub post_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub register_max_requests: Option<i32>,\n  #[new(default)]\n  pub register_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub image_max_requests: Option<i32>,\n  #[new(default)]\n  pub image_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub comment_max_requests: Option<i32>,\n  #[new(default)]\n  pub comment_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub search_max_requests: Option<i32>,\n  #[new(default)]\n  pub search_interval_seconds: Option<i32>,\n  #[new(default)]\n  pub import_user_settings_max_requests: Option<i32>,\n  #[new(default)]\n  pub import_user_settings_interval_seconds: Option<i32>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site_rate_limit))]\npub struct LocalSiteRateLimitUpdateForm {\n  pub message_max_requests: Option<i32>,\n  pub message_interval_seconds: Option<i32>,\n  pub post_max_requests: Option<i32>,\n  pub post_interval_seconds: Option<i32>,\n  pub register_max_requests: Option<i32>,\n  pub register_interval_seconds: Option<i32>,\n  pub image_max_requests: Option<i32>,\n  pub image_interval_seconds: Option<i32>,\n  pub comment_max_requests: Option<i32>,\n  pub comment_interval_seconds: Option<i32>,\n  pub search_max_requests: Option<i32>,\n  pub search_interval_seconds: Option<i32>,\n  pub import_user_settings_max_requests: Option<i32>,\n  pub import_user_settings_interval_seconds: Option<i32>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/local_site_url_blocklist.rs",
    "content": "use chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_site_url_blocklist;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site_url_blocklist))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct LocalSiteUrlBlocklist {\n  pub id: i32,\n  pub url: String,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Default, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_site_url_blocklist))]\npub struct LocalSiteUrlBlocklistForm {\n  pub url: String,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/local_user.rs",
    "content": "use crate::newtypes::LocalUserId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::local_user;\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::{CommentSortType, ListingType, PostListingMode, PostSortType, VoteShow},\n};\nuse lemmy_diesel_utils::sensitive::SensitiveString;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n#[serde(default)]\n/// A local user.\npub struct LocalUser {\n  pub id: LocalUserId,\n  /// The person_id for the local user.\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub password_encrypted: Option<SensitiveString>,\n  pub email: Option<SensitiveString>,\n  /// Whether to show NSFW content.\n  pub show_nsfw: bool,\n  pub theme: String,\n  pub default_post_sort_type: PostSortType,\n  pub default_listing_type: ListingType,\n  pub interface_language: String,\n  /// Whether to show avatars.\n  pub show_avatars: bool,\n  pub send_notifications_to_email: bool,\n  /// Whether to show bot accounts.\n  pub show_bot_accounts: bool,\n  /// Whether to show read posts.\n  pub show_read_posts: bool,\n  /// Whether their email has been verified.\n  pub email_verified: bool,\n  /// Whether their registration application has been accepted.\n  pub accepted_application: bool,\n  #[serde(skip)]\n  pub totp_2fa_secret: Option<SensitiveString>,\n  /// Open links in a new tab.\n  pub open_links_in_new_tab: bool,\n  pub blur_nsfw: bool,\n  /// Whether infinite scroll is enabled.\n  pub infinite_scroll_enabled: bool,\n  /// Whether the person is an admin.\n  pub admin: bool,\n  /// A post-view mode that changes how multiple post listings look.\n  pub post_listing_mode: PostListingMode,\n  pub totp_2fa_enabled: bool,\n  /// Whether user avatars and inline images in the UI that are gifs should be allowed to play or\n  /// should be paused\n  pub enable_animated_images: bool,\n  /// Whether to auto-collapse bot comments.\n  pub collapse_bot_comments: bool,\n  /// The last time a donation request was shown to this user. If this is more than a year ago,\n  /// a new notification request should be shown.\n  pub last_donation_notification_at: DateTime<Utc>,\n  /// Whether a user can send / receive private messages\n  pub enable_private_messages: bool,\n  pub default_comment_sort_type: CommentSortType,\n  /// Whether to automatically mark fetched posts as read.\n  pub auto_mark_fetched_posts_as_read: bool,\n  /// Whether to hide posts containing images/videos\n  pub hide_media: bool,\n  /// A default time range limit to apply to post sorts, in seconds.\n  pub default_post_time_range_seconds: Option<i32>,\n  pub show_score: bool,\n  pub show_upvotes: bool,\n  pub show_downvotes: VoteShow,\n  pub show_upvote_percentage: bool,\n  pub show_person_votes: bool,\n  pub default_items_per_page: i32,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user))]\npub struct LocalUserInsertForm {\n  pub person_id: PersonId,\n  pub password_encrypted: Option<String>,\n  #[new(default)]\n  pub email: Option<String>,\n  #[new(default)]\n  pub show_nsfw: Option<bool>,\n  #[new(default)]\n  pub theme: Option<String>,\n  #[new(default)]\n  pub default_post_sort_type: Option<PostSortType>,\n  #[new(default)]\n  pub default_listing_type: Option<ListingType>,\n  #[new(default)]\n  pub interface_language: Option<String>,\n  #[new(default)]\n  pub show_avatars: Option<bool>,\n  #[new(default)]\n  pub send_notifications_to_email: Option<bool>,\n  #[new(default)]\n  pub show_bot_accounts: Option<bool>,\n  #[new(default)]\n  pub show_read_posts: Option<bool>,\n  #[new(default)]\n  pub email_verified: Option<bool>,\n  #[new(default)]\n  pub accepted_application: Option<bool>,\n  #[new(default)]\n  pub totp_2fa_secret: Option<Option<String>>,\n  #[new(default)]\n  pub open_links_in_new_tab: Option<bool>,\n  #[new(default)]\n  pub blur_nsfw: Option<bool>,\n  #[new(default)]\n  pub infinite_scroll_enabled: Option<bool>,\n  #[new(default)]\n  pub admin: Option<bool>,\n  #[new(default)]\n  pub post_listing_mode: Option<PostListingMode>,\n  #[new(default)]\n  pub totp_2fa_enabled: Option<bool>,\n  #[new(default)]\n  pub enable_animated_images: Option<bool>,\n  #[new(default)]\n  pub collapse_bot_comments: Option<bool>,\n  #[new(default)]\n  pub last_donation_notification_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub enable_private_messages: Option<bool>,\n  #[new(default)]\n  pub default_comment_sort_type: Option<CommentSortType>,\n  #[new(default)]\n  pub auto_mark_fetched_posts_as_read: Option<bool>,\n  #[new(default)]\n  pub hide_media: Option<bool>,\n  #[new(default)]\n  pub default_post_time_range_seconds: Option<i32>,\n  #[new(default)]\n  pub show_score: Option<bool>,\n  #[new(default)]\n  pub show_upvotes: Option<bool>,\n  #[new(default)]\n  pub show_downvotes: Option<VoteShow>,\n  #[new(default)]\n  pub show_upvote_percentage: Option<bool>,\n  #[new(default)]\n  pub show_person_votes: Option<bool>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = local_user))]\npub struct LocalUserUpdateForm {\n  pub password_encrypted: Option<Option<String>>,\n  pub email: Option<Option<String>>,\n  pub show_nsfw: Option<bool>,\n  pub theme: Option<String>,\n  pub default_post_sort_type: Option<PostSortType>,\n  pub default_listing_type: Option<ListingType>,\n  pub interface_language: Option<String>,\n  pub show_avatars: Option<bool>,\n  pub send_notifications_to_email: Option<bool>,\n  pub show_bot_accounts: Option<bool>,\n  pub show_read_posts: Option<bool>,\n  pub email_verified: Option<bool>,\n  pub accepted_application: Option<bool>,\n  pub totp_2fa_secret: Option<Option<String>>,\n  pub open_links_in_new_tab: Option<bool>,\n  pub blur_nsfw: Option<bool>,\n  pub infinite_scroll_enabled: Option<bool>,\n  pub admin: Option<bool>,\n  pub post_listing_mode: Option<PostListingMode>,\n  pub totp_2fa_enabled: Option<bool>,\n  pub enable_animated_images: Option<bool>,\n  pub collapse_bot_comments: Option<bool>,\n  pub last_donation_notification_at: Option<DateTime<Utc>>,\n  pub enable_private_messages: Option<bool>,\n  pub default_comment_sort_type: Option<CommentSortType>,\n  pub auto_mark_fetched_posts_as_read: Option<bool>,\n  pub hide_media: Option<bool>,\n  pub default_post_time_range_seconds: Option<Option<i32>>,\n  pub show_score: Option<bool>,\n  pub show_upvotes: Option<bool>,\n  pub show_downvotes: Option<VoteShow>,\n  pub show_upvote_percentage: Option<bool>,\n  pub show_person_votes: Option<bool>,\n  pub default_items_per_page: Option<i32>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/login_token.rs",
    "content": "use crate::newtypes::LocalUserId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::login_token;\nuse lemmy_diesel_utils::sensitive::SensitiveString;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n/// Stores data related to a specific user login session.\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = login_token))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(token)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct LoginToken {\n  /// Jwt token for this login\n  #[serde(skip)]\n  pub token: SensitiveString,\n  pub user_id: LocalUserId,\n  /// Time of login\n  pub published_at: DateTime<Utc>,\n  /// IP address where login was made from, allows invalidating logins by IP address.\n  /// Could be stored in truncated format, or store derived information for better privacy.\n  pub ip: Option<String>,\n  pub user_agent: Option<String>,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = login_token))]\npub struct LoginTokenCreateForm {\n  pub token: SensitiveString,\n  pub user_id: LocalUserId,\n  pub ip: Option<String>,\n  pub user_agent: Option<String>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/mod.rs",
    "content": "use lemmy_diesel_utils::dburl::DbUrl;\nuse url::Url;\n\n#[cfg(feature = \"full\")]\npub mod activity;\npub mod actor_language;\npub mod combined;\npub mod comment;\npub mod comment_report;\npub mod community;\n#[cfg(feature = \"full\")]\npub mod community_community_follow;\npub mod community_report;\npub mod community_tag;\npub mod custom_emoji;\npub mod custom_emoji_keyword;\npub mod email_verification;\npub mod federation_allowlist;\npub mod federation_blocklist;\npub mod federation_queue_state;\npub mod images;\npub mod instance;\npub mod keyword_block;\npub mod language;\npub mod local_site;\npub mod local_site_rate_limit;\npub mod local_site_url_blocklist;\npub mod local_user;\npub mod login_token;\npub mod modlog;\npub mod multi_community;\npub mod notification;\npub mod oauth_account;\npub mod oauth_provider;\npub mod password_reset_request;\npub mod person;\npub mod post;\npub mod post_report;\npub mod private_message;\npub mod private_message_report;\npub mod registration_application;\npub mod secret;\npub mod site;\npub mod tagline;\n\n/// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip).\n///\n/// This is necessary so they can be successfully deserialized from API responses, even though the\n/// value is not sent by Lemmy. Necessary for crates which rely on Rust API such as\n/// lemmy-stats-crawler.\n#[expect(clippy::expect_used)]\nfn placeholder_apub_url() -> DbUrl {\n  DbUrl(Box::new(\n    Url::parse(\"http://example.com\").expect(\"parse placeholder url\"),\n  ))\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/modlog.rs",
    "content": "use crate::newtypes::{CommentId, CommunityId, ModlogId, PostId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::modlog;\nuse lemmy_db_schema_file::{InstanceId, PersonId, enums::ModlogKind};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = modlog))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = modlog_keys))]\npub struct Modlog {\n  pub id: ModlogId,\n  pub kind: ModlogKind,\n  pub is_revert: bool,\n  #[serde(skip)]\n  pub mod_id: PersonId,\n  pub reason: Option<String>,\n  #[serde(skip)]\n  pub target_person_id: Option<PersonId>,\n  #[serde(skip)]\n  pub target_community_id: Option<CommunityId>,\n  #[serde(skip)]\n  pub target_post_id: Option<PostId>,\n  #[serde(skip)]\n  pub target_comment_id: Option<CommentId>,\n  #[serde(skip)]\n  pub target_instance_id: Option<InstanceId>,\n  pub expires_at: Option<DateTime<Utc>>,\n  pub published_at: DateTime<Utc>,\n  pub bulk_action_parent_id: Option<ModlogId>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = modlog))]\npub struct ModlogInsertForm<'a> {\n  pub(crate) kind: ModlogKind,\n  pub(crate) is_revert: bool,\n  #[new(default)]\n  pub bulk_action_parent_id: Option<ModlogId>,\n  pub(crate) mod_id: PersonId,\n  #[new(default)]\n  pub(crate) reason: Option<&'a str>,\n  #[new(default)]\n  pub(crate) target_person_id: Option<PersonId>,\n  #[new(default)]\n  pub(crate) target_community_id: Option<CommunityId>,\n  #[new(default)]\n  pub(crate) target_post_id: Option<PostId>,\n  #[new(default)]\n  pub(crate) target_comment_id: Option<CommentId>,\n  #[new(default)]\n  pub(crate) target_instance_id: Option<InstanceId>,\n  #[new(default)]\n  pub(crate) expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/multi_community.rs",
    "content": "use crate::{\n  newtypes::{CommunityId, MultiCommunityId},\n  source::placeholder_apub_url,\n};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::{\n  multi_community,\n  multi_community_entry,\n  multi_community_follow,\n};\nuse lemmy_db_schema_file::{InstanceId, PersonId, enums::CommunityFollowerState};\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = multi_community_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunity {\n  pub id: MultiCommunityId,\n  pub creator_id: PersonId,\n  pub instance_id: InstanceId,\n  pub name: String,\n  pub title: Option<String>,\n  /// A shorter, one-line summary.\n  pub summary: Option<String>,\n  pub local: bool,\n  pub deleted: bool,\n  pub ap_id: DbUrl,\n  #[serde(skip)]\n  pub public_key: String,\n  #[serde(skip)]\n  pub private_key: Option<SensitiveString>,\n  #[serde(skip, default = \"placeholder_apub_url\")]\n  pub inbox_url: DbUrl,\n  pub last_refreshed_at: DateTime<Utc>,\n  #[serde(skip, default = \"placeholder_apub_url\")]\n  pub following_url: DbUrl,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub subscribers: i32,\n  pub subscribers_local: i32,\n  pub communities: i32,\n  /// A sidebar in markdown.\n  pub sidebar: Option<String>,\n}\n\n#[derive(Debug, Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community))]\npub struct MultiCommunityInsertForm {\n  pub creator_id: PersonId,\n  pub instance_id: InstanceId,\n  pub name: String,\n  pub public_key: String,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub local: Option<bool>,\n  #[new(default)]\n  pub title: Option<String>,\n  #[new(default)]\n  pub summary: Option<String>,\n  #[new(default)]\n  pub sidebar: Option<String>,\n  #[new(default)]\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub private_key: Option<SensitiveString>,\n  #[new(default)]\n  pub inbox_url: Option<DbUrl>,\n  #[new(default)]\n  pub following_url: Option<DbUrl>,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community))]\npub struct MultiCommunityUpdateForm {\n  pub title: Option<Option<String>>,\n  pub summary: Option<Option<String>>,\n  pub sidebar: Option<Option<String>>,\n  pub deleted: Option<bool>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community_follow))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunityFollow {\n  pub multi_community_id: MultiCommunityId,\n  pub person_id: PersonId,\n  pub follow_state: CommunityFollowerState,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community_follow))]\npub struct MultiCommunityFollowForm {\n  pub multi_community_id: MultiCommunityId,\n  pub person_id: PersonId,\n  pub follow_state: CommunityFollowerState,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community_entry))]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(primary_key(multi_community_id, community_id))\n)]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunityEntry {\n  pub multi_community_id: MultiCommunityId,\n  pub community_id: CommunityId,\n}\n\n#[derive(Debug, Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = multi_community_entry))]\npub struct MultiCommunityEntryForm {\n  pub multi_community_id: MultiCommunityId,\n  pub community_id: CommunityId,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/notification.rs",
    "content": "use crate::{\n  newtypes::{CommentId, ModlogId, NotificationId, PostId, PrivateMessageId},\n  source::{comment::Comment, post::Post, private_message::PrivateMessage},\n};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::notification;\nuse lemmy_db_schema_file::{PersonId, enums::NotificationType};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = notification))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = notification_keys))]\npub struct Notification {\n  pub id: NotificationId,\n  pub recipient_id: PersonId,\n  pub comment_id: Option<CommentId>,\n  pub read: bool,\n  pub published_at: DateTime<Utc>,\n  pub kind: NotificationType,\n  pub post_id: Option<PostId>,\n  pub private_message_id: Option<PrivateMessageId>,\n  pub modlog_id: Option<ModlogId>,\n  pub creator_id: PersonId,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = notification))]\npub struct NotificationInsertForm {\n  pub recipient_id: PersonId,\n  pub creator_id: PersonId,\n  pub kind: NotificationType,\n  #[new(default)]\n  pub comment_id: Option<CommentId>,\n  #[new(default)]\n  pub post_id: Option<PostId>,\n  #[new(default)]\n  pub private_message_id: Option<PrivateMessageId>,\n  #[new(default)]\n  pub modlog_id: Option<ModlogId>,\n}\n\nimpl NotificationInsertForm {\n  pub fn new_post(post: &Post, recipient_id: PersonId, kind: NotificationType) -> Self {\n    Self {\n      post_id: Some(post.id),\n      ..Self::new(recipient_id, post.creator_id, kind)\n    }\n  }\n  pub fn new_comment(comment: &Comment, recipient_id: PersonId, kind: NotificationType) -> Self {\n    Self {\n      comment_id: Some(comment.id),\n      ..Self::new(recipient_id, comment.creator_id, kind)\n    }\n  }\n  pub fn new_private_message(private_message: &PrivateMessage) -> Self {\n    Self {\n      private_message_id: Some(private_message.id),\n      ..Self::new(\n        private_message.recipient_id,\n        private_message.creator_id,\n        NotificationType::PrivateMessage,\n      )\n    }\n  }\n  pub fn new_mod_action(modlog_id: ModlogId, recipient_id: PersonId, creator_id: PersonId) -> Self {\n    Self {\n      modlog_id: Some(modlog_id),\n      ..Self::new(recipient_id, creator_id, NotificationType::ModAction)\n    }\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/oauth_account.rs",
    "content": "use crate::newtypes::{LocalUserId, OAuthProviderId};\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::oauth_account;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = oauth_account))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// An auth account method.\npub struct OAuthAccount {\n  pub local_user_id: LocalUserId,\n  pub oauth_provider_id: OAuthProviderId,\n  pub oauth_user_id: String,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = oauth_account))]\npub struct OAuthAccountInsertForm {\n  pub local_user_id: LocalUserId,\n  pub oauth_provider_id: OAuthProviderId,\n  pub oauth_user_id: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/oauth_provider.rs",
    "content": "use crate::newtypes::OAuthProviderId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::oauth_provider;\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = oauth_provider))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// oauth provider with client_secret - should never be sent to the client\npub struct AdminOAuthProvider {\n  pub id: OAuthProviderId,\n  /// The OAuth 2.0 provider name displayed to the user on the Login page\n  pub display_name: String,\n  /// The issuer url of the OAUTH provider.\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  pub issuer: DbUrl,\n  /// The authorization endpoint is used to interact with the resource owner and obtain an\n  /// authorization grant. This is usually provided by the OAUTH provider.\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  pub authorization_endpoint: DbUrl,\n  /// The token endpoint is used by the client to obtain an access token by presenting its\n  /// authorization grant or refresh token. This is usually provided by the OAUTH provider.\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  pub token_endpoint: DbUrl,\n  /// The UserInfo Endpoint is an OAuth 2.0 Protected Resource that returns Claims about the\n  /// authenticated End-User. This is defined in the OIDC specification.\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  pub userinfo_endpoint: DbUrl,\n  /// The OAuth 2.0 claim containing the unique user ID returned by the provider. Usually this\n  /// should be set to \"sub\".\n  pub id_claim: String,\n  /// The client_id is provided by the OAuth 2.0 provider and is a unique identifier to this\n  /// service\n  pub client_id: String,\n  /// The client_secret is provided by the OAuth 2.0 provider and is used to authenticate this\n  /// service with the provider\n  #[serde(skip)]\n  pub client_secret: SensitiveString,\n  /// Lists the scopes requested from users. Users will have to grant access to the requested scope\n  /// at sign up.\n  pub scopes: String,\n  /// Automatically sets email as verified on registration\n  pub auto_verify_email: bool,\n  /// Allows linking an OAUTH account to an existing user account by matching emails\n  pub account_linking_enabled: bool,\n  /// switch to enable or disable an oauth provider\n  pub enabled: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// switch to enable or disable PKCE\n  pub use_pkce: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n// A subset of OAuthProvider used for public requests, for example to display the OAUTH buttons on\n// the login page\npub struct PublicOAuthProvider {\n  pub id: OAuthProviderId,\n  /// The OAuth 2.0 provider name displayed to the user on the Login page\n  pub display_name: String,\n  /// The authorization endpoint is used to interact with the resource owner and obtain an\n  /// authorization grant. This is usually provided by the OAUTH provider.\n  #[cfg_attr(feature = \"ts-rs\", ts(type = \"string\"))]\n  pub authorization_endpoint: DbUrl,\n  /// The client_id is provided by the OAuth 2.0 provider and is a unique identifier to this\n  /// service\n  pub client_id: String,\n  /// Lists the scopes requested from users. Users will have to grant access to the requested scope\n  /// at sign up.\n  pub scopes: String,\n  /// switch to enable or disable PKCE\n  pub use_pkce: bool,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = oauth_provider))]\npub struct OAuthProviderInsertForm {\n  pub display_name: String,\n  pub issuer: DbUrl,\n  pub authorization_endpoint: DbUrl,\n  pub token_endpoint: DbUrl,\n  pub userinfo_endpoint: DbUrl,\n  pub id_claim: String,\n  pub client_id: String,\n  pub client_secret: String,\n  pub scopes: String,\n  pub auto_verify_email: Option<bool>,\n  pub account_linking_enabled: Option<bool>,\n  pub use_pkce: Option<bool>,\n  pub enabled: Option<bool>,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = oauth_provider))]\npub struct OAuthProviderUpdateForm {\n  pub display_name: Option<String>,\n  pub authorization_endpoint: Option<DbUrl>,\n  pub token_endpoint: Option<DbUrl>,\n  pub userinfo_endpoint: Option<DbUrl>,\n  pub id_claim: Option<String>,\n  pub client_secret: Option<String>,\n  pub scopes: Option<String>,\n  pub auto_verify_email: Option<bool>,\n  pub account_linking_enabled: Option<bool>,\n  pub use_pkce: Option<bool>,\n  pub enabled: Option<bool>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/password_reset_request.rs",
    "content": "use crate::newtypes::LocalUserId;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::password_reset_request;\nuse lemmy_diesel_utils::sensitive::SensitiveString;\n\n#[derive(PartialEq, Eq, Debug)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = password_reset_request))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct PasswordResetRequest {\n  pub id: i32,\n  pub token: SensitiveString,\n  pub published_at: DateTime<Utc>,\n  pub local_user_id: LocalUserId,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = password_reset_request))]\npub struct PasswordResetRequestForm {\n  pub local_user_id: LocalUserId,\n  pub token: SensitiveString,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/person.rs",
    "content": "use crate::source::placeholder_apub_url;\nuse chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse i_love_jesus::CursorKeysModule;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::{person, person_actions};\nuse lemmy_db_schema_file::{InstanceId, PersonId};\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = person))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = person_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A person.\npub struct Person {\n  pub id: PersonId,\n  pub name: String,\n  /// A shorter display name.\n  pub display_name: Option<String>,\n  /// A URL for an avatar.\n  pub avatar: Option<DbUrl>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// The federated ap_id.\n  pub ap_id: DbUrl,\n  /// An optional bio, in markdown.\n  pub bio: Option<String>,\n  /// Whether the person is local to our site.\n  pub local: bool,\n  #[serde(skip)]\n  pub private_key: Option<SensitiveString>,\n  #[serde(skip)]\n  pub public_key: String,\n  pub last_refreshed_at: DateTime<Utc>,\n  /// A URL for a banner.\n  pub banner: Option<DbUrl>,\n  /// Whether the person is deleted.\n  pub deleted: bool,\n  #[cfg_attr(feature = \"ts-rs\", ts(skip))]\n  #[serde(skip, default = \"placeholder_apub_url\")]\n  pub inbox_url: DbUrl,\n  /// A matrix id, usually given an @person:matrix.org\n  pub matrix_user_id: Option<String>,\n  /// Whether the person is a bot account.\n  pub bot_account: bool,\n  pub instance_id: InstanceId,\n  pub post_count: i32,\n  #[serde(skip)]\n  pub post_score: i32,\n  pub comment_count: i32,\n  #[serde(skip)]\n  pub comment_score: i32,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person))]\npub struct PersonInsertForm {\n  pub name: String,\n  pub public_key: String,\n  pub instance_id: InstanceId,\n  #[new(default)]\n  pub display_name: Option<String>,\n  #[new(default)]\n  pub avatar: Option<DbUrl>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub bio: Option<String>,\n  #[new(default)]\n  pub local: Option<bool>,\n  #[new(default)]\n  pub private_key: Option<String>,\n  #[new(default)]\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub banner: Option<DbUrl>,\n  #[new(default)]\n  pub deleted: Option<bool>,\n  #[new(default)]\n  pub inbox_url: Option<DbUrl>,\n  #[new(default)]\n  pub matrix_user_id: Option<String>,\n  #[new(default)]\n  pub bot_account: Option<bool>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person))]\npub struct PersonUpdateForm {\n  pub display_name: Option<Option<String>>,\n  pub avatar: Option<Option<DbUrl>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub ap_id: Option<DbUrl>,\n  pub bio: Option<Option<String>>,\n  pub local: Option<bool>,\n  pub public_key: Option<String>,\n  pub private_key: Option<Option<String>>,\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  pub banner: Option<Option<DbUrl>>,\n  pub deleted: Option<bool>,\n  pub inbox_url: Option<DbUrl>,\n  pub matrix_user_id: Option<Option<String>>,\n  pub bot_account: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, Associations)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::person::Person)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_actions))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(person_id, target_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PersonActions {\n  #[serde(skip)]\n  pub followed_at: Option<DateTime<Utc>>,\n  /// When the person was blocked.\n  pub blocked_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub target_id: PersonId,\n  #[serde(skip)]\n  pub follow_pending: Option<bool>,\n  /// When the person was noted.\n  pub noted_at: Option<DateTime<Utc>>,\n  /// A note about the person.\n  pub note: Option<String>,\n  /// When the person was voted on.\n  pub voted_at: Option<DateTime<Utc>>,\n  /// A total of upvotes given to this person\n  pub upvotes: Option<i32>,\n  /// A total of downvotes given to this person\n  pub downvotes: Option<i32>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_actions))]\npub struct PersonFollowerForm {\n  pub target_id: PersonId,\n  pub person_id: PersonId,\n  pub follow_pending: bool,\n  #[new(value = \"Utc::now()\")]\n  pub followed_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_actions))]\npub struct PersonBlockForm {\n  pub person_id: PersonId,\n  pub target_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub blocked_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = person_actions))]\npub struct PersonNoteForm {\n  pub person_id: PersonId,\n  pub target_id: PersonId,\n  pub note: String,\n  #[new(value = \"Utc::now()\")]\n  pub noted_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/post.rs",
    "content": "use crate::newtypes::{CommunityId, LanguageId, PostId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::{PersonId, enums::PostNotificationsMode};\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  i_love_jesus::CursorKeysModule,\n  lemmy_db_schema_file::schema::{post, post_actions},\n};\n\n#[skip_serializing_none]\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = post))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = post_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A post.\npub struct Post {\n  pub id: PostId,\n  pub name: String,\n  /// An optional link / url for the post.\n  pub url: Option<DbUrl>,\n  /// An optional post body, in markdown.\n  pub body: Option<String>,\n  pub creator_id: PersonId,\n  pub community_id: CommunityId,\n  /// Whether the post is removed.\n  pub removed: bool,\n  /// Whether the post is locked.\n  pub locked: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// Whether the post is deleted.\n  pub deleted: bool,\n  /// Whether the post is NSFW.\n  pub nsfw: bool,\n  /// A title for the link.\n  pub embed_title: Option<String>,\n  /// A description for the link.\n  pub embed_description: Option<String>,\n  /// A thumbnail picture url.\n  pub thumbnail_url: Option<DbUrl>,\n  /// The federated activity id / ap_id.\n  pub ap_id: DbUrl,\n  /// Whether the post is local.\n  pub local: bool,\n  /// A video url for the link.\n  pub embed_video_url: Option<DbUrl>,\n  pub language_id: LanguageId,\n  /// Whether the post is featured to its community.\n  pub featured_community: bool,\n  /// Whether the post is featured to its site.\n  pub featured_local: bool,\n  pub url_content_type: Option<String>,\n  /// An optional alt_text, usable for image posts.\n  pub alt_text: Option<String>,\n  /// Time at which the post will be published. None means publish immediately.\n  pub scheduled_publish_time_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  /// A newest comment time, limited to 2 days, to prevent necrobumping\n  pub newest_comment_time_necro_at: Option<DateTime<Utc>>,\n  /// The time of the newest comment in the post, if the post has any comments.\n  pub newest_comment_time_at: Option<DateTime<Utc>>,\n  pub comments: i32,\n  pub score: i32,\n  pub upvotes: i32,\n  pub downvotes: i32,\n  #[serde(skip)]\n  pub hot_rank: f32,\n  #[serde(skip)]\n  pub hot_rank_active: f32,\n  #[serde(skip)]\n  pub controversy_rank: f32,\n  /// A rank that amplifies smaller communities\n  #[serde(skip)]\n  pub scaled_rank: f32,\n  pub report_count: i16,\n  pub unresolved_report_count: i16,\n  /// If a local user posts in a remote community, the comment is hidden until it is confirmed\n  /// accepted by the community (by receiving it back via federation).\n  pub federation_pending: bool,\n  pub embed_video_width: Option<i32>,\n  pub embed_video_height: Option<i32>,\n}\n\n// TODO: FromBytes, ToBytes are only needed to develop wasm plugin, could be behind feature flag\n#[derive(Debug, Clone, derive_new::new, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset,))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post))]\npub struct PostInsertForm {\n  pub name: String,\n  pub creator_id: PersonId,\n  pub community_id: CommunityId,\n  #[new(default)]\n  pub nsfw: Option<bool>,\n  #[new(default)]\n  pub url: Option<DbUrl>,\n  #[new(default)]\n  pub body: Option<String>,\n  #[new(default)]\n  pub removed: Option<bool>,\n  #[new(default)]\n  pub locked: Option<bool>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub deleted: Option<bool>,\n  #[new(default)]\n  pub embed_title: Option<String>,\n  #[new(default)]\n  pub embed_description: Option<String>,\n  #[new(default)]\n  pub embed_video_url: Option<DbUrl>,\n  #[new(default)]\n  pub embed_video_width: Option<i32>,\n  #[new(default)]\n  pub embed_video_height: Option<i32>,\n  #[new(default)]\n  pub thumbnail_url: Option<DbUrl>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub local: Option<bool>,\n  #[new(default)]\n  pub language_id: Option<LanguageId>,\n  #[new(default)]\n  pub featured_community: Option<bool>,\n  #[new(default)]\n  pub featured_local: Option<bool>,\n  #[new(default)]\n  pub url_content_type: Option<String>,\n  #[new(default)]\n  pub alt_text: Option<String>,\n  #[new(default)]\n  pub scheduled_publish_time_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub federation_pending: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset, Serialize, Deserialize))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post))]\npub struct PostUpdateForm {\n  pub name: Option<String>,\n  pub nsfw: Option<bool>,\n  pub url: Option<Option<DbUrl>>,\n  pub body: Option<Option<String>>,\n  pub removed: Option<bool>,\n  pub locked: Option<bool>,\n  pub published_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub deleted: Option<bool>,\n  pub embed_title: Option<Option<String>>,\n  pub embed_description: Option<Option<String>>,\n  pub embed_video_url: Option<Option<DbUrl>>,\n  pub embed_video_width: Option<Option<i32>>,\n  pub embed_video_height: Option<Option<i32>>,\n  pub thumbnail_url: Option<Option<DbUrl>>,\n  pub ap_id: Option<DbUrl>,\n  pub local: Option<bool>,\n  pub language_id: Option<LanguageId>,\n  pub featured_community: Option<bool>,\n  pub featured_local: Option<bool>,\n  pub url_content_type: Option<Option<String>>,\n  pub alt_text: Option<Option<String>>,\n  pub scheduled_publish_time_at: Option<Option<DateTime<Utc>>>,\n  pub federation_pending: Option<bool>,\n}\n\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::post::Post)))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\n#[cfg_attr(feature = \"full\", diesel(primary_key(person_id, post_id)))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = post_actions_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PostActions {\n  /// When the post was read.\n  pub read_at: Option<DateTime<Utc>>,\n  /// When was the last time you read the comments.\n  pub read_comments_at: Option<DateTime<Utc>>,\n  /// When the post was saved.\n  pub saved_at: Option<DateTime<Utc>>,\n  /// When the post was upvoted or downvoted.\n  pub voted_at: Option<DateTime<Utc>>,\n  /// When the post was hidden.\n  pub hidden_at: Option<DateTime<Utc>>,\n  #[serde(skip)]\n  pub person_id: PersonId,\n  #[serde(skip)]\n  pub post_id: PostId,\n  /// The number of comments you read last. Subtract this from total comments to get an unread\n  /// count.\n  pub read_comments_amount: Option<i32>,\n  /// True if upvoted, false if downvoted. Upvote is greater than downvote.\n  pub vote_is_upvote: Option<bool>,\n  pub notifications: Option<PostNotificationsMode>,\n}\n\n#[derive(Clone, Serialize, Deserialize, Debug)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\npub struct PostLikeForm {\n  pub post_id: PostId,\n  pub person_id: PersonId,\n  pub vote_is_upvote: Option<Option<bool>>,\n  pub voted_at: Option<Option<DateTime<Utc>>>,\n}\n\nimpl PostLikeForm {\n  /// Pass `is_upvote: None` to remove an existing vote for this post\n  pub fn new(post_id: PostId, person_id: PersonId, is_upvote: Option<bool>) -> Self {\n    let voted_at = if is_upvote.is_some() {\n      Some(Some(Utc::now()))\n    } else {\n      Some(None)\n    };\n\n    Self {\n      post_id,\n      person_id,\n      vote_is_upvote: Some(is_upvote),\n      voted_at,\n    }\n  }\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\npub struct PostSavedForm {\n  pub post_id: PostId,\n  pub person_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub saved_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new, Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\npub(crate) struct PostReadForm {\n  pub post_id: PostId,\n  pub person_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub read_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\npub struct PostReadCommentsForm {\n  pub post_id: PostId,\n  pub person_id: PersonId,\n  pub read_comments_amount: i32,\n  #[new(value = \"Utc::now()\")]\n  pub read_comments_at: DateTime<Utc>,\n}\n\n#[derive(derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_actions))]\npub struct PostHideForm {\n  pub post_id: PostId,\n  pub person_id: PersonId,\n  #[new(value = \"Utc::now()\")]\n  pub hidden_at: DateTime<Utc>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/post_report.rs",
    "content": "use crate::newtypes::{PostId, PostReportId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::post_report;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Identifiable, Queryable, Selectable, Associations)\n)]\n#[cfg_attr(feature = \"full\", diesel(belongs_to(crate::source::post::Post)))] // Is this the right assoc?\n#[cfg_attr(feature = \"full\", diesel(table_name = post_report))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A post report.\npub struct PostReport {\n  pub id: PostReportId,\n  pub creator_id: PersonId,\n  pub post_id: PostId,\n  /// The original post title.\n  pub original_post_name: String,\n  /// The original post url.\n  pub original_post_url: Option<DbUrl>,\n  /// The original post body.\n  pub original_post_body: Option<String>,\n  pub reason: String,\n  pub resolved: bool,\n  pub resolver_id: Option<PersonId>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub violates_instance_rules: bool,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = post_report))]\npub struct PostReportForm {\n  pub creator_id: PersonId,\n  pub post_id: PostId,\n  pub original_post_name: String,\n  pub original_post_url: Option<DbUrl>,\n  pub original_post_body: Option<String>,\n  pub reason: String,\n  pub violates_instance_rules: bool,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/private_message.rs",
    "content": "use crate::newtypes::PrivateMessageId;\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::private_message;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::person::Person, foreign_key = creator_id)\n))] // Is this the right assoc?\n#[cfg_attr(feature = \"full\", diesel(table_name = private_message))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A private message.\npub struct PrivateMessage {\n  pub id: PrivateMessageId,\n  pub creator_id: PersonId,\n  pub recipient_id: PersonId,\n  pub content: String,\n  pub deleted: bool,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub ap_id: DbUrl,\n  pub local: bool,\n  pub removed: bool,\n  pub deleted_by_recipient: bool,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Insertable, AsChangeset, Serialize, Deserialize)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = private_message))]\npub struct PrivateMessageInsertForm {\n  pub creator_id: PersonId,\n  pub recipient_id: PersonId,\n  pub content: String,\n  #[new(default)]\n  pub deleted: Option<bool>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub local: Option<bool>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset, Serialize, Deserialize))]\n#[cfg_attr(feature = \"full\", diesel(table_name = private_message))]\npub struct PrivateMessageUpdateForm {\n  pub content: Option<String>,\n  pub deleted: Option<bool>,\n  pub published_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  pub ap_id: Option<DbUrl>,\n  pub local: Option<bool>,\n  pub removed: Option<bool>,\n  pub deleted_by_recipient: Option<bool>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/private_message_report.rs",
    "content": "use crate::newtypes::{PrivateMessageId, PrivateMessageReportId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::private_message_report;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Associations, Identifiable)\n)]\n#[cfg_attr(\n  feature = \"full\",\n  diesel(belongs_to(crate::source::private_message::PrivateMessage))\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = private_message_report))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The private message report.\npub struct PrivateMessageReport {\n  pub id: PrivateMessageReportId,\n  pub creator_id: PersonId,\n  pub private_message_id: PrivateMessageId,\n  /// The original text.\n  pub original_pm_text: String,\n  pub reason: String,\n  pub resolved: bool,\n  pub resolver_id: Option<PersonId>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = private_message_report))]\npub struct PrivateMessageReportForm {\n  pub creator_id: PersonId,\n  pub private_message_id: PrivateMessageId,\n  pub original_pm_text: String,\n  pub reason: String,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/registration_application.rs",
    "content": "use crate::newtypes::{LocalUserId, RegistrationApplicationId};\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::PersonId;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::registration_application};\n\n#[skip_serializing_none]\n#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = registration_application))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = registration_application_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A registration application.\npub struct RegistrationApplication {\n  pub id: RegistrationApplicationId,\n  pub local_user_id: LocalUserId,\n  pub answer: String,\n  pub admin_id: Option<PersonId>,\n  pub deny_reason: Option<String>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[cfg_attr(feature = \"full\", derive(Insertable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = registration_application))]\npub struct RegistrationApplicationInsertForm {\n  pub local_user_id: LocalUserId,\n  pub answer: String,\n}\n\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = registration_application))]\npub struct RegistrationApplicationUpdateForm {\n  pub admin_id: Option<Option<PersonId>>,\n  pub deny_reason: Option<Option<String>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/secret.rs",
    "content": "#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::secret;\nuse lemmy_diesel_utils::sensitive::SensitiveString;\n\n#[derive(Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = secret))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\npub struct Secret {\n  pub id: i32,\n  pub jwt_secret: SensitiveString,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/site.rs",
    "content": "use crate::newtypes::SiteId;\nuse chrono::{DateTime, Utc};\nuse lemmy_db_schema_file::InstanceId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema_file::schema::site;\nuse lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable, Identifiable))]\n#[cfg_attr(feature = \"full\", diesel(table_name = site))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Additional data for federated instances. This may be missing for other platforms which are not\n/// fully compatible. Basic data is guaranteed to be available via [[Instance]].\npub struct Site {\n  pub id: SiteId,\n  pub name: String,\n  /// A sidebar for the site in markdown.\n  pub sidebar: Option<String>,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n  /// An icon URL.\n  pub icon: Option<DbUrl>,\n  /// A banner url.\n  pub banner: Option<DbUrl>,\n  /// A shorter, one-line summary of the site.\n  pub summary: Option<String>,\n  /// The federated ap_id.\n  pub ap_id: DbUrl,\n  /// The time the site was last refreshed.\n  pub last_refreshed_at: DateTime<Utc>,\n  /// The site inbox\n  pub inbox_url: DbUrl,\n  #[serde(skip)]\n  pub private_key: Option<SensitiveString>,\n  #[serde(skip)]\n  pub public_key: String,\n  pub instance_id: InstanceId,\n  /// If present, nsfw content is visible by default. Should be displayed by frontends/clients\n  /// when the site is first opened by a user.\n  pub content_warning: Option<String>,\n}\n\n#[derive(Clone, derive_new::new)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = site))]\npub struct SiteInsertForm {\n  pub name: String,\n  pub instance_id: InstanceId,\n  #[new(default)]\n  pub sidebar: Option<String>,\n  #[new(default)]\n  pub published_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub updated_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub icon: Option<DbUrl>,\n  #[new(default)]\n  pub banner: Option<DbUrl>,\n  #[new(default)]\n  pub summary: Option<String>,\n  #[new(default)]\n  pub ap_id: Option<DbUrl>,\n  #[new(default)]\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  #[new(default)]\n  pub inbox_url: Option<DbUrl>,\n  #[new(default)]\n  pub private_key: Option<String>,\n  #[new(default)]\n  pub public_key: Option<String>,\n  #[new(default)]\n  pub content_warning: Option<String>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = site))]\npub struct SiteUpdateForm {\n  pub name: Option<String>,\n  pub sidebar: Option<Option<String>>,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n  // when you want to null out a column, you have to send Some(None)), since sending None means you\n  // just don't want to update that column.\n  pub icon: Option<Option<DbUrl>>,\n  pub banner: Option<Option<DbUrl>>,\n  pub summary: Option<Option<String>>,\n  pub ap_id: Option<DbUrl>,\n  pub last_refreshed_at: Option<DateTime<Utc>>,\n  pub inbox_url: Option<DbUrl>,\n  pub private_key: Option<Option<String>>,\n  pub public_key: Option<String>,\n  pub content_warning: Option<Option<String>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/source/tagline.rs",
    "content": "use crate::newtypes::TaglineId;\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::tagline};\n\n#[skip_serializing_none]\n#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"full\",\n  derive(Queryable, Selectable, Identifiable, CursorKeysModule)\n)]\n#[cfg_attr(feature = \"full\", diesel(table_name = tagline))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"full\", cursor_keys_module(name = tagline_keys))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A tagline, shown at the top of your site.\npub struct Tagline {\n  pub id: TaglineId,\n  pub content: String,\n  pub published_at: DateTime<Utc>,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = tagline))]\npub struct TaglineInsertForm {\n  pub content: String,\n}\n\n#[derive(Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(Insertable, AsChangeset))]\n#[cfg_attr(feature = \"full\", diesel(table_name = tagline))]\npub struct TaglineUpdateForm {\n  pub content: String,\n  pub updated_at: Option<Option<DateTime<Utc>>>,\n}\n"
  },
  {
    "path": "crates/db_schema/src/test_data.rs",
    "content": "use crate::source::{\n  instance::Instance,\n  local_site::{LocalSite, LocalSiteInsertForm},\n  local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitInsertForm},\n  person::{Person, PersonInsertForm},\n  site::{Site, SiteInsertForm},\n};\nuse lemmy_diesel_utils::{connection::DbPool, traits::Crud};\nuse lemmy_utils::error::LemmyResult;\n\npub struct TestData {\n  pub instance: Instance,\n  pub site: Site,\n  pub local_site: LocalSite,\n  pub person: Person,\n}\n\nimpl TestData {\n  pub async fn create(pool: &mut DbPool<'_>) -> LemmyResult<Self> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let site_form = SiteInsertForm::new(\"test site\".to_string(), instance.id);\n    let site = Site::create(pool, &site_form).await?;\n\n    let person = Person::create(pool, &PersonInsertForm::test_form(instance.id, \"langs\")).await?;\n    let local_site_form = LocalSiteInsertForm {\n      system_account: Some(person.id),\n      ..LocalSiteInsertForm::new(site.id)\n    };\n    let local_site = LocalSite::create(pool, &local_site_form).await?;\n    LocalSiteRateLimit::create(pool, &LocalSiteRateLimitInsertForm::new(local_site.id)).await?;\n\n    let person_form = PersonInsertForm::test_form(instance.id, \"holly\");\n\n    let person = Person::create(pool, &person_form).await?;\n\n    Ok(Self {\n      instance,\n      site,\n      local_site,\n      person,\n    })\n  }\n\n  pub async fn delete(self, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, self.instance.id).await?;\n    Site::delete(pool, self.site.id).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_schema/src/traits.rs",
    "content": "use crate::newtypes::CommunityId;\nuse diesel_uplete::UpleteCount;\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl};\nuse lemmy_utils::{error::LemmyResult, settings::structs::Settings};\nuse std::future::Future;\nuse url::Url;\n\npub trait Followable: Sized {\n  type Form;\n  type IdType;\n  fn follow(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn follow_accepted(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    person_id: PersonId,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn unfollow(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    item_id: Self::IdType,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n}\n\npub trait Likeable: Sized {\n  type Form;\n  type IdType;\n  fn like(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n\n  fn remove_all_likes(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n\n  fn remove_likes_in_community(\n    pool: &mut DbPool<'_>,\n    creator_id: PersonId,\n    community_id: CommunityId,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n}\n\npub trait Bannable: Sized {\n  type Form;\n  fn ban(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn unban(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n}\n\npub trait Saveable: Sized {\n  type Form;\n  fn save(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn unsave(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n}\n\npub trait Blockable: Sized {\n  type Form;\n  type ObjectIdType;\n  type ObjectType;\n  fn block(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn unblock(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<UpleteCount>> + Send;\n  fn read_block(\n    pool: &mut DbPool<'_>,\n    for_person_id: PersonId,\n    for_item_id: Self::ObjectIdType,\n  ) -> impl Future<Output = LemmyResult<()>> + Send;\n\n  fn read_blocks_for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    // Note: cant use lemmyresult because of try_pool\n  ) -> impl Future<Output = LemmyResult<Vec<Self::ObjectType>>> + Send;\n}\n\npub trait Reportable: Sized {\n  type Form;\n  type IdType;\n  type ObjectIdType;\n  fn report(\n    pool: &mut DbPool<'_>,\n    form: &Self::Form,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n  fn update_resolved(\n    pool: &mut DbPool<'_>,\n    report_id: Self::IdType,\n    resolver_id: PersonId,\n    is_resolved: bool,\n  ) -> impl Future<Output = LemmyResult<usize>> + Send;\n  fn resolve_apub(\n    pool: &mut DbPool<'_>,\n    object_id: Self::ObjectIdType,\n    report_creator_id: PersonId,\n    resolver_id: PersonId,\n  ) -> impl Future<Output = LemmyResult<usize>> + Send;\n  fn resolve_all_for_object(\n    pool: &mut DbPool<'_>,\n    comment_id_: Self::ObjectIdType,\n    by_resolver_id: PersonId,\n  ) -> impl Future<Output = LemmyResult<usize>> + Send;\n}\n\npub trait ApubActor: Sized {\n  fn read_from_apub_id(\n    pool: &mut DbPool<'_>,\n    object_id: &DbUrl,\n  ) -> impl Future<Output = LemmyResult<Option<Self>>> + Send;\n  /// - actor_name is the name of the community or user to read.\n  /// - domain if None only local actors are searched, if Some only actors from that domain\n  /// - include_deleted, if true, will return communities or users that were deleted/removed\n  fn read_from_name(\n    pool: &mut DbPool<'_>,\n    actor_name: &str,\n    domain: Option<&str>,\n    include_deleted: bool,\n  ) -> impl Future<Output = LemmyResult<Option<Self>>> + Send;\n\n  fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl>;\n  fn actor_url(&self, settings: &Settings) -> LemmyResult<Url>;\n}\n\npub trait InternalToCombinedView {\n  type CombinedView;\n\n  /// Maps the combined DB row to an enum\n  fn map_to_enum(self) -> Option<Self::CombinedView>;\n}\n"
  },
  {
    "path": "crates/db_schema/src/utils/mod.rs",
    "content": "pub mod queries;\n\nuse chrono::TimeDelta;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse url::Url;\n\nconst FETCH_LIMIT_DEFAULT: i64 = 20;\npub const FETCH_LIMIT_MAX: usize = 50;\npub const SITEMAP_LIMIT: i64 = 50000;\npub const SITEMAP_DAYS: TimeDelta = TimeDelta::days(31);\npub const RANK_DEFAULT: f32 = 0.0001;\npub const DELETED_REPLACEMENT_TEXT: &str = \"*Permanently Deleted*\";\n\npub fn limit_fetch(limit: Option<i64>, no_limit: Option<bool>) -> LemmyResult<i64> {\n  Ok(if no_limit.unwrap_or_default() {\n    i64::MAX\n  } else {\n    match limit {\n      Some(limit) => limit_fetch_check(limit)?,\n      None => FETCH_LIMIT_DEFAULT,\n    }\n  })\n}\n\npub fn limit_fetch_check(limit: i64) -> LemmyResult<i64> {\n  if !(1..=FETCH_LIMIT_MAX.try_into()?).contains(&limit) {\n    Err(LemmyErrorType::InvalidFetchLimit.into())\n  } else {\n    Ok(limit)\n  }\n}\n\npub(crate) fn format_actor_url(\n  name: &str,\n  domain: &str,\n  prefix: char,\n  settings: &Settings,\n) -> LemmyResult<Url> {\n  let local_protocol_and_hostname = settings.get_protocol_and_hostname();\n  let local_hostname = &settings.hostname;\n  let url = if domain != local_hostname {\n    format!(\"{local_protocol_and_hostname}/{prefix}/{name}@{domain}\",)\n  } else {\n    format!(\"{local_protocol_and_hostname}/{prefix}/{name}\")\n  };\n  Ok(Url::parse(&url)?)\n}\n"
  },
  {
    "path": "crates/db_schema/src/utils/queries/filters.rs",
    "content": "use diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  NullableExpressionMethods,\n  QueryDsl,\n  helper_types::{Eq, NotEq},\n};\nuse lemmy_db_schema_file::{\n  aliases::my_instance_persons_actions,\n  enums::{CommunityFollowerState, CommunityVisibility},\n  schema::{\n    community,\n    community_actions,\n    instance_actions,\n    local_site,\n    multi_community,\n    multi_community_entry,\n    person_actions,\n  },\n};\n\n/// Hide all content from blocked communities and persons. Content from blocked instances is also\n/// hidden, unless the user followed the community explicitly.\n#[diesel::dsl::auto_type]\npub fn filter_blocked() -> _ {\n  instance_actions::blocked_communities_at\n    .is_null()\n    .or(community_actions::followed_at.is_not_null())\n    .and(community_actions::blocked_at.is_null())\n    .and(person_actions::blocked_at.is_null())\n    .and(\n      my_instance_persons_actions\n        .field(instance_actions::blocked_persons_at)\n        .is_null(),\n    )\n}\n\ntype IsSubscribedType =\n  Eq<lemmy_db_schema_file::schema::community_actions::follow_state, Option<CommunityFollowerState>>;\n\npub fn filter_is_subscribed() -> IsSubscribedType {\n  community_actions::follow_state.eq(Some(CommunityFollowerState::Accepted))\n}\n\ntype IsNotUnlistedType =\n  NotEq<lemmy_db_schema_file::schema::community::visibility, CommunityVisibility>;\n\n#[diesel::dsl::auto_type]\npub fn filter_not_unlisted_or_is_subscribed() -> _ {\n  let not_unlisted: IsNotUnlistedType = community::visibility.ne(CommunityVisibility::Unlisted);\n  let is_subscribed: IsSubscribedType = filter_is_subscribed();\n  not_unlisted.or(is_subscribed)\n}\n\n#[diesel::dsl::auto_type]\npub fn filter_suggested_communities() -> _ {\n  community::id.eq_any(\n    local_site::table\n      .left_join(multi_community::table.inner_join(multi_community_entry::table))\n      .filter(multi_community_entry::community_id.is_not_null())\n      .select(multi_community_entry::community_id.assume_not_null()),\n  )\n}\n"
  },
  {
    "path": "crates/db_schema/src/utils/queries/mod.rs",
    "content": "pub mod filters;\npub mod selects;\n"
  },
  {
    "path": "crates/db_schema/src/utils/queries/selects.rs",
    "content": "use crate::{Person1AliasAllColumnsTuple, Person2AliasAllColumnsTuple};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  NullableExpressionMethods,\n  PgExpressionMethods,\n  QueryDsl,\n  dsl::{case_when, exists, not},\n  expression::SqlLiteral,\n  helper_types::Nullable,\n  query_source::AliasedField,\n  sql_types::{Json, Timestamptz},\n};\nuse lemmy_db_schema_file::{\n  aliases::{\n    CreatorCommunityInstanceActions,\n    CreatorHomeInstanceActions,\n    CreatorLocalInstanceActions,\n    creator_community_actions,\n    creator_community_instance_actions,\n    creator_home_instance_actions,\n    creator_local_instance_actions,\n    creator_local_user,\n    person1,\n    person2,\n  },\n  schema::{\n    comment,\n    community,\n    community_actions,\n    community_tag,\n    instance_actions,\n    local_user,\n    person,\n    post,\n    post_community_tag,\n  },\n};\nuse lemmy_diesel_utils::utils::functions::{coalesce_2_nullable, coalesce_3_nullable};\n\n/// Checks that the creator_local_user is an admin.\n#[diesel::dsl::auto_type]\npub fn creator_is_admin() -> _ {\n  creator_local_user\n    .field(local_user::admin)\n    .nullable()\n    .is_not_distinct_from(true)\n}\n\n/// Checks that the local_user is an admin.\n#[diesel::dsl::auto_type]\npub fn local_user_is_admin() -> _ {\n  local_user::admin.nullable().is_not_distinct_from(true)\n}\n\n/// Checks to see if the comment creator is an admin.\n#[diesel::dsl::auto_type]\npub fn comment_creator_is_admin() -> _ {\n  exists(\n    creator_local_user.filter(\n      comment::creator_id\n        .eq(creator_local_user.field(local_user::person_id))\n        .and(creator_local_user.field(local_user::admin).eq(true)),\n    ),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn post_creator_is_admin() -> _ {\n  exists(\n    creator_local_user.filter(\n      post::creator_id\n        .eq(creator_local_user.field(local_user::person_id))\n        .and(creator_local_user.field(local_user::admin).eq(true)),\n    ),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn creator_is_moderator() -> _ {\n  creator_community_actions\n    .field(community_actions::became_moderator_at)\n    .nullable()\n    .is_not_null()\n}\n\n#[diesel::dsl::auto_type]\npub fn creator_banned_from_community() -> _ {\n  creator_community_actions\n    .field(community_actions::received_ban_at)\n    .nullable()\n    .is_not_null()\n}\n\n#[diesel::dsl::auto_type]\npub fn creator_ban_expires_from_community() -> _ {\n  creator_community_actions\n    .field(community_actions::ban_expires_at)\n    .nullable()\n}\n\n#[diesel::dsl::auto_type]\n/// Checks to see if a creator is banned from the local instance.\nfn creator_local_banned() -> _ {\n  creator_local_instance_actions\n    .field(instance_actions::received_ban_at)\n    .nullable()\n    .is_not_null()\n}\n\n#[diesel::dsl::auto_type]\nfn creator_local_ban_expires() -> _ {\n  creator_local_instance_actions\n    .field(instance_actions::ban_expires_at)\n    .nullable()\n}\n\n#[diesel::dsl::auto_type]\n/// Checks to see if a creator is banned from their community's instance\nfn creator_community_instance_banned() -> _ {\n  creator_community_instance_actions\n    .field(instance_actions::received_ban_at)\n    .nullable()\n    .is_not_null()\n}\n\n#[diesel::dsl::auto_type]\nfn creator_community_instance_ban_expires() -> _ {\n  creator_community_instance_actions\n    .field(instance_actions::ban_expires_at)\n    .nullable()\n}\n\n#[diesel::dsl::auto_type]\n/// Checks to see if a creator is banned from their home instance\npub fn creator_home_banned() -> _ {\n  creator_home_instance_actions\n    .field(instance_actions::received_ban_at)\n    .nullable()\n    .is_not_null()\n}\n\n#[diesel::dsl::auto_type]\n/// Checks to see if a creator is banned from their home instance\npub fn creator_home_ban_expires() -> _ {\n  creator_home_instance_actions\n    .field(instance_actions::ban_expires_at)\n    .nullable()\n}\n\n#[diesel::dsl::auto_type]\n/// Checks to see if a user is site banned from any of these places:\n/// - Their own instance\n/// - The local instance\npub fn creator_local_home_banned() -> _ {\n  creator_local_banned().or(creator_home_banned())\n}\n\npub type CreatorLocalHomeBanExpiresType = coalesce_2_nullable<\n  Timestamptz,\n  Nullable<AliasedField<CreatorLocalInstanceActions, instance_actions::ban_expires_at>>,\n  Nullable<AliasedField<CreatorHomeInstanceActions, instance_actions::ban_expires_at>>,\n>;\n\npub fn creator_local_home_ban_expires() -> CreatorLocalHomeBanExpiresType {\n  coalesce_2_nullable(creator_local_ban_expires(), creator_home_ban_expires())\n}\n\n/// Checks to see if a user is site banned from any of these places:\n/// - The local instance\n/// - Their own instance\n/// - The community instance.\n#[diesel::dsl::auto_type]\npub fn creator_local_home_community_banned() -> _ {\n  creator_local_banned()\n    .or(creator_home_banned())\n    .or(creator_community_instance_banned())\n}\n\npub type CreatorLocalHomeCommunityBanExpiresType = coalesce_3_nullable<\n  Timestamptz,\n  Nullable<AliasedField<CreatorLocalInstanceActions, instance_actions::ban_expires_at>>,\n  Nullable<AliasedField<CreatorHomeInstanceActions, instance_actions::ban_expires_at>>,\n  Nullable<AliasedField<CreatorCommunityInstanceActions, instance_actions::ban_expires_at>>,\n>;\n\npub fn creator_local_home_community_ban_expires() -> CreatorLocalHomeCommunityBanExpiresType {\n  coalesce_3_nullable(\n    creator_local_ban_expires(),\n    creator_home_ban_expires(),\n    creator_community_instance_ban_expires(),\n  )\n}\n\n#[diesel::dsl::auto_type]\nfn am_higher_mod() -> _ {\n  let i_became_moderator = community_actions::became_moderator_at.nullable();\n\n  let creator_became_moderator = creator_community_actions\n    .field(community_actions::became_moderator_at)\n    .nullable();\n\n  i_became_moderator.is_not_null().and(\n    creator_became_moderator\n      .ge(i_became_moderator)\n      .is_distinct_from(false),\n  )\n}\n\n/// Checks to see if you can mod an item.\n///\n/// Caveat: Since admin status isn't federated or ordered, it can't know whether\n/// item creator is a federated admin, or a higher admin.\n/// The back-end will reject an action for admin that is higher via\n/// LocalUser::is_higher_mod_or_admin_check\n#[diesel::dsl::auto_type]\npub fn local_user_can_mod() -> _ {\n  local_user_is_admin().or(not(creator_is_admin()).and(am_higher_mod()))\n}\n\n/// Checks to see if you can mod a post.\n#[diesel::dsl::auto_type]\npub fn local_user_can_mod_post() -> _ {\n  local_user_is_admin().or(not(post_creator_is_admin()).and(am_higher_mod()))\n}\n\n/// Checks to see if you can mod a comment.\n#[diesel::dsl::auto_type]\npub fn local_user_can_mod_comment() -> _ {\n  local_user_is_admin().or(not(comment_creator_is_admin()).and(am_higher_mod()))\n}\n\n/// A special type of can_mod for communities, which dont have creators.\n#[diesel::dsl::auto_type]\npub fn local_user_community_can_mod() -> _ {\n  let am_admin = local_user::admin.nullable();\n  let am_moderator = community_actions::became_moderator_at\n    .nullable()\n    .is_not_null();\n  am_admin.or(am_moderator).is_not_distinct_from(true)\n}\n\n/// Selects the comment columns, but gives an empty string for content when\n/// deleted or removed, and you're not a mod/admin.\n#[diesel::dsl::auto_type]\npub fn comment_select_remove_deletes() -> _ {\n  let deleted_or_removed = comment::deleted.or(comment::removed);\n\n  // You can only view the content if it hasn't been removed, you're a mod or it's your own comment.\n  let is_creator = local_user::person_id\n    .nullable()\n    .eq(comment::creator_id.nullable());\n  let can_view_content = not(deleted_or_removed)\n    .or(local_user_can_mod_comment())\n    .or(is_creator);\n  let content = case_when(can_view_content, comment::content).otherwise(\"\");\n\n  (\n    comment::id,\n    comment::creator_id,\n    comment::post_id,\n    content,\n    comment::removed,\n    comment::published_at,\n    comment::updated_at,\n    comment::deleted,\n    comment::ap_id,\n    comment::local,\n    comment::path,\n    comment::distinguished,\n    comment::language_id,\n    comment::score,\n    comment::upvotes,\n    comment::downvotes,\n    comment::child_count,\n    comment::hot_rank,\n    comment::controversy_rank,\n    comment::report_count,\n    comment::unresolved_report_count,\n    comment::federation_pending,\n    comment::locked,\n  )\n}\n\n/// Selects the post columns, but gives an empty string for content when\n/// deleted or removed, and you're not a mod/admin.\n#[diesel::dsl::auto_type]\npub fn post_select_remove_deletes() -> _ {\n  let deleted_or_removed = post::deleted.or(post::removed);\n\n  // You can only view the content if it hasn't been removed, you're a mod or it's your own post.\n  let is_creator = local_user::person_id\n    .nullable()\n    .eq(post::creator_id.nullable());\n  let can_view_content = not(deleted_or_removed)\n    .or(local_user_can_mod_post())\n    .or(is_creator);\n  let body = case_when(can_view_content, post::body).otherwise(\"\");\n\n  (\n    post::id,\n    post::name,\n    post::url,\n    body,\n    post::creator_id,\n    post::community_id,\n    post::removed,\n    post::locked,\n    post::published_at,\n    post::updated_at,\n    post::deleted,\n    post::nsfw,\n    post::embed_title,\n    post::embed_description,\n    post::thumbnail_url,\n    post::ap_id,\n    post::local,\n    post::embed_video_url,\n    post::language_id,\n    post::featured_community,\n    post::featured_local,\n    post::url_content_type,\n    post::alt_text,\n    post::scheduled_publish_time_at,\n    post::newest_comment_time_necro_at,\n    post::newest_comment_time_at,\n    post::comments,\n    post::score,\n    post::upvotes,\n    post::downvotes,\n    post::hot_rank,\n    post::hot_rank_active,\n    post::controversy_rank,\n    post::scaled_rank,\n    post::report_count,\n    post::unresolved_report_count,\n    post::federation_pending,\n    post::embed_video_width,\n    post::embed_video_height,\n  )\n}\n\n#[diesel::dsl::auto_type]\n// Gets the post community tags set on a specific post.\npub fn post_community_tags_fragment() -> _ {\n  let sel: SqlLiteral<Json> =\n    diesel::dsl::sql::<diesel::sql_types::Json>(\"json_agg(community_tag.*)\");\n  post_community_tag::table\n    .inner_join(community_tag::table)\n    .select(sel)\n    .filter(post_community_tag::post_id.eq(post::id))\n    .filter(community_tag::deleted.eq(false))\n    .single_value()\n}\n\n#[diesel::dsl::auto_type]\n/// Gets the tags available within a specific community\npub fn community_tags_fragment() -> _ {\n  let sel: SqlLiteral<Json> =\n    diesel::dsl::sql::<diesel::sql_types::Json>(\"json_agg(community_tag.*)\");\n  community_tag::table\n    .select(sel)\n    .filter(community_tag::community_id.eq(community::id))\n    .filter(\n      community_tag::deleted\n        .eq(false)\n        // Show deleted tags for admins and mods\n        .or(local_user_community_can_mod()),\n    )\n    .single_value()\n}\n\n/// The select for the person1 alias.\npub fn person1_select() -> Person1AliasAllColumnsTuple {\n  person1.fields(person::all_columns)\n}\n\n/// The select for the person2 alias.\npub fn person2_select() -> Person2AliasAllColumnsTuple {\n  person2.fields(person::all_columns)\n}\n"
  },
  {
    "path": "crates/db_schema_file/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_schema_file\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_db_schema_file\"\npath = \"src/lib.rs\"\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"diesel\",\n  \"diesel_ltree\",\n  \"diesel-derive-enum\",\n  \"diesel-uplete\",\n  \"diesel-derive-newtype\",\n]\nts-rs = [\"dep:ts-rs\"]\n\n[dependencies]\nserde = { workspace = true }\nstrum = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel_ltree = { workspace = true, optional = true }\nts-rs = { workspace = true, optional = true }\ndiesel-derive-enum = { workspace = true, optional = true }\ndiesel-uplete = { workspace = true, optional = true }\ndiesel-derive-newtype = { workspace = true, optional = true }\n"
  },
  {
    "path": "crates/db_schema_file/diesel_ltree.patch",
    "content": "diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs\nindex 8bc07d2c3..3b95487ec 100644\n--- a/crates/db_schema_file/src/schema.rs\n+++ b/crates/db_schema_file/src/schema.rs\n@@ -68,7 +68,7 @@ pub mod sql_types {\n \n diesel::table! {\n     use diesel::sql_types::*;\n-    use super::sql_types::Ltree;\n+    use diesel_ltree::sql_types::Ltree;\n \n     comment (id) {\n         id -> Int4,\n@@ -1078,5 +1078,7 @@ diesel::allow_tables_to_appear_in_same_query!(\n   search_combined,\n   site,\n   site_language,\n+  person_actions,\n+  image_details,\n );\n diesel::allow_tables_to_appear_in_same_query!(custom_emoji, custom_emoji_keyword,);\n"
  },
  {
    "path": "crates/db_schema_file/src/enums.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel_derive_enum::DbEnum;\nuse serde::{Deserialize, Serialize};\nuse strum::Display;\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::PostSortTypeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The post sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html\npub enum PostSortType {\n  #[default]\n  Active,\n  Hot,\n  New,\n  Old,\n  Top,\n  MostComments,\n  NewComments,\n  Controversial,\n  Scaled,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::CommentSortTypeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The comment sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html\npub enum CommentSortType {\n  #[default]\n  Hot,\n  Top,\n  New,\n  Old,\n  Controversial,\n}\n\n#[derive(Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::ListingTypeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A listing type for post and comment list fetches.\npub enum ListingType {\n  /// Content from your own site, as well as all connected / federated sites.\n  All,\n  /// Content from your site only.\n  #[default]\n  Local,\n  /// Content only from communities you've subscribed to.\n  Subscribed,\n  /// Content that you can moderate (because you are a moderator of the community it is posted to)\n  ModeratorView,\n  /// Communities which are recommended by local instance admins\n  Suggested,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::RegistrationModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The registration mode for your site. Determines what happens after a user signs up.\npub enum RegistrationMode {\n  /// Closed to public.\n  Closed,\n  /// Open, but pending approval of a registration application.\n  RequireApplication,\n  /// Open to all.\n  #[default]\n  Open,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::PostListingModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A post-view mode that changes how multiple post listings look.\npub enum PostListingMode {\n  /// A compact, list-type view.\n  #[default]\n  List,\n  /// A larger card-type view.\n  Card,\n  /// A smaller card-type view, usually with images as thumbnails\n  SmallCard,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::CommunityVisibility\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Defines who can browse and interact with content in a community.\npub enum CommunityVisibility {\n  /// Public community, any local or federated user can interact.\n  #[default]\n  Public,\n  /// Community is unlisted/hidden and doesn't appear in community list. Posts from the community\n  /// are not shown in Local and All feeds, except for subscribed users.\n  Unlisted,\n  /// Unfederated community, only local users can interact (with or without login).\n  LocalOnlyPublic,\n  /// Unfederated  community, only logged-in local users can interact.\n  LocalOnlyPrivate,\n  /// Users need to be approved by mods before they are able to browse or post.\n  Private,\n}\n\nimpl CommunityVisibility {\n  pub fn can_federate(&self) -> bool {\n    use CommunityVisibility::*;\n    self != &LocalOnlyPublic && self != &LocalOnlyPrivate\n  }\n  pub fn can_view_without_login(&self) -> bool {\n    use CommunityVisibility::*;\n    self == &Public || self == &LocalOnlyPublic\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::FederationModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// The federation mode for an item\npub enum FederationMode {\n  #[default]\n  /// Allows all\n  All,\n  /// Allows only local\n  Local,\n  /// Disables\n  Disable,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::ImageModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A mode for setting how pictrs handles images.\npub enum ImageMode {\n  /// Leave images unchanged, don't generate any local thumbnails for post urls. Instead the\n  /// Opengraph image is directly returned as thumbnail\n  None,\n  /// Generate thumbnails for external post urls and store them persistently in pict-rs. This\n  /// ensures that they can be reliably retrieved and can be resized using pict-rs APIs. However it\n  /// also increases storage usage.\n  ///\n  /// This behaviour matches Lemmy 0.18.\n  StoreLinkPreviews,\n  /// If enabled, all images from remote domains are rewritten to pass through\n  /// `/api/v4/image/proxy`, including embedded images in markdown. Images are stored temporarily in\n  /// pict-rs for caching. This improves privacy as users don't expose their IP to untrusted\n  /// servers, and decreases load on other servers. However it increases bandwidth use for the local\n  /// server.\n  ///\n  /// Requires pict-rs 0.5\n  #[default]\n  ProxyAllImages,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::ActorTypeEnum\"\n)]\npub enum ActorType {\n  Site,\n  Community,\n  Person,\n  MultiCommunity,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::CommunityFollowerState\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub enum CommunityFollowerState {\n  Accepted,\n  Pending,\n  ApprovalRequired,\n  Denied,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::TagColorEnum\"\n)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Color of community tag.\npub enum TagColor {\n  #[default]\n  Color01,\n  Color02,\n  Color03,\n  Color04,\n  Color05,\n  Color06,\n  Color07,\n  Color08,\n  Color09,\n  Color10,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::VoteShowEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Lets you show votes for others only, show all votes, or hide all votes.\npub enum VoteShow {\n  #[default]\n  Show,\n  ShowForOthers,\n  Hide,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::PostNotificationsModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Available settings for post notifications\npub enum PostNotificationsMode {\n  AllComments,\n  #[default]\n  RepliesAndMentions,\n  Mute,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::CommunityNotificationsModeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Available settings for community notifications\npub enum CommunityNotificationsMode {\n  AllPostsAndComments,\n  AllPosts,\n  #[default]\n  RepliesAndMentions,\n  Mute,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::NotificationTypeEnum\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// Types of notifications which can be received in inbox\npub enum NotificationType {\n  // Necessary for enumstring\n  #[default]\n  Mention,\n  Reply,\n  Subscribed,\n  PrivateMessage,\n  ModAction,\n}\n\n#[derive(Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"full\", derive(DbEnum))]\n#[cfg_attr(\n  feature = \"full\",\n  ExistingTypePath = \"crate::schema::sql_types::ModlogKind\"\n)]\n#[cfg_attr(feature = \"full\", DbValueStyle = \"verbatim\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n/// A list of possible types for the various modlog actions.\npub enum ModlogKind {\n  // Necessary for enumstring\n  #[default]\n  AdminAdd,\n  AdminBan,\n  AdminAllowInstance,\n  AdminBlockInstance,\n  AdminPurgeComment,\n  AdminPurgeCommunity,\n  AdminPurgePerson,\n  AdminPurgePost,\n  ModAddToCommunity,\n  ModBanFromCommunity,\n  AdminFeaturePostSite,\n  ModFeaturePostCommunity,\n  ModChangeCommunityVisibility,\n  ModLockPost,\n  ModRemoveComment,\n  AdminRemoveCommunity,\n  ModRemovePost,\n  ModTransferCommunity,\n  ModLockComment,\n  ModWarnComment,\n  ModWarnPost,\n}\n"
  },
  {
    "path": "crates/db_schema_file/src/joins.rs",
    "content": "use crate::{\n  InstanceId,\n  PersonId,\n  aliases::{\n    creator_community_actions,\n    creator_community_instance_actions,\n    creator_home_instance_actions,\n    creator_local_instance_actions,\n    creator_local_user,\n    my_instance_persons_actions,\n  },\n  schema::{\n    comment,\n    comment_actions,\n    community,\n    community_actions,\n    image_details,\n    instance_actions,\n    local_user,\n    multi_community,\n    multi_community_follow,\n    person,\n    person_actions,\n    post,\n    post_actions,\n  },\n};\nuse diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods};\n\n#[diesel::dsl::auto_type]\npub fn creator_local_user_admin_join() -> _ {\n  creator_local_user.on(\n    person::id\n      .eq(creator_local_user.field(local_user::person_id))\n      .and(creator_local_user.field(local_user::admin).eq(true)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn community_join() -> _ {\n  community::table.on(post::community_id.eq(community::id))\n}\n#[diesel::dsl::auto_type]\npub fn creator_home_instance_actions_join() -> _ {\n  creator_home_instance_actions.on(\n    creator_home_instance_actions\n      .field(instance_actions::instance_id)\n      .eq(person::instance_id)\n      .and(\n        creator_home_instance_actions\n          .field(instance_actions::person_id)\n          .eq(person::id),\n      ),\n  )\n}\n#[diesel::dsl::auto_type]\npub fn creator_community_instance_actions_join() -> _ {\n  creator_community_instance_actions.on(\n    creator_home_instance_actions\n      .field(instance_actions::instance_id)\n      .eq(community::instance_id)\n      .and(\n        creator_community_instance_actions\n          .field(instance_actions::person_id)\n          .eq(person::id),\n      ),\n  )\n}\n\n/// join with instance actions for local instance\n///\n/// Requires annotation for return type, see https://docs.diesel.rs/2.2.x/diesel/dsl/attr.auto_type.html#annotating-types\n#[diesel::dsl::auto_type]\npub fn creator_local_instance_actions_join(local_instance_id: InstanceId) -> _ {\n  creator_local_instance_actions.on(\n    creator_local_instance_actions\n      .field(instance_actions::instance_id)\n      .eq(local_instance_id)\n      .and(\n        creator_local_instance_actions\n          .field(instance_actions::person_id)\n          .eq(person::id),\n      ),\n  )\n}\n\n/// Your instance actions for the community's instance.\n#[diesel::dsl::auto_type]\npub fn my_instance_communities_actions_join(my_person_id: Option<PersonId>) -> _ {\n  instance_actions::table.on(\n    instance_actions::instance_id\n      .eq(community::instance_id)\n      .and(instance_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n/// Your instance actions for the person's instance.\n#[diesel::dsl::auto_type]\npub fn my_instance_persons_actions_join(my_person_id: Option<PersonId>) -> _ {\n  instance_actions::table.on(\n    instance_actions::instance_id\n      .eq(person::instance_id)\n      .and(instance_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n/// Your instance actions for the person's instance.\n/// A dupe of the above function, but aliased\n#[diesel::dsl::auto_type]\npub fn my_instance_persons_actions_join_1(my_person_id: Option<PersonId>) -> _ {\n  my_instance_persons_actions.on(\n    my_instance_persons_actions\n      .field(instance_actions::instance_id)\n      .eq(person::instance_id)\n      .and(\n        my_instance_persons_actions\n          .field(instance_actions::person_id)\n          .nullable()\n          .eq(my_person_id),\n      ),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn image_details_join() -> _ {\n  image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))\n}\n\n#[diesel::dsl::auto_type]\npub fn my_community_actions_join(my_person_id: Option<PersonId>) -> _ {\n  community_actions::table.on(\n    community_actions::community_id\n      .eq(community::id)\n      .and(community_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn my_post_actions_join(my_person_id: Option<PersonId>) -> _ {\n  post_actions::table.on(\n    post_actions::post_id\n      .eq(post::id)\n      .and(post_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn my_comment_actions_join(my_person_id: Option<PersonId>) -> _ {\n  comment_actions::table.on(\n    comment_actions::comment_id\n      .eq(comment::id)\n      .and(comment_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn my_person_actions_join(my_person_id: Option<PersonId>) -> _ {\n  person_actions::table.on(\n    person_actions::target_id\n      .eq(person::id)\n      .and(person_actions::person_id.nullable().eq(my_person_id)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn my_local_user_admin_join(my_person_id: Option<PersonId>) -> _ {\n  local_user::table.on(\n    local_user::person_id\n      .nullable()\n      .eq(my_person_id)\n      .and(local_user::admin.eq(true)),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn my_multi_community_follower_join(my_person_id: Option<PersonId>) -> _ {\n  multi_community_follow::table.on(\n    multi_community_follow::multi_community_id\n      .eq(multi_community::id)\n      .and(\n        multi_community_follow::person_id\n          .nullable()\n          .eq(my_person_id),\n      ),\n  )\n}\n\n#[diesel::dsl::auto_type]\npub fn creator_community_actions_join() -> _ {\n  creator_community_actions.on(\n    creator_community_actions\n      .field(community_actions::community_id)\n      .eq(community::id)\n      .and(\n        creator_community_actions\n          .field(community_actions::person_id)\n          .eq(person::id),\n      ),\n  )\n}\n"
  },
  {
    "path": "crates/db_schema_file/src/lib.rs",
    "content": "use core::default::Default;\n#[cfg(feature = \"full\")]\nuse diesel_derive_newtype::DieselNewType;\nuse serde::{Deserialize, Serialize};\n\npub mod enums;\n#[cfg(feature = \"full\")]\npub mod joins;\n#[cfg(feature = \"full\")]\npub mod schema;\n#[cfg(feature = \"full\")]\npub mod table_impls;\n\n#[cfg(feature = \"full\")]\npub mod aliases {\n  use crate::schema::{community_actions, instance_actions, local_user, person};\n  diesel::alias!(\n    community_actions as creator_community_actions: CreatorCommunityActions,\n    instance_actions as creator_home_instance_actions: CreatorHomeInstanceActions,\n    instance_actions as creator_community_instance_actions: CreatorCommunityInstanceActions,\n    instance_actions as creator_local_instance_actions: CreatorLocalInstanceActions,\n    instance_actions as my_instance_persons_actions: MyInstancePersonsActions,\n    local_user as creator_local_user: CreatorLocalUser,\n    person as person1: Person1,\n    person as person2: Person2,\n  );\n}\n\n#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The person id.\npub struct PersonId(pub i32);\n\n#[derive(\n  Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, Ord, PartialOrd,\n)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The instance id.\npub struct InstanceId(pub i32);\n\nimpl InstanceId {\n  pub fn inner(self) -> i32 {\n    self.0\n  }\n}\n"
  },
  {
    "path": "crates/db_schema_file/src/schema.rs",
    "content": "// @generated automatically by Diesel CLI.\n\npub mod sql_types {\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"actor_type_enum\"))]\n  pub struct ActorTypeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"comment_sort_type_enum\"))]\n  pub struct CommentSortTypeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"community_follower_state\"))]\n  pub struct CommunityFollowerState;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"community_notifications_mode_enum\"))]\n  pub struct CommunityNotificationsModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"community_visibility\"))]\n  pub struct CommunityVisibility;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"federation_mode_enum\"))]\n  pub struct FederationModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"image_mode_enum\"))]\n  pub struct ImageModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"listing_type_enum\"))]\n  pub struct ListingTypeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"ltree\"))]\n  pub struct Ltree;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"modlog_kind\"))]\n  pub struct ModlogKind;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"notification_type_enum\"))]\n  pub struct NotificationTypeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"post_listing_mode_enum\"))]\n  pub struct PostListingModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"post_notifications_mode_enum\"))]\n  pub struct PostNotificationsModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"post_sort_type_enum\"))]\n  pub struct PostSortTypeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"registration_mode_enum\"))]\n  pub struct RegistrationModeEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"tag_color_enum\"))]\n  pub struct TagColorEnum;\n\n  #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]\n  #[diesel(postgres_type(name = \"vote_show_enum\"))]\n  pub struct VoteShowEnum;\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use diesel_ltree::sql_types::Ltree;\n\n    comment (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        post_id -> Int4,\n        content -> Text,\n        removed -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        deleted -> Bool,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        local -> Bool,\n        path -> Ltree,\n        distinguished -> Bool,\n        language_id -> Int4,\n        score -> Int4,\n        upvotes -> Int4,\n        downvotes -> Int4,\n        child_count -> Int4,\n        hot_rank -> Float4,\n        controversy_rank -> Float4,\n        report_count -> Int2,\n        unresolved_report_count -> Int2,\n        federation_pending -> Bool,\n        locked -> Bool,\n    }\n}\n\ndiesel::table! {\n    comment_actions (person_id, comment_id) {\n        voted_at -> Nullable<Timestamptz>,\n        saved_at -> Nullable<Timestamptz>,\n        person_id -> Int4,\n        comment_id -> Int4,\n        vote_is_upvote -> Nullable<Bool>,\n    }\n}\n\ndiesel::table! {\n    comment_report (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        comment_id -> Int4,\n        original_comment_text -> Text,\n        reason -> Text,\n        resolved -> Bool,\n        resolver_id -> Nullable<Int4>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        violates_instance_rules -> Bool,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::CommunityVisibility;\n\n    community (id) {\n        id -> Int4,\n        #[max_length = 255]\n        name -> Varchar,\n        #[max_length = 50]\n        title -> Varchar,\n        sidebar -> Nullable<Text>,\n        removed -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        deleted -> Bool,\n        nsfw -> Bool,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        local -> Bool,\n        private_key -> Nullable<Text>,\n        public_key -> Text,\n        last_refreshed_at -> Timestamptz,\n        icon -> Nullable<Text>,\n        banner -> Nullable<Text>,\n        #[max_length = 255]\n        followers_url -> Nullable<Varchar>,\n        #[max_length = 255]\n        inbox_url -> Varchar,\n        posting_restricted_to_mods -> Bool,\n        instance_id -> Int4,\n        #[max_length = 255]\n        moderators_url -> Nullable<Varchar>,\n        #[max_length = 255]\n        featured_url -> Nullable<Varchar>,\n        visibility -> CommunityVisibility,\n        #[max_length = 150]\n        summary -> Nullable<Varchar>,\n        random_number -> Int2,\n        subscribers -> Int4,\n        posts -> Int4,\n        comments -> Int4,\n        users_active_day -> Int4,\n        users_active_week -> Int4,\n        users_active_month -> Int4,\n        users_active_half_year -> Int4,\n        hot_rank -> Float4,\n        subscribers_local -> Int4,\n        interactions_month -> Int4,\n        report_count -> Int2,\n        unresolved_report_count -> Int2,\n        local_removed -> Bool,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::CommunityFollowerState;\n    use super::sql_types::CommunityNotificationsModeEnum;\n\n    community_actions (person_id, community_id) {\n        followed_at -> Nullable<Timestamptz>,\n        blocked_at -> Nullable<Timestamptz>,\n        became_moderator_at -> Nullable<Timestamptz>,\n        received_ban_at -> Nullable<Timestamptz>,\n        ban_expires_at -> Nullable<Timestamptz>,\n        person_id -> Int4,\n        community_id -> Int4,\n        follow_state -> Nullable<CommunityFollowerState>,\n        follow_approver_id -> Nullable<Int4>,\n        notifications -> Nullable<CommunityNotificationsModeEnum>,\n    }\n}\n\ndiesel::table! {\n    community_community_follow (community_id, target_id) {\n        target_id -> Int4,\n        community_id -> Int4,\n        published_at -> Timestamptz,\n    }\n}\n\ndiesel::table! {\n    community_language (community_id, language_id) {\n        community_id -> Int4,\n        language_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    community_report (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        community_id -> Int4,\n        original_community_name -> Text,\n        original_community_title -> Text,\n        original_community_summary -> Nullable<Text>,\n        original_community_sidebar -> Nullable<Text>,\n        original_community_icon -> Nullable<Text>,\n        original_community_banner -> Nullable<Text>,\n        reason -> Text,\n        resolved -> Bool,\n        resolver_id -> Nullable<Int4>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::TagColorEnum;\n\n    community_tag (id) {\n        id -> Int4,\n        ap_id -> Text,\n        #[max_length = 255]\n        name -> Varchar,\n        #[max_length = 255]\n        display_name -> Nullable<Varchar>,\n        #[max_length = 150]\n        summary -> Nullable<Varchar>,\n        community_id -> Int4,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        deleted -> Bool,\n        color -> TagColorEnum,\n    }\n}\n\ndiesel::table! {\n    custom_emoji (id) {\n        id -> Int4,\n        #[max_length = 128]\n        shortcode -> Varchar,\n        image_url -> Text,\n        alt_text -> Text,\n        category -> Text,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    custom_emoji_keyword (custom_emoji_id, keyword) {\n        custom_emoji_id -> Int4,\n        #[max_length = 128]\n        keyword -> Varchar,\n    }\n}\n\ndiesel::table! {\n    email_verification (id) {\n        id -> Int4,\n        local_user_id -> Int4,\n        email -> Text,\n        verification_token -> Text,\n        published_at -> Timestamptz,\n    }\n}\n\ndiesel::table! {\n    federation_allowlist (instance_id) {\n        instance_id -> Int4,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    federation_blocklist (instance_id) {\n        instance_id -> Int4,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        expires_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    federation_queue_state (instance_id) {\n        instance_id -> Int4,\n        last_successful_id -> Nullable<Int8>,\n        fail_count -> Int4,\n        last_retry_at -> Nullable<Timestamptz>,\n        last_successful_published_time_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    image_details (link) {\n        link -> Text,\n        width -> Int4,\n        height -> Int4,\n        content_type -> Text,\n        #[max_length = 50]\n        blurhash -> Nullable<Varchar>,\n    }\n}\n\ndiesel::table! {\n    instance (id) {\n        id -> Int4,\n        #[max_length = 255]\n        domain -> Varchar,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        #[max_length = 255]\n        software -> Nullable<Varchar>,\n        #[max_length = 255]\n        version -> Nullable<Varchar>,\n    }\n}\n\ndiesel::table! {\n    instance_actions (person_id, instance_id) {\n        blocked_communities_at -> Nullable<Timestamptz>,\n        person_id -> Int4,\n        instance_id -> Int4,\n        received_ban_at -> Nullable<Timestamptz>,\n        ban_expires_at -> Nullable<Timestamptz>,\n        blocked_persons_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    language (id) {\n        id -> Int4,\n        #[max_length = 3]\n        code -> Varchar,\n        name -> Text,\n    }\n}\n\ndiesel::table! {\n    local_image (pictrs_alias) {\n        pictrs_alias -> Text,\n        published_at -> Timestamptz,\n        person_id -> Nullable<Int4>,\n        thumbnail_for_post_id -> Nullable<Int4>,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::ListingTypeEnum;\n    use super::sql_types::RegistrationModeEnum;\n    use super::sql_types::PostListingModeEnum;\n    use super::sql_types::PostSortTypeEnum;\n    use super::sql_types::CommentSortTypeEnum;\n    use super::sql_types::FederationModeEnum;\n    use super::sql_types::ImageModeEnum;\n\n    local_site (id) {\n        id -> Int4,\n        site_id -> Int4,\n        site_setup -> Bool,\n        community_creation_admin_only -> Bool,\n        require_email_verification -> Bool,\n        application_question -> Nullable<Text>,\n        private_instance -> Bool,\n        default_theme -> Text,\n        default_post_listing_type -> ListingTypeEnum,\n        legal_information -> Nullable<Text>,\n        application_email_admins -> Bool,\n        slur_filter_regex -> Nullable<Text>,\n        federation_enabled -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        registration_mode -> RegistrationModeEnum,\n        reports_email_admins -> Bool,\n        federation_signed_fetch -> Bool,\n        default_post_listing_mode -> PostListingModeEnum,\n        default_post_sort_type -> PostSortTypeEnum,\n        default_comment_sort_type -> CommentSortTypeEnum,\n        oauth_registration -> Bool,\n        post_upvotes -> FederationModeEnum,\n        post_downvotes -> FederationModeEnum,\n        comment_upvotes -> FederationModeEnum,\n        comment_downvotes -> FederationModeEnum,\n        default_post_time_range_seconds -> Nullable<Int4>,\n        disallow_nsfw_content -> Bool,\n        users -> Int4,\n        posts -> Int4,\n        comments -> Int4,\n        communities -> Int4,\n        users_active_day -> Int4,\n        users_active_week -> Int4,\n        users_active_month -> Int4,\n        users_active_half_year -> Int4,\n        disable_email_notifications -> Bool,\n        suggested_multi_community_id -> Nullable<Int4>,\n        system_account -> Int4,\n        default_items_per_page -> Int4,\n        image_mode -> ImageModeEnum,\n        image_proxy_bypass_domains -> Nullable<Text>,\n        image_upload_timeout_seconds -> Int4,\n        image_max_thumbnail_size -> Int4,\n        image_max_avatar_size -> Int4,\n        image_max_banner_size -> Int4,\n        image_max_upload_size -> Int4,\n        image_allow_video_uploads -> Bool,\n        image_upload_disabled -> Bool,\n    }\n}\n\ndiesel::table! {\n    local_site_rate_limit (local_site_id) {\n        local_site_id -> Int4,\n        message_max_requests -> Int4,\n        message_interval_seconds -> Int4,\n        post_max_requests -> Int4,\n        post_interval_seconds -> Int4,\n        register_max_requests -> Int4,\n        register_interval_seconds -> Int4,\n        image_max_requests -> Int4,\n        image_interval_seconds -> Int4,\n        comment_max_requests -> Int4,\n        comment_interval_seconds -> Int4,\n        search_max_requests -> Int4,\n        search_interval_seconds -> Int4,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        import_user_settings_max_requests -> Int4,\n        import_user_settings_interval_seconds -> Int4,\n    }\n}\n\ndiesel::table! {\n    local_site_url_blocklist (id) {\n        id -> Int4,\n        url -> Text,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::PostSortTypeEnum;\n    use super::sql_types::ListingTypeEnum;\n    use super::sql_types::PostListingModeEnum;\n    use super::sql_types::CommentSortTypeEnum;\n    use super::sql_types::VoteShowEnum;\n\n    local_user (id) {\n        id -> Int4,\n        person_id -> Int4,\n        password_encrypted -> Nullable<Text>,\n        email -> Nullable<Text>,\n        show_nsfw -> Bool,\n        theme -> Text,\n        default_post_sort_type -> PostSortTypeEnum,\n        default_listing_type -> ListingTypeEnum,\n        #[max_length = 20]\n        interface_language -> Varchar,\n        show_avatars -> Bool,\n        send_notifications_to_email -> Bool,\n        show_bot_accounts -> Bool,\n        show_read_posts -> Bool,\n        email_verified -> Bool,\n        accepted_application -> Bool,\n        totp_2fa_secret -> Nullable<Text>,\n        open_links_in_new_tab -> Bool,\n        blur_nsfw -> Bool,\n        infinite_scroll_enabled -> Bool,\n        admin -> Bool,\n        post_listing_mode -> PostListingModeEnum,\n        totp_2fa_enabled -> Bool,\n        enable_animated_images -> Bool,\n        collapse_bot_comments -> Bool,\n        last_donation_notification_at -> Timestamptz,\n        enable_private_messages -> Bool,\n        default_comment_sort_type -> CommentSortTypeEnum,\n        auto_mark_fetched_posts_as_read -> Bool,\n        hide_media -> Bool,\n        default_post_time_range_seconds -> Nullable<Int4>,\n        show_score -> Bool,\n        show_upvotes -> Bool,\n        show_downvotes -> VoteShowEnum,\n        show_upvote_percentage -> Bool,\n        show_person_votes -> Bool,\n        default_items_per_page -> Int4,\n    }\n}\n\ndiesel::table! {\n    local_user_keyword_block (local_user_id, keyword) {\n        local_user_id -> Int4,\n        #[max_length = 50]\n        keyword -> Varchar,\n    }\n}\n\ndiesel::table! {\n    local_user_language (local_user_id, language_id) {\n        local_user_id -> Int4,\n        language_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    login_token (token) {\n        token -> Text,\n        user_id -> Int4,\n        published_at -> Timestamptz,\n        ip -> Nullable<Text>,\n        user_agent -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::ModlogKind;\n\n    modlog (id) {\n        id -> Int4,\n        kind -> ModlogKind,\n        is_revert -> Bool,\n        mod_id -> Int4,\n        reason -> Nullable<Text>,\n        target_person_id -> Nullable<Int4>,\n        target_community_id -> Nullable<Int4>,\n        target_post_id -> Nullable<Int4>,\n        target_comment_id -> Nullable<Int4>,\n        target_instance_id -> Nullable<Int4>,\n        expires_at -> Nullable<Timestamptz>,\n        published_at -> Timestamptz,\n        bulk_action_parent_id -> Nullable<Int4>,\n    }\n}\n\ndiesel::table! {\n    multi_community (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        instance_id -> Int4,\n        #[max_length = 255]\n        name -> Varchar,\n        #[max_length = 255]\n        title -> Nullable<Varchar>,\n        #[max_length = 255]\n        summary -> Nullable<Varchar>,\n        local -> Bool,\n        deleted -> Bool,\n        ap_id -> Text,\n        public_key -> Text,\n        private_key -> Nullable<Text>,\n        inbox_url -> Text,\n        last_refreshed_at -> Timestamptz,\n        following_url -> Text,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        subscribers -> Int4,\n        subscribers_local -> Int4,\n        communities -> Int4,\n        sidebar -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    multi_community_entry (multi_community_id, community_id) {\n        multi_community_id -> Int4,\n        community_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::CommunityFollowerState;\n\n    multi_community_follow (person_id, multi_community_id) {\n        multi_community_id -> Int4,\n        person_id -> Int4,\n        follow_state -> CommunityFollowerState,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::NotificationTypeEnum;\n\n    notification (id) {\n        id -> Int4,\n        recipient_id -> Int4,\n        comment_id -> Nullable<Int4>,\n        read -> Bool,\n        published_at -> Timestamptz,\n        kind -> NotificationTypeEnum,\n        post_id -> Nullable<Int4>,\n        private_message_id -> Nullable<Int4>,\n        modlog_id -> Nullable<Int4>,\n        creator_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    oauth_account (oauth_provider_id, local_user_id) {\n        local_user_id -> Int4,\n        oauth_provider_id -> Int4,\n        oauth_user_id -> Text,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    oauth_provider (id) {\n        id -> Int4,\n        display_name -> Text,\n        issuer -> Text,\n        authorization_endpoint -> Text,\n        token_endpoint -> Text,\n        userinfo_endpoint -> Text,\n        id_claim -> Text,\n        client_id -> Text,\n        client_secret -> Text,\n        scopes -> Text,\n        auto_verify_email -> Bool,\n        account_linking_enabled -> Bool,\n        enabled -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        use_pkce -> Bool,\n    }\n}\n\ndiesel::table! {\n    password_reset_request (id) {\n        id -> Int4,\n        token -> Text,\n        published_at -> Timestamptz,\n        local_user_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    person (id) {\n        id -> Int4,\n        #[max_length = 255]\n        name -> Varchar,\n        #[max_length = 50]\n        display_name -> Nullable<Varchar>,\n        avatar -> Nullable<Text>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        bio -> Nullable<Text>,\n        local -> Bool,\n        private_key -> Nullable<Text>,\n        public_key -> Text,\n        last_refreshed_at -> Timestamptz,\n        banner -> Nullable<Text>,\n        deleted -> Bool,\n        #[max_length = 255]\n        inbox_url -> Varchar,\n        matrix_user_id -> Nullable<Text>,\n        bot_account -> Bool,\n        instance_id -> Int4,\n        post_count -> Int4,\n        post_score -> Int4,\n        comment_count -> Int4,\n        comment_score -> Int4,\n    }\n}\n\ndiesel::table! {\n    person_actions (person_id, target_id) {\n        followed_at -> Nullable<Timestamptz>,\n        blocked_at -> Nullable<Timestamptz>,\n        person_id -> Int4,\n        target_id -> Int4,\n        follow_pending -> Nullable<Bool>,\n        noted_at -> Nullable<Timestamptz>,\n        note -> Nullable<Text>,\n        voted_at -> Nullable<Timestamptz>,\n        upvotes -> Nullable<Int4>,\n        downvotes -> Nullable<Int4>,\n    }\n}\n\ndiesel::table! {\n    person_content_combined (id) {\n        published_at -> Timestamptz,\n        creator_id -> Int4,\n        post_id -> Nullable<Int4>,\n        comment_id -> Nullable<Int4>,\n        id -> Int4,\n    }\n}\n\ndiesel::table! {\n    person_liked_combined (id) {\n        voted_at -> Timestamptz,\n        id -> Int4,\n        person_id -> Int4,\n        creator_id -> Int4,\n        post_id -> Nullable<Int4>,\n        comment_id -> Nullable<Int4>,\n        vote_is_upvote -> Bool,\n    }\n}\n\ndiesel::table! {\n    person_saved_combined (id) {\n        saved_at -> Timestamptz,\n        person_id -> Int4,\n        creator_id -> Int4,\n        post_id -> Nullable<Int4>,\n        comment_id -> Nullable<Int4>,\n        id -> Int4,\n    }\n}\n\ndiesel::table! {\n    post (id) {\n        id -> Int4,\n        #[max_length = 200]\n        name -> Varchar,\n        #[max_length = 2000]\n        url -> Nullable<Varchar>,\n        body -> Nullable<Text>,\n        creator_id -> Int4,\n        community_id -> Int4,\n        removed -> Bool,\n        locked -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        deleted -> Bool,\n        nsfw -> Bool,\n        embed_title -> Nullable<Text>,\n        embed_description -> Nullable<Text>,\n        thumbnail_url -> Nullable<Text>,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        local -> Bool,\n        embed_video_url -> Nullable<Text>,\n        language_id -> Int4,\n        featured_community -> Bool,\n        featured_local -> Bool,\n        url_content_type -> Nullable<Text>,\n        alt_text -> Nullable<Text>,\n        scheduled_publish_time_at -> Nullable<Timestamptz>,\n        newest_comment_time_necro_at -> Nullable<Timestamptz>,\n        newest_comment_time_at -> Nullable<Timestamptz>,\n        comments -> Int4,\n        score -> Int4,\n        upvotes -> Int4,\n        downvotes -> Int4,\n        hot_rank -> Float4,\n        hot_rank_active -> Float4,\n        controversy_rank -> Float4,\n        scaled_rank -> Float4,\n        report_count -> Int2,\n        unresolved_report_count -> Int2,\n        federation_pending -> Bool,\n        embed_video_width -> Nullable<Int4>,\n        embed_video_height -> Nullable<Int4>,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::PostNotificationsModeEnum;\n\n    post_actions (person_id, post_id) {\n        read_at -> Nullable<Timestamptz>,\n        read_comments_at -> Nullable<Timestamptz>,\n        saved_at -> Nullable<Timestamptz>,\n        voted_at -> Nullable<Timestamptz>,\n        hidden_at -> Nullable<Timestamptz>,\n        person_id -> Int4,\n        post_id -> Int4,\n        read_comments_amount -> Nullable<Int4>,\n        vote_is_upvote -> Nullable<Bool>,\n        notifications -> Nullable<PostNotificationsModeEnum>,\n    }\n}\n\ndiesel::table! {\n    post_community_tag (post_id, community_tag_id) {\n        post_id -> Int4,\n        community_tag_id -> Int4,\n        published_at -> Timestamptz,\n    }\n}\n\ndiesel::table! {\n    post_report (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        post_id -> Int4,\n        #[max_length = 200]\n        original_post_name -> Varchar,\n        original_post_url -> Nullable<Text>,\n        original_post_body -> Nullable<Text>,\n        reason -> Text,\n        resolved -> Bool,\n        resolver_id -> Nullable<Int4>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        violates_instance_rules -> Bool,\n    }\n}\n\ndiesel::table! {\n    private_message (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        recipient_id -> Int4,\n        content -> Text,\n        deleted -> Bool,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        local -> Bool,\n        removed -> Bool,\n        deleted_by_recipient -> Bool,\n    }\n}\n\ndiesel::table! {\n    private_message_report (id) {\n        id -> Int4,\n        creator_id -> Int4,\n        private_message_id -> Int4,\n        original_pm_text -> Text,\n        reason -> Text,\n        resolved -> Bool,\n        resolver_id -> Nullable<Int4>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    received_activity (ap_id) {\n        ap_id -> Text,\n        published_at -> Timestamptz,\n    }\n}\n\ndiesel::table! {\n    registration_application (id) {\n        id -> Int4,\n        local_user_id -> Int4,\n        answer -> Text,\n        admin_id -> Nullable<Int4>,\n        deny_reason -> Nullable<Text>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::table! {\n    remote_image (link) {\n        link -> Text,\n        published_at -> Timestamptz,\n    }\n}\n\ndiesel::table! {\n    report_combined (id) {\n        id -> Int4,\n        published_at -> Timestamptz,\n        post_report_id -> Nullable<Int4>,\n        comment_report_id -> Nullable<Int4>,\n        private_message_report_id -> Nullable<Int4>,\n        community_report_id -> Nullable<Int4>,\n        resolved -> Bool,\n    }\n}\n\ndiesel::table! {\n    search_combined (id) {\n        published_at -> Timestamptz,\n        score -> Int4,\n        post_id -> Nullable<Int4>,\n        comment_id -> Nullable<Int4>,\n        community_id -> Nullable<Int4>,\n        person_id -> Nullable<Int4>,\n        id -> Int4,\n        multi_community_id -> Nullable<Int4>,\n    }\n}\n\ndiesel::table! {\n    secret (id) {\n        id -> Int4,\n        jwt_secret -> Varchar,\n    }\n}\n\ndiesel::table! {\n    use diesel::sql_types::*;\n    use super::sql_types::ActorTypeEnum;\n\n    sent_activity (id) {\n        id -> Int8,\n        ap_id -> Text,\n        data -> Json,\n        sensitive -> Bool,\n        published_at -> Timestamptz,\n        send_inboxes -> Array<Nullable<Text>>,\n        send_community_followers_of -> Nullable<Int4>,\n        send_all_instances -> Bool,\n        actor_type -> ActorTypeEnum,\n        actor_apub_id -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    site (id) {\n        id -> Int4,\n        #[max_length = 20]\n        name -> Varchar,\n        sidebar -> Nullable<Text>,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n        icon -> Nullable<Text>,\n        banner -> Nullable<Text>,\n        #[max_length = 150]\n        summary -> Nullable<Varchar>,\n        #[max_length = 255]\n        ap_id -> Varchar,\n        last_refreshed_at -> Timestamptz,\n        #[max_length = 255]\n        inbox_url -> Varchar,\n        private_key -> Nullable<Text>,\n        public_key -> Text,\n        instance_id -> Int4,\n        content_warning -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    site_language (site_id, language_id) {\n        site_id -> Int4,\n        language_id -> Int4,\n    }\n}\n\ndiesel::table! {\n    tagline (id) {\n        id -> Int4,\n        content -> Text,\n        published_at -> Timestamptz,\n        updated_at -> Nullable<Timestamptz>,\n    }\n}\n\ndiesel::joinable!(comment -> language (language_id));\ndiesel::joinable!(comment -> person (creator_id));\ndiesel::joinable!(comment -> post (post_id));\ndiesel::joinable!(comment_actions -> comment (comment_id));\ndiesel::joinable!(comment_actions -> person (person_id));\ndiesel::joinable!(comment_report -> comment (comment_id));\ndiesel::joinable!(community -> instance (instance_id));\ndiesel::joinable!(community_actions -> community (community_id));\ndiesel::joinable!(community_language -> community (community_id));\ndiesel::joinable!(community_language -> language (language_id));\ndiesel::joinable!(community_report -> community (community_id));\ndiesel::joinable!(community_tag -> community (community_id));\ndiesel::joinable!(custom_emoji_keyword -> custom_emoji (custom_emoji_id));\ndiesel::joinable!(email_verification -> local_user (local_user_id));\ndiesel::joinable!(federation_allowlist -> instance (instance_id));\ndiesel::joinable!(federation_blocklist -> instance (instance_id));\ndiesel::joinable!(federation_queue_state -> instance (instance_id));\ndiesel::joinable!(instance_actions -> instance (instance_id));\ndiesel::joinable!(instance_actions -> person (person_id));\ndiesel::joinable!(local_image -> person (person_id));\ndiesel::joinable!(local_image -> post (thumbnail_for_post_id));\ndiesel::joinable!(local_site -> multi_community (suggested_multi_community_id));\ndiesel::joinable!(local_site -> person (system_account));\ndiesel::joinable!(local_site -> site (site_id));\ndiesel::joinable!(local_site_rate_limit -> local_site (local_site_id));\ndiesel::joinable!(local_user -> person (person_id));\ndiesel::joinable!(local_user_keyword_block -> local_user (local_user_id));\ndiesel::joinable!(local_user_language -> language (language_id));\ndiesel::joinable!(local_user_language -> local_user (local_user_id));\ndiesel::joinable!(login_token -> local_user (user_id));\ndiesel::joinable!(modlog -> comment (target_comment_id));\ndiesel::joinable!(modlog -> community (target_community_id));\ndiesel::joinable!(modlog -> instance (target_instance_id));\ndiesel::joinable!(modlog -> post (target_post_id));\ndiesel::joinable!(multi_community -> instance (instance_id));\ndiesel::joinable!(multi_community -> person (creator_id));\ndiesel::joinable!(multi_community_entry -> community (community_id));\ndiesel::joinable!(multi_community_entry -> multi_community (multi_community_id));\ndiesel::joinable!(multi_community_follow -> multi_community (multi_community_id));\ndiesel::joinable!(multi_community_follow -> person (person_id));\ndiesel::joinable!(notification -> comment (comment_id));\ndiesel::joinable!(notification -> modlog (modlog_id));\ndiesel::joinable!(notification -> post (post_id));\ndiesel::joinable!(notification -> private_message (private_message_id));\ndiesel::joinable!(oauth_account -> local_user (local_user_id));\ndiesel::joinable!(oauth_account -> oauth_provider (oauth_provider_id));\ndiesel::joinable!(password_reset_request -> local_user (local_user_id));\ndiesel::joinable!(person -> instance (instance_id));\ndiesel::joinable!(person_content_combined -> comment (comment_id));\ndiesel::joinable!(person_content_combined -> person (creator_id));\ndiesel::joinable!(person_content_combined -> post (post_id));\ndiesel::joinable!(person_liked_combined -> comment (comment_id));\ndiesel::joinable!(person_liked_combined -> post (post_id));\ndiesel::joinable!(person_saved_combined -> comment (comment_id));\ndiesel::joinable!(person_saved_combined -> post (post_id));\ndiesel::joinable!(post -> community (community_id));\ndiesel::joinable!(post -> language (language_id));\ndiesel::joinable!(post -> person (creator_id));\ndiesel::joinable!(post_actions -> person (person_id));\ndiesel::joinable!(post_actions -> post (post_id));\ndiesel::joinable!(post_community_tag -> community_tag (community_tag_id));\ndiesel::joinable!(post_community_tag -> post (post_id));\ndiesel::joinable!(post_report -> post (post_id));\ndiesel::joinable!(private_message_report -> private_message (private_message_id));\ndiesel::joinable!(registration_application -> local_user (local_user_id));\ndiesel::joinable!(registration_application -> person (admin_id));\ndiesel::joinable!(report_combined -> comment_report (comment_report_id));\ndiesel::joinable!(report_combined -> community_report (community_report_id));\ndiesel::joinable!(report_combined -> post_report (post_report_id));\ndiesel::joinable!(report_combined -> private_message_report (private_message_report_id));\ndiesel::joinable!(search_combined -> comment (comment_id));\ndiesel::joinable!(search_combined -> community (community_id));\ndiesel::joinable!(search_combined -> multi_community (multi_community_id));\ndiesel::joinable!(search_combined -> person (person_id));\ndiesel::joinable!(search_combined -> post (post_id));\ndiesel::joinable!(site -> instance (instance_id));\ndiesel::joinable!(site_language -> language (language_id));\ndiesel::joinable!(site_language -> site (site_id));\n\ndiesel::allow_tables_to_appear_in_same_query!(\n  comment,\n  comment_actions,\n  comment_report,\n  community,\n  community_actions,\n  community_language,\n  community_report,\n  community_tag,\n  email_verification,\n  federation_allowlist,\n  federation_blocklist,\n  federation_queue_state,\n  instance,\n  instance_actions,\n  language,\n  local_image,\n  local_site,\n  local_site_rate_limit,\n  local_user,\n  local_user_keyword_block,\n  local_user_language,\n  login_token,\n  modlog,\n  multi_community,\n  multi_community_entry,\n  multi_community_follow,\n  notification,\n  oauth_account,\n  oauth_provider,\n  password_reset_request,\n  person,\n  person_content_combined,\n  person_liked_combined,\n  person_saved_combined,\n  post,\n  post_actions,\n  post_community_tag,\n  post_report,\n  private_message,\n  private_message_report,\n  registration_application,\n  report_combined,\n  search_combined,\n  site,\n  site_language,\n  person_actions,\n  image_details,\n);\ndiesel::allow_tables_to_appear_in_same_query!(custom_emoji, custom_emoji_keyword,);\n"
  },
  {
    "path": "crates/db_schema_file/src/table_impls.rs",
    "content": "use crate::schema::{\n  comment_actions,\n  community_actions,\n  instance_actions,\n  person_actions,\n  post_actions,\n};\n\nimpl diesel_uplete::SupportedTable for comment_actions::table {\n  type Key = (comment_actions::person_id, comment_actions::comment_id);\n  type AdditionalIgnoredColumns = ();\n}\n\nimpl diesel_uplete::SupportedTable for community_actions::table {\n  type Key = (\n    community_actions::person_id,\n    community_actions::community_id,\n  );\n  type AdditionalIgnoredColumns = ();\n}\n\nimpl diesel_uplete::SupportedTable for instance_actions::table {\n  type Key = (instance_actions::person_id, instance_actions::instance_id);\n  type AdditionalIgnoredColumns = ();\n}\n\nimpl diesel_uplete::SupportedTable for person_actions::table {\n  type Key = (person_actions::person_id, person_actions::target_id);\n  type AdditionalIgnoredColumns = ();\n}\n\nimpl diesel_uplete::SupportedTable for post_actions::table {\n  type Key = (post_actions::person_id, post_actions::post_id);\n  type AdditionalIgnoredColumns = ();\n}\n"
  },
  {
    "path": "crates/db_views/comment/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_comment\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"diesel_ltree\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\", \"lemmy_db_schema_file/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\ndiesel_ltree = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\nlemmy_db_views_local_user = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/comment/src/api.rs",
    "content": "use crate::CommentView;\nuse lemmy_db_schema::newtypes::{CommentId, CommunityId, LanguageId, PostId};\nuse lemmy_db_schema_file::enums::{CommentSortType, ListingType};\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment response.\npub struct CommentResponse {\n  pub comment_view: CommentView,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a comment.\npub struct CreateComment {\n  pub content: String,\n  pub post_id: PostId,\n  pub parent_id: Option<CommentId>,\n  pub language_id: Option<LanguageId>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Like a comment.\npub struct CreateCommentLike {\n  pub comment_id: CommentId,\n  /// True means Upvote, False means Downvote, and None means remove vote.\n  pub is_upvote: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete your own comment.\npub struct DeleteComment {\n  pub comment_id: CommentId,\n  pub deleted: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Distinguish a comment (IE speak as moderator).\npub struct DistinguishComment {\n  pub comment_id: CommentId,\n  pub distinguished: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, Copy, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetch an individual comment.\npub struct GetComment {\n  pub id: CommentId,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Get a list of comments.\npub struct GetComments {\n  pub type_: Option<ListingType>,\n  pub sort: Option<CommentSortType>,\n  /// Filter to within a given time range, in seconds.\n  /// IE 60 would give results for the past minute.\n  pub time_range_seconds: Option<i32>,\n  pub max_depth: Option<i32>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub community_id: Option<CommunityId>,\n  pub community_name: Option<String>,\n  pub post_id: Option<PostId>,\n  pub parent_id: Option<CommentId>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// List comment likes. Admins-only.\npub struct ListCommentLikes {\n  pub comment_id: CommentId,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Locks a comment and its children, IE prevents new replies.\npub struct LockComment {\n  pub comment_id: CommentId,\n  pub locked: bool,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Purges a comment from the database. This will delete all content attached to that comment.\npub struct PurgeComment {\n  pub comment_id: CommentId,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Remove a comment (only doable by mods).\npub struct RemoveComment {\n  pub comment_id: CommentId,\n  pub removed: bool,\n  pub reason: String,\n  /// Setting this will override whatever `removed` was set to,\n  /// leave as null or unset to act just on the comment itself.\n  pub remove_children: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Save / bookmark a comment.\npub struct SaveComment {\n  pub comment_id: CommentId,\n  pub save: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit a comment.\npub struct EditComment {\n  pub comment_id: CommentId,\n  pub content: Option<String>,\n  pub language_id: Option<LanguageId>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Creates a warning against a comment and notifies the user.\npub struct CreateCommentWarning {\n  pub comment_id: CommentId,\n  pub reason: String,\n}\n"
  },
  {
    "path": "crates/db_views/comment/src/impls.rs",
    "content": "use crate::{CommentSlimView, CommentView};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  dsl::exists,\n};\nuse diesel_async::RunQueryDsl;\nuse diesel_ltree::{Ltree, LtreeExtensions, nlevel};\nuse i_love_jesus::asc_if;\nuse lemmy_db_schema::{\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::{CommentId, CommunityId, PostId},\n  source::{\n    comment::{Comment, comment_keys as key},\n    local_user::LocalUser,\n    site::Site,\n  },\n  utils::{\n    limit_fetch,\n    queries::filters::{filter_blocked, filter_suggested_communities},\n  },\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{\n    CommentSortType::{self, *},\n    CommunityFollowerState,\n    CommunityVisibility,\n    ListingType,\n  },\n  joins::{\n    creator_community_actions_join,\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_instance_communities_actions_join,\n    my_instance_persons_actions_join_1,\n    my_local_user_admin_join,\n    my_person_actions_join,\n  },\n  schema::{comment, community, community_actions, local_user_language, person, post},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n  utils::{Subpath, now, seconds_to_pg_interval},\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl PaginationCursorConversion for CommentView {\n  type PaginatedType = Comment;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.comment.id.0)\n  }\n\n  async fn from_cursor(\n    data: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Comment::read(pool, CommentId(data.id()?)).await\n  }\n}\n\nimpl CommentView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let community_join = community::table.on(post::community_id.eq(community::id));\n\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(my_person_id);\n    let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id);\n    let my_instance_communities_actions_join: my_instance_communities_actions_join =\n      my_instance_communities_actions_join(my_person_id);\n    let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 =\n      my_instance_persons_actions_join_1(my_person_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    comment::table\n      .inner_join(person::table)\n      .inner_join(post::table)\n      .inner_join(community_join)\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_community_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(my_community_actions_join)\n      .left_join(my_comment_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_local_user_admin_join)\n      .left_join(my_instance_communities_actions_join)\n      .left_join(my_instance_persons_actions_join_1)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    comment_id: CommentId,\n    my_local_user: Option<&'_ LocalUser>,\n    local_instance_id: InstanceId,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = Self::joins(my_local_user.person_id(), local_instance_id)\n      .filter(comment::id.eq(comment_id))\n      .select(Self::as_select())\n      .into_boxed();\n\n    query = my_local_user.visible_communities_only(query);\n\n    // Check permissions to view private community content.\n    // Specifically, if the community is private then only accepted followers may view its\n    // content, otherwise it is filtered out. Admins can view private community content\n    // without restriction.\n    if !my_local_user.is_admin() {\n      query = query.filter(\n        community::visibility\n          .ne(CommunityVisibility::Private)\n          .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n      );\n    }\n\n    query\n      .first::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub fn map_to_slim(self) -> CommentSlimView {\n    CommentSlimView {\n      comment: self.comment,\n      creator: self.creator,\n      comment_actions: self.comment_actions,\n      person_actions: self.person_actions,\n      creator_is_admin: self.creator_is_admin,\n      can_mod: self.can_mod,\n      creator_banned: self.creator_banned,\n      creator_banned_from_community: self.creator_banned_from_community,\n      creator_is_moderator: self.creator_is_moderator,\n    }\n  }\n}\n\n#[derive(Default)]\npub struct CommentQuery<'a> {\n  pub listing_type: Option<ListingType>,\n  pub sort: Option<CommentSortType>,\n  pub time_range_seconds: Option<i32>,\n  pub community_id: Option<CommunityId>,\n  pub post_id: Option<PostId>,\n  pub parent_path: Option<Ltree>,\n  pub local_user: Option<&'a LocalUser>,\n  pub max_depth: Option<i32>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\nimpl CommentQuery<'_> {\n  pub async fn list(\n    self,\n    site: &Site,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<PagedResponse<CommentView>> {\n    let o = self;\n\n    // The left join below will return None in this case\n    let my_person_id = o.local_user.person_id();\n    let local_user_id = o.local_user.local_user_id();\n\n    let mut query = CommentView::joins(my_person_id, site.instance_id)\n      .select(CommentView::as_select())\n      .into_boxed();\n\n    if let Some(post_id) = o.post_id {\n      query = query.filter(comment::post_id.eq(post_id));\n    };\n\n    if let Some(parent_path) = o.parent_path.as_ref() {\n      query = query.filter(comment::path.contained_by(parent_path));\n    };\n\n    if let Some(community_id) = o.community_id {\n      query = query.filter(post::community_id.eq(community_id));\n    }\n\n    let is_subscribed = community_actions::followed_at.is_not_null();\n\n    // For posts, we only show hidden if its subscribed, but for comments,\n    // we ignore hidden.\n    query = match o.listing_type.unwrap_or_default() {\n      ListingType::Subscribed => query.filter(is_subscribed),\n      ListingType::Local => query.filter(community::local.eq(true)),\n      ListingType::All => query,\n      ListingType::ModeratorView => {\n        query.filter(community_actions::became_moderator_at.is_not_null())\n      }\n      ListingType::Suggested => query.filter(filter_suggested_communities()),\n    };\n\n    if !o.local_user.show_bot_accounts() {\n      query = query.filter(person::bot_account.eq(false));\n    };\n\n    if o.local_user.is_some() && o.listing_type.unwrap_or_default() != ListingType::ModeratorView {\n      // Filter out the rows with missing languages\n      query = query.filter(exists(\n        local_user_language::table.filter(\n          comment::language_id\n            .eq(local_user_language::language_id)\n            .and(\n              local_user_language::local_user_id\n                .nullable()\n                .eq(local_user_id),\n            ),\n        ),\n      ));\n\n      query = query.filter(filter_blocked());\n    };\n\n    if !o.local_user.show_nsfw(site) {\n      query = query\n        .filter(post::nsfw.eq(false))\n        .filter(community::nsfw.eq(false));\n    };\n\n    query = o.local_user.visible_communities_only(query);\n    query = query.filter(\n      comment::federation_pending\n        .eq(false)\n        .or(comment::creator_id.nullable().eq(my_person_id)),\n    );\n\n    if !o.local_user.is_admin() {\n      query = query.filter(\n        community::visibility\n          .ne(CommunityVisibility::Private)\n          .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n      );\n    }\n\n    // Filter by the time range\n    if let Some(time_range_seconds) = o.time_range_seconds {\n      query =\n        query.filter(comment::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)));\n    }\n\n    // A Max depth given means its a tree fetch\n    let limit = if let Some(max_depth) = o.max_depth {\n      let depth_limit = if let Some(parent_path) = o.parent_path.as_ref() {\n        let count: i32 = parent_path.0.split('.').count().try_into()?;\n        count + max_depth\n        // Add one because of root \"0\"\n      } else {\n        max_depth + 1\n      };\n\n      query = query.filter(nlevel(comment::path).le(depth_limit));\n\n      // TODO limit question. Limiting does not work for comment threads ATM, only max_depth\n      // For now, don't do any limiting for tree fetches\n      // https://stackoverflow.com/questions/72983614/postgres-ltree-how-to-limit-the-max-number-of-children-at-any-given-level\n\n      // Don't use the regular error-checking one, many more comments must ofter be fetched.\n      // This does not work for comment trees, and the limit should be manually set to a high number\n      //\n      // If a max depth is given, then you know its a tree fetch, and limits should be ignored\n      // TODO a kludge to prevent attacks. Limit comments to 300 for now.\n      // (i64::MAX, 0)\n      300\n    } else {\n      limit_fetch(o.limit, None)?\n    };\n    query = query.limit(limit);\n\n    // Only sort by ascending for Old\n    let sort = o.sort.unwrap_or(Hot);\n    let sort_direction = asc_if(sort == Old);\n\n    let mut pq = CommentView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?;\n\n    // Order by a subpath for max depth queries\n    // Only order if filtering by a post id, or parent_path. DOS potential otherwise and max_depth\n    // + !post_id isn't used anyways (afaik)\n    if o.max_depth.is_some() && (o.post_id.is_some() || o.parent_path.is_some()) {\n      // Always order by the parent path first\n      pq = pq.then_order_by(Subpath(key::path));\n    }\n\n    // Distinguished comments should go first when viewing post\n    // Don't do for new / old sorts\n    if sort != New && sort != Old && (o.post_id.is_some() || o.parent_path.is_some()) {\n      pq = pq.then_order_by(key::distinguished);\n    }\n\n    pq = match sort {\n      Hot => pq.then_order_by(key::hot_rank).then_order_by(key::score),\n      Controversial => pq.then_order_by(key::controversy_rank),\n      Old | New => pq.then_order_by(key::published_at),\n      Top => pq.then_order_by(key::score),\n    };\n\n    let conn = &mut get_conn(pool).await?;\n    let res = pq.load::<CommentView>(conn).await?;\n\n    paginate_response(res, limit, o.page_cursor)\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use super::*;\n  use crate::{CommentView, impls::CommentQuery};\n  use lemmy_db_schema::{\n    assert_length,\n    impls::actor_language::UNDETERMINED_ID,\n    newtypes::CommentId,\n    source::{\n      actor_language::LocalUserLanguage,\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm},\n      community::{\n        Community,\n        CommunityActions,\n        CommunityFollowerForm,\n        CommunityInsertForm,\n        CommunityModeratorForm,\n        CommunityPersonBanForm,\n        CommunityUpdateForm,\n      },\n      instance::Instance,\n      language::Language,\n      local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n      person::{Person, PersonActions, PersonBlockForm, PersonInsertForm},\n      post::{Post, PostInsertForm, PostUpdateForm},\n      site::{Site, SiteInsertForm},\n    },\n    traits::{Bannable, Blockable, Followable, Likeable},\n  };\n  use lemmy_db_views_local_user::LocalUserView;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  // TODO rename these\n  struct Data {\n    instance: Instance,\n    comment_0: Comment,\n    comment_1: Comment,\n    comment_2: Comment,\n    _comment_5: Comment,\n    post: Post,\n    timmy_local_user_view: LocalUserView,\n    sara_person: Person,\n    community: Community,\n    site: Site,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    Instance::read_all(pool).await?;\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_person_form = PersonInsertForm::test_form(inserted_instance.id, \"timmy\");\n    let inserted_timmy_person = Person::create(pool, &timmy_person_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form_admin(inserted_timmy_person.id);\n\n    let inserted_timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n\n    let sara_person_form = PersonInsertForm::test_form(inserted_instance.id, \"sara\");\n    let sara_person = Person::create(pool, &sara_person_form).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community 5\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post 2\".into(),\n      inserted_timmy_person.id,\n      community.id,\n    );\n    let post = Post::create(pool, &new_post).await?;\n    let english_id = Language::read_id_from_code(pool, \"en\").await?;\n\n    // Create a comment tree with this hierarchy\n    //       0\n    //     \\     \\\n    //    1      2\n    //    \\\n    //  3  4\n    //     \\\n    //     5\n    let comment_form_0 = CommentInsertForm {\n      language_id: Some(english_id),\n      ..CommentInsertForm::new(inserted_timmy_person.id, post.id, \"Comment 0\".into())\n    };\n\n    let comment_0 = Comment::create(pool, &comment_form_0, None).await?;\n\n    let comment_form_1 = CommentInsertForm {\n      language_id: Some(english_id),\n      ..CommentInsertForm::new(sara_person.id, post.id, \"Comment 1\".into())\n    };\n    let comment_1 = Comment::create(pool, &comment_form_1, Some(&comment_0.path)).await?;\n\n    let finnish_id = Language::read_id_from_code(pool, \"fi\").await?;\n    let comment_form_2 = CommentInsertForm {\n      language_id: Some(finnish_id),\n      ..CommentInsertForm::new(inserted_timmy_person.id, post.id, \"Comment 2\".into())\n    };\n\n    let comment_2 = Comment::create(pool, &comment_form_2, Some(&comment_0.path)).await?;\n\n    let comment_form_3 = CommentInsertForm {\n      language_id: Some(english_id),\n      ..CommentInsertForm::new(inserted_timmy_person.id, post.id, \"Comment 3\".into())\n    };\n    let _inserted_comment_3 = Comment::create(pool, &comment_form_3, Some(&comment_1.path)).await?;\n\n    let polish_id = Language::read_id_from_code(pool, \"pl\").await?;\n    let comment_form_4 = CommentInsertForm {\n      language_id: Some(polish_id),\n      ..CommentInsertForm::new(inserted_timmy_person.id, post.id, \"Comment 4\".into())\n    };\n\n    let inserted_comment_4 = Comment::create(pool, &comment_form_4, Some(&comment_1.path)).await?;\n\n    let comment_form_5 =\n      CommentInsertForm::new(inserted_timmy_person.id, post.id, \"Comment 5\".into());\n    let _comment_5 = Comment::create(pool, &comment_form_5, Some(&inserted_comment_4.path)).await?;\n\n    let timmy_blocks_sara_form = PersonBlockForm::new(inserted_timmy_person.id, sara_person.id);\n    let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?;\n\n    assert_eq!(\n      (inserted_timmy_person.id, sara_person.id, true),\n      (\n        inserted_block.person_id,\n        inserted_block.target_id,\n        inserted_block.blocked_at.is_some()\n      )\n    );\n\n    let comment_like_form =\n      CommentLikeForm::new(comment_0.id, inserted_timmy_person.id, Some(true));\n\n    CommentActions::like(pool, &comment_like_form).await?;\n\n    let timmy_local_user_view = LocalUserView {\n      local_user: inserted_timmy_local_user.clone(),\n      person: inserted_timmy_person.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n    let site_form = SiteInsertForm::new(\"test site\".to_string(), inserted_instance.id);\n    let site = Site::create(pool, &site_form).await?;\n    Ok(Data {\n      instance: inserted_instance,\n      comment_0,\n      comment_1,\n      comment_2,\n      _comment_5,\n      post,\n      timmy_local_user_view,\n      sara_person,\n      community,\n      site,\n    })\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let read_comment_views_no_person = CommentQuery {\n      sort: (Some(CommentSortType::Old)),\n      post_id: (Some(data.post.id)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    assert!(read_comment_views_no_person[0].comment_actions.is_none());\n    assert!(!read_comment_views_no_person[0].can_mod);\n\n    let read_comment_views_with_person = CommentQuery {\n      sort: (Some(CommentSortType::Old)),\n      post_id: (Some(data.post.id)),\n      local_user: (Some(&data.timmy_local_user_view.local_user)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    assert!(\n      read_comment_views_with_person[0]\n        .comment_actions\n        .as_ref()\n        .is_some_and(|x| x.vote_is_upvote == Some(true))\n    );\n    assert!(read_comment_views_with_person[0].can_mod);\n\n    // Make sure its 1, not showing the blocked comment\n    assert_length!(5, read_comment_views_with_person);\n\n    let read_comment_from_blocked_person = CommentView::read(\n      pool,\n      data.comment_1.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await?;\n\n    // Make sure block set the creator blocked\n    assert!(\n      read_comment_from_blocked_person\n        .person_actions\n        .is_some_and(|x| x.blocked_at.is_some())\n    );\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_comment_tree() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let top_path = data.comment_0.path.clone();\n    let read_comment_views_top_path = CommentQuery {\n      post_id: (Some(data.post.id)),\n      parent_path: (Some(top_path)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    let child_path = data.comment_1.path.clone();\n    let read_comment_views_child_path = CommentQuery {\n      post_id: (Some(data.post.id)),\n      parent_path: (Some(child_path)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    // Make sure the comment parent-limited fetch is correct\n    assert_length!(6, read_comment_views_top_path);\n    assert_length!(4, read_comment_views_child_path);\n\n    // Make sure it contains the parent, but not the comment from the other tree\n    let child_comments = read_comment_views_child_path\n      .iter()\n      .map(|c| c.comment.id)\n      .collect::<Vec<CommentId>>();\n    assert!(child_comments.contains(&data.comment_1.id));\n    assert!(!child_comments.contains(&data.comment_2.id));\n\n    let read_comment_views_top_max_depth = CommentQuery {\n      post_id: (Some(data.post.id)),\n      max_depth: (Some(1)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    // Make sure a depth limited one only has the top comment\n    assert_length!(1, read_comment_views_top_max_depth);\n\n    let child_path = data.comment_1.path.clone();\n    let read_comment_views_parent_max_depth = CommentQuery {\n      post_id: (Some(data.post.id)),\n      parent_path: (Some(child_path)),\n      max_depth: (Some(1)),\n      sort: (Some(CommentSortType::Old)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    // Make sure a depth limited one, and given child comment 1, has 3\n    // 1, 3, 4\n    assert_eq!(\n      vec![\"Comment 1\", \"Comment 3\", \"Comment 4\"],\n      read_comment_views_parent_max_depth\n        .iter()\n        .map(|r| r.comment.content.as_str())\n        .collect::<Vec<&str>>()\n    );\n    assert!(\n      read_comment_views_parent_max_depth[1]\n        .comment\n        .content\n        .eq(\"Comment 3\")\n    );\n    assert_length!(3, read_comment_views_parent_max_depth);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_languages() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // by default, user has all languages enabled and should see all comments\n    // (except from blocked user)\n    let all_languages = CommentQuery {\n      local_user: (Some(&data.timmy_local_user_view.local_user)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_length!(5, all_languages);\n\n    // change user lang to finnish, should only show one post in finnish and one undetermined\n    let finnish_id = Language::read_id_from_code(pool, \"fi\").await?;\n    LocalUserLanguage::update(\n      pool,\n      vec![finnish_id],\n      data.timmy_local_user_view.local_user.id,\n    )\n    .await?;\n    let finnish_comments = CommentQuery {\n      local_user: (Some(&data.timmy_local_user_view.local_user)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_length!(1, finnish_comments);\n    let finnish_comment = finnish_comments\n      .iter()\n      .find(|c| c.comment.language_id == finnish_id);\n    assert!(finnish_comment.is_some());\n    assert_eq!(\n      Some(&data.comment_2.content),\n      finnish_comment.map(|c| &c.comment.content)\n    );\n\n    // now show all comments with undetermined language (which is the default value)\n    LocalUserLanguage::update(\n      pool,\n      vec![UNDETERMINED_ID],\n      data.timmy_local_user_view.local_user.id,\n    )\n    .await?;\n    let undetermined_comment = CommentQuery {\n      local_user: (Some(&data.timmy_local_user_view.local_user)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_length!(1, undetermined_comment);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_distinguished_first() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form = CommentUpdateForm {\n      distinguished: Some(true),\n      ..Default::default()\n    };\n    Comment::update(pool, data.comment_2.id, &form).await?;\n\n    let comments = CommentQuery {\n      post_id: Some(data.comment_2.post_id),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(comments[0].comment.id, data.comment_2.id);\n    assert!(comments[0].comment.distinguished);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_creator_is_moderator() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Make one of the inserted persons a moderator\n    let person_id = data.sara_person.id;\n    let community_id = data.community.id;\n    let form = CommunityModeratorForm::new(community_id, person_id);\n    CommunityActions::join(pool, &form).await?;\n\n    // Make sure that they come back as a mod in the list\n    let comments = CommentQuery {\n      sort: (Some(CommentSortType::Old)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    assert_eq!(comments[1].creator.name, \"sara\");\n    assert!(comments[1].creator_is_moderator);\n\n    assert!(!comments[0].creator_is_moderator);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_creator_is_admin() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let comments = CommentQuery {\n      sort: (Some(CommentSortType::Old)),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    // Timmy is an admin, and make sure that field is true\n    assert_eq!(comments[0].creator.name, \"timmy\");\n    assert!(comments[0].creator_is_admin);\n\n    // Sara isn't, make sure its false\n    assert_eq!(comments[1].creator.name, \"sara\");\n    assert!(!comments[1].creator_is_admin);\n\n    cleanup(data, pool).await\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Community::delete(pool, data.community.id).await?;\n    Person::delete(pool, data.timmy_local_user_view.person.id).await?;\n    LocalUser::delete(pool, data.timmy_local_user_view.local_user.id).await?;\n    Person::delete(pool, data.sara_person.id).await?;\n    Instance::delete(pool, data.instance.id).await?;\n    Site::delete(pool, data.site.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn local_only_instance() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    Community::update(\n      pool,\n      data.community.id,\n      &CommunityUpdateForm {\n        visibility: Some(CommunityVisibility::LocalOnlyPrivate),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let unauthenticated_query = CommentQuery {\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(0, unauthenticated_query.len());\n\n    let authenticated_query = CommentQuery {\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(5, authenticated_query.len());\n\n    let unauthenticated_comment =\n      CommentView::read(pool, data.comment_0.id, None, data.instance.id).await;\n    assert!(unauthenticated_comment.is_err());\n\n    let authenticated_comment = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await;\n    assert!(authenticated_comment.is_ok());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_listing_local_user_banned_from_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Test that comment view shows if local user is blocked from community\n    let banned_from_comm_person = PersonInsertForm::test_form(data.instance.id, \"jill\");\n\n    let inserted_banned_from_comm_person = Person::create(pool, &banned_from_comm_person).await?;\n\n    let inserted_banned_from_comm_local_user = LocalUser::create(\n      pool,\n      &LocalUserInsertForm::test_form(inserted_banned_from_comm_person.id),\n      vec![],\n    )\n    .await?;\n\n    CommunityActions::ban(\n      pool,\n      &CommunityPersonBanForm::new(data.community.id, inserted_banned_from_comm_person.id),\n    )\n    .await?;\n\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&inserted_banned_from_comm_local_user),\n      data.instance.id,\n    )\n    .await?;\n\n    assert!(\n      comment_view\n        .community_actions\n        .is_some_and(|x| x.received_ban_at.is_some())\n    );\n\n    Person::delete(pool, inserted_banned_from_comm_person.id).await?;\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_listing_local_user_not_banned_from_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await?;\n\n    assert!(comment_view.community_actions.is_none());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_listings_hide_nsfw() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Mark a post as nsfw\n    let update_form = PostUpdateForm {\n      nsfw: Some(true),\n      ..Default::default()\n    };\n    Post::update(pool, data.post.id, &update_form).await?;\n\n    // Make sure comments of this post are not returned\n    let comments = CommentQuery::default().list(&data.site, pool).await?;\n    assert_eq!(0, comments.len());\n\n    // Mark site as nsfw\n    let mut site = data.site.clone();\n    site.content_warning = Some(\"nsfw\".to_string());\n\n    // Now comments of nsfw post are returned\n    let comments = CommentQuery::default().list(&site, pool).await?;\n    assert_eq!(6, comments.len());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_listing_private_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let mut data = init_data(pool).await?;\n\n    // Mark community as private\n    Community::update(\n      pool,\n      data.community.id,\n      &CommunityUpdateForm {\n        visibility: Some(CommunityVisibility::Private),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    // No comments returned without auth\n    let read_comment_listing = CommentQuery::default().list(&data.site, pool).await?;\n    assert_eq!(0, read_comment_listing.len());\n    let comment_view = CommentView::read(pool, data.comment_0.id, None, data.instance.id).await;\n    assert!(comment_view.is_err());\n\n    // No comments returned for non-follower who is not admin\n    data.timmy_local_user_view.local_user.admin = false;\n    let read_comment_listing = CommentQuery {\n      community_id: Some(data.community.id),\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(0, read_comment_listing.len());\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await;\n    assert!(comment_view.is_err());\n\n    // Admin can view content without following\n    data.timmy_local_user_view.local_user.admin = true;\n    let read_comment_listing = CommentQuery {\n      community_id: Some(data.community.id),\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(5, read_comment_listing.len());\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await;\n    assert!(comment_view.is_ok());\n    data.timmy_local_user_view.local_user.admin = false;\n\n    // User can view after following\n    CommunityActions::follow(\n      pool,\n      &CommunityFollowerForm::new(\n        data.community.id,\n        data.timmy_local_user_view.person.id,\n        CommunityFollowerState::Accepted,\n      ),\n    )\n    .await?;\n    let read_comment_listing = CommentQuery {\n      community_id: Some(data.community.id),\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(5, read_comment_listing.len());\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await;\n    assert!(comment_view.is_ok());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_removed() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let mut data = init_data(pool).await?;\n\n    // Mark a comment as removed\n    let form = CommentUpdateForm {\n      removed: Some(true),\n      ..Default::default()\n    };\n    Comment::update(pool, data.comment_0.id, &form).await?;\n\n    // Read as normal user, content is cleared\n    // Timmy leaves admin\n    LocalUser::update(\n      pool,\n      data.timmy_local_user_view.local_user.id,\n      &LocalUserUpdateForm {\n        admin: Some(false),\n        ..Default::default()\n      },\n    )\n    .await?;\n    data.timmy_local_user_view.local_user.admin = false;\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await?;\n    assert_eq!(\"\", comment_view.comment.content);\n    let comment_listing = CommentQuery {\n      community_id: Some(data.community.id),\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      sort: Some(CommentSortType::Old),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(\"\", comment_listing[0].comment.content);\n\n    // Read as admin, content is returned\n    LocalUser::update(\n      pool,\n      data.timmy_local_user_view.local_user.id,\n      &LocalUserUpdateForm {\n        admin: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n    data.timmy_local_user_view.local_user.admin = true;\n    let comment_view = CommentView::read(\n      pool,\n      data.comment_0.id,\n      Some(&data.timmy_local_user_view.local_user),\n      data.instance.id,\n    )\n    .await?;\n    assert_eq!(data.comment_0.content, comment_view.comment.content);\n    let comment_listing = CommentQuery {\n      community_id: Some(data.community.id),\n      local_user: Some(&data.timmy_local_user_view.local_user),\n      sort: Some(CommentSortType::Old),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(data.comment_0.content, comment_listing[0].comment.content);\n\n    cleanup(data, pool).await\n  }\n}\n"
  },
  {
    "path": "crates/db_views/comment/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::{\n  comment::{Comment, CommentActions},\n  community::{Community, CommunityActions},\n  community_tag::CommunityTagsView,\n  person::{Person, PersonActions},\n  post::Post,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeCommunityBanExpiresType,\n    comment_creator_is_admin,\n    comment_select_remove_deletes,\n    creator_ban_expires_from_community,\n    creator_banned_from_community,\n    creator_is_moderator,\n    creator_local_home_community_ban_expires,\n    creator_local_home_community_banned,\n    local_user_can_mod_comment,\n    post_community_tags_fragment,\n  },\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment view.\npub struct CommentView {\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = comment_select_remove_deletes()\n    )\n  )]\n  pub comment: Comment,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub post: Post,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community_actions: Option<CommunityActions>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub comment_actions: Option<CommentActions>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person_actions: Option<PersonActions>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = comment_creator_is_admin()\n    )\n  )]\n  pub creator_is_admin: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = post_community_tags_fragment()\n    )\n  )]\n  pub tags: CommunityTagsView,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = local_user_can_mod_comment()\n    )\n  )]\n  pub can_mod: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_local_home_community_banned()\n    )\n  )]\n  pub creator_banned: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = CreatorLocalHomeCommunityBanExpiresType,\n      select_expression = creator_local_home_community_ban_expires()\n     )\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_is_moderator()\n    )\n  )]\n  pub creator_is_moderator: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_banned_from_community()\n    )\n  )]\n  pub creator_banned_from_community: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_ban_expires_from_community()\n    )\n  )]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A slimmer comment view, without the post, or community.\npub struct CommentSlimView {\n  pub comment: Comment,\n  pub creator: Person,\n  pub comment_actions: Option<CommentActions>,\n  pub person_actions: Option<PersonActions>,\n  pub creator_is_admin: bool,\n  pub can_mod: bool,\n  pub creator_banned: bool,\n  pub creator_is_moderator: bool,\n  pub creator_banned_from_community: bool,\n}\n"
  },
  {
    "path": "crates/db_views/community/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_community\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_community_moderator/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_schema_file/ts-rs\",\n  \"lemmy_db_views_community_moderator/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_community_moderator = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\nurl = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/community/src/api.rs",
    "content": "use crate::{CommunityView, MultiCommunityView};\nuse lemmy_db_schema::{\n  CommunitySortType,\n  MultiCommunityListingType,\n  MultiCommunitySortType,\n  newtypes::{CommunityId, CommunityTagId, LanguageId, MultiCommunityId},\n  source::site::Site,\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::{CommunityNotificationsMode, CommunityVisibility, ListingType, TagColor},\n};\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Add a moderator to a community.\npub struct AddModToCommunity {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  pub added: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The response of adding a moderator to a community.\npub struct AddModToCommunityResponse {\n  pub moderators: Vec<CommunityModeratorView>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ApproveCommunityPendingFollower {\n  pub community_id: CommunityId,\n  pub follower_id: PersonId,\n  pub approve: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Ban a user from a community.\npub struct BanFromCommunity {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n  pub ban: bool,\n  /// Optionally remove or restore all their data. Useful for new troll accounts.\n  /// If ban is true, then this means remove. If ban is false, it means restore.\n  pub remove_or_restore_data: Option<bool>,\n  pub reason: String,\n  /// A time that the ban will expire, in unix epoch seconds.\n  ///\n  /// An i64 unix timestamp is used for a simpler API client implementation.\n  pub expires_at: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Block a community.\npub struct BlockCommunity {\n  pub community_id: CommunityId,\n  pub block: bool,\n}\n\n/// Parameter for setting community icon or banner. Can't use POST data here as it already contains\n/// the image data.\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CommunityIdQuery {\n  pub id: CommunityId,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A simple community response.\npub struct CommunityResponse {\n  pub community_view: CommunityView,\n  pub discussion_languages: Vec<LanguageId>,\n}\n\n#[skip_serializing_none]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n/// Create a community.\npub struct CreateCommunity {\n  /// The unique name.\n  pub name: String,\n  /// A longer title.\n  pub title: String,\n  /// A sidebar for the community in markdown.\n  pub sidebar: Option<String>,\n  /// A shorter, one line summary of your community.\n  pub summary: Option<String>,\n  /// An icon URL.\n  pub icon: Option<String>,\n  /// A banner URL.\n  pub banner: Option<String>,\n  /// Whether its an NSFW community.\n  pub nsfw: Option<bool>,\n  /// Whether to restrict posting only to moderators.\n  pub posting_restricted_to_mods: Option<bool>,\n  pub discussion_languages: Option<Vec<LanguageId>>,\n  pub visibility: Option<CommunityVisibility>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete your own community.\npub struct DeleteCommunity {\n  pub community_id: CommunityId,\n  pub deleted: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit a community.\npub struct EditCommunity {\n  pub community_id: CommunityId,\n  /// A longer title.\n  pub title: Option<String>,\n  /// A sidebar for the community in markdown.\n  pub sidebar: Option<String>,\n  /// A shorter, one line summary of your community.\n  pub summary: Option<String>,\n  /// Whether its an NSFW community.\n  pub nsfw: Option<bool>,\n  /// Whether to restrict posting only to moderators.\n  pub posting_restricted_to_mods: Option<bool>,\n  pub discussion_languages: Option<Vec<LanguageId>>,\n  pub visibility: Option<CommunityVisibility>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Follow / subscribe to a community.\npub struct FollowCommunity {\n  pub community_id: CommunityId,\n  pub follow: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n// TODO make this into a tagged enum\n/// Get a community. Must provide either an id, or a name.\npub struct GetCommunity {\n  pub id: Option<CommunityId>,\n  /// Example: star_trek , or star_trek@xyz.tld\n  pub name: Option<String>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The community response.\npub struct GetCommunityResponse {\n  pub community_view: CommunityView,\n  pub site: Option<Site>,\n  pub moderators: Vec<CommunityModeratorView>,\n  pub discussion_languages: Vec<LanguageId>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches a random community\npub struct GetRandomCommunity {\n  pub type_: Option<ListingType>,\n  pub show_nsfw: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Hide a community from the main view.\npub struct HideCommunity {\n  pub community_id: CommunityId,\n  pub hidden: bool,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches a list of communities.\npub struct ListCommunities {\n  pub type_: Option<ListingType>,\n  pub sort: Option<CommunitySortType>,\n  /// Filter to within a given time range, in seconds.\n  /// IE 60 would give results for the past minute.\n  pub time_range_seconds: Option<i32>,\n  pub show_nsfw: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Purges a community from the database. This will delete all content attached to that community.\npub struct PurgeCommunity {\n  pub community_id: CommunityId,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Remove a community (only doable by moderators).\npub struct RemoveCommunity {\n  pub community_id: CommunityId,\n  pub removed: bool,\n  pub reason: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Transfer a community to a new owner.\npub struct TransferCommunity {\n  pub community_id: CommunityId,\n  pub person_id: PersonId,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CreateMultiCommunity {\n  pub name: String,\n  pub title: Option<String>,\n  pub summary: Option<String>,\n  pub sidebar: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct EditMultiCommunity {\n  pub id: MultiCommunityId,\n  pub title: Option<String>,\n  pub summary: Option<String>,\n  pub sidebar: Option<String>,\n  pub deleted: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct CreateOrDeleteMultiCommunityEntry {\n  pub id: MultiCommunityId,\n  pub community_id: CommunityId,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ListMultiCommunities {\n  pub type_: Option<MultiCommunityListingType>,\n  pub sort: Option<MultiCommunitySortType>,\n  pub creator_id: Option<PersonId>,\n  /// Filter to within a given time range, in seconds.\n  /// IE 60 would give results for the past minute.\n  pub time_range_seconds: Option<i32>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct GetMultiCommunity {\n  pub id: Option<MultiCommunityId>,\n  pub name: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct GetMultiCommunityResponse {\n  pub multi_community_view: MultiCommunityView,\n  pub communities: Vec<CommunityView>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunityResponse {\n  pub multi_community_view: MultiCommunityView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct FollowMultiCommunity {\n  pub multi_community_id: MultiCommunityId,\n  pub follow: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Change notification settings for a community\npub struct EditCommunityNotifications {\n  pub community_id: CommunityId,\n  pub mode: CommunityNotificationsMode,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a tag for a community.\npub struct CreateCommunityTag {\n  pub community_id: CommunityId,\n  pub name: String,\n  pub display_name: Option<String>,\n  pub summary: Option<String>,\n  pub color: Option<TagColor>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Make changes to a community tag\npub struct EditCommunityTag {\n  pub tag_id: CommunityTagId,\n  pub display_name: Option<String>,\n  pub summary: Option<String>,\n  pub color: Option<TagColor>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete a community tag.\npub struct DeleteCommunityTag {\n  pub tag_id: CommunityTagId,\n  pub delete: bool,\n}\n"
  },
  {
    "path": "crates/db_views/community/src/impls.rs",
    "content": "use crate::{CommunityView, MultiCommunityView};\nuse diesel::{ExpressionMethods, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::asc_if;\nuse lemmy_db_schema::{\n  CommunitySortType,\n  MultiCommunityListingType,\n  MultiCommunitySortType,\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::{CommunityId, MultiCommunityId},\n  source::{\n    community::{Community, community_keys as key},\n    local_user::LocalUser,\n    multi_community::{MultiCommunity, multi_community_keys as mkey},\n    site::Site,\n  },\n  utils::{\n    limit_fetch,\n    queries::filters::{\n      filter_is_subscribed,\n      filter_not_unlisted_or_is_subscribed,\n      filter_suggested_communities,\n    },\n  },\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  enums::ListingType,\n  joins::{\n    my_community_actions_join,\n    my_instance_communities_actions_join,\n    my_local_user_admin_join,\n    my_multi_community_follower_join,\n  },\n  schema::{\n    community,\n    community_actions,\n    instance_actions,\n    multi_community,\n    multi_community_entry,\n    multi_community_follow,\n    person,\n  },\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n  utils::{LowerKey, now, seconds_to_pg_interval},\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl CommunityView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(person_id: Option<PersonId>) -> _ {\n    let community_actions_join: my_community_actions_join = my_community_actions_join(person_id);\n    let instance_actions_community_join: my_instance_communities_actions_join =\n      my_instance_communities_actions_join(person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(person_id);\n\n    community::table\n      .left_join(community_actions_join)\n      .left_join(instance_actions_community_join)\n      .left_join(my_local_user_admin_join)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    my_local_user: Option<&'_ LocalUser>,\n    is_mod_or_admin: bool,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let mut query = Self::joins(my_local_user.person_id())\n      .filter(community::id.eq(community_id))\n      .select(Self::as_select())\n      .into_boxed();\n\n    // Hide deleted and removed for non-admins or mods\n    if !is_mod_or_admin {\n      query = query\n        .filter(Community::hide_removed_and_deleted())\n        .filter(filter_not_unlisted_or_is_subscribed());\n    }\n\n    query = my_local_user.visible_communities_only(query);\n\n    query\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl PaginationCursorConversion for CommunityView {\n  type PaginatedType = Community;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.community.id.0)\n  }\n\n  async fn from_cursor(\n    data: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Community::read(pool, CommunityId(data.id()?)).await\n  }\n}\n\n#[derive(Default)]\npub struct CommunityQuery<'a> {\n  pub listing_type: Option<ListingType>,\n  pub sort: Option<CommunitySortType>,\n  pub time_range_seconds: Option<i32>,\n  pub local_user: Option<&'a LocalUser>,\n  pub show_nsfw: Option<bool>,\n  pub multi_community_id: Option<MultiCommunityId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\nimpl CommunityQuery<'_> {\n  pub async fn list(\n    self,\n    site: &Site,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<PagedResponse<CommunityView>> {\n    use lemmy_db_schema::CommunitySortType::*;\n    let o = self;\n    let limit = limit_fetch(o.limit, None)?;\n\n    let mut query = CommunityView::joins(o.local_user.person_id())\n      .select(CommunityView::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    // Hide deleted and removed for non-admins\n    let is_admin = o.local_user.map(|l| l.admin).unwrap_or_default();\n    if !is_admin {\n      query = query\n        .filter(Community::hide_removed_and_deleted())\n        .filter(filter_not_unlisted_or_is_subscribed());\n    }\n\n    if let Some(listing_type) = o.listing_type {\n      query = match listing_type {\n        ListingType::All => query.filter(filter_not_unlisted_or_is_subscribed()),\n        ListingType::Subscribed => query.filter(filter_is_subscribed()),\n        ListingType::Local => query\n          .filter(community::local.eq(true))\n          .filter(filter_not_unlisted_or_is_subscribed()),\n        ListingType::ModeratorView => {\n          query.filter(community_actions::became_moderator_at.is_not_null())\n        }\n        ListingType::Suggested => query.filter(filter_suggested_communities()),\n      };\n    }\n\n    // Don't show blocked communities and communities on blocked instances. nsfw communities are\n    // also hidden (based on profile setting)\n    query = query.filter(instance_actions::blocked_communities_at.is_null());\n    query = query.filter(community_actions::blocked_at.is_null());\n    if !(o.local_user.show_nsfw(site) || o.show_nsfw.unwrap_or_default()) {\n      query = query.filter(community::nsfw.eq(false));\n    }\n\n    query = o.local_user.visible_communities_only(query);\n\n    if let Some(multi_community_id) = o.multi_community_id {\n      let communities = multi_community_entry::table\n        .filter(multi_community_entry::multi_community_id.eq(multi_community_id))\n        .select(multi_community_entry::community_id);\n      query = query.filter(community::id.eq_any(communities))\n    }\n\n    // Filter by the time range\n    if let Some(time_range_seconds) = o.time_range_seconds {\n      query = query\n        .filter(community::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)));\n    }\n\n    // Only sort by ascending for Old or NameAsc sorts.\n    let sort = o.sort.unwrap_or_default();\n    let sort_direction = asc_if(sort == Old || sort == NameAsc);\n\n    let mut pq = CommunityView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?;\n\n    pq = match sort {\n      Hot => pq.then_order_by(key::hot_rank),\n      Comments => pq.then_order_by(key::comments),\n      Posts => pq.then_order_by(key::posts),\n      New => pq.then_order_by(key::published_at),\n      Old => pq.then_order_by(key::published_at),\n      Subscribers => pq.then_order_by(key::subscribers),\n      SubscribersLocal => pq.then_order_by(key::subscribers_local),\n      ActiveSixMonths => pq.then_order_by(key::users_active_half_year),\n      ActiveMonthly => pq.then_order_by(key::users_active_month),\n      ActiveWeekly => pq.then_order_by(key::users_active_week),\n      ActiveDaily => pq.then_order_by(key::users_active_day),\n      NameAsc => pq.then_order_by(LowerKey(key::name)),\n      NameDesc => pq.then_order_by(LowerKey(key::name)),\n    };\n\n    // finally use unique id as tie breaker\n    pq = pq.then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = pq\n      .load::<CommunityView>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, o.page_cursor)\n  }\n}\n\nimpl MultiCommunityView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(person_id: Option<PersonId>) -> _ {\n    let my_multi_community_follower_join: my_multi_community_follower_join =\n      my_multi_community_follower_join(person_id);\n\n    multi_community::table\n      .inner_join(person::table)\n      .left_join(my_multi_community_follower_join)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    id: MultiCommunityId,\n    my_person_id: Option<PersonId>,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    Self::joins(my_person_id)\n      .filter(multi_community::id.eq(id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl PaginationCursorConversion for MultiCommunityView {\n  type PaginatedType = MultiCommunity;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.multi.id.0)\n  }\n\n  async fn from_cursor(\n    data: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    MultiCommunity::read(pool, MultiCommunityId(data.id()?)).await\n  }\n}\n\n#[derive(Default)]\npub struct MultiCommunityQuery {\n  pub listing_type: Option<MultiCommunityListingType>,\n  pub sort: Option<MultiCommunitySortType>,\n  pub time_range_seconds: Option<i32>,\n  pub my_person_id: Option<PersonId>,\n  pub creator_id: Option<PersonId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub no_limit: Option<bool>,\n}\n\nimpl MultiCommunityQuery {\n  pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult<PagedResponse<MultiCommunityView>> {\n    use lemmy_db_schema::{MultiCommunityListingType::*, MultiCommunitySortType::*};\n    let o = self;\n\n    let limit = limit_fetch(o.limit, o.no_limit)?;\n    let mut query = MultiCommunityView::joins(o.my_person_id)\n      .select(MultiCommunityView::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(listing_type) = o.listing_type {\n      query = match listing_type {\n        All => query,\n        Subscribed => {\n          if let Some(my_person_id) = o.my_person_id {\n            query.filter(multi_community_follow::person_id.eq(my_person_id))\n          } else {\n            query\n          }\n        }\n        Local => query.filter(multi_community::local),\n      };\n    }\n\n    if let Some(creator_id) = o.creator_id {\n      query = query.filter(multi_community::creator_id.eq(creator_id));\n    }\n\n    // Filter by the time range\n    if let Some(time_range_seconds) = o.time_range_seconds {\n      query = query.filter(\n        multi_community::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)),\n      );\n    }\n\n    // Only sort by ascending for Old or NameAsc sorts.\n    let sort = o.sort.unwrap_or_default();\n    let sort_direction = asc_if(sort == Old || sort == NameAsc);\n\n    let mut pq =\n      MultiCommunityView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?;\n\n    pq = match sort {\n      New => pq.then_order_by(mkey::published_at),\n      Old => pq.then_order_by(mkey::published_at),\n      Communities => pq.then_order_by(mkey::communities),\n      Subscribers => pq.then_order_by(mkey::subscribers),\n      SubscribersLocal => pq.then_order_by(mkey::subscribers_local),\n      NameAsc => pq.then_order_by(LowerKey(mkey::name)),\n      NameDesc => pq.then_order_by(LowerKey(mkey::name)),\n    };\n\n    // finally use unique id as tie breaker\n    pq = pq.then_order_by(mkey::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = pq\n      .load::<MultiCommunityView>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n\n    paginate_response(res, limit, o.page_cursor)\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use crate::{\n    CommunityView,\n    impls::{CommunityQuery, MultiCommunityListingType, MultiCommunityQuery},\n  };\n  use lemmy_db_schema::{\n    CommunitySortType,\n    source::{\n      community::{\n        Community,\n        CommunityActions,\n        CommunityFollowerForm,\n        CommunityInsertForm,\n        CommunityModeratorForm,\n        CommunityUpdateForm,\n      },\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      multi_community::{MultiCommunity, MultiCommunityFollowForm, MultiCommunityInsertForm},\n      person::{Person, PersonInsertForm},\n      site::Site,\n    },\n    traits::Followable,\n  };\n  use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::{LemmyErrorType, LemmyResult};\n  use serial_test::serial;\n  use std::collections::HashSet;\n  use url::Url;\n\n  struct Data {\n    instance: Instance,\n    local_user: LocalUser,\n    communities: [Community; 3],\n    site: Site,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let person_name = \"tegan\".to_string();\n\n    let new_person = PersonInsertForm::test_form(instance.id, &person_name);\n\n    let inserted_person = Person::create(pool, &new_person).await?;\n\n    let local_user_form = LocalUserInsertForm::test_form(inserted_person.id);\n    let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?;\n\n    let communities = [\n      Community::create(\n        pool,\n        &CommunityInsertForm::new(\n          instance.id,\n          \"test_community_1\".to_string(),\n          \"nada1\".to_owned(),\n          \"pubkey\".to_string(),\n        ),\n      )\n      .await?,\n      Community::create(\n        pool,\n        &CommunityInsertForm::new(\n          instance.id,\n          \"test_community_2\".to_string(),\n          \"nada2\".to_owned(),\n          \"pubkey\".to_string(),\n        ),\n      )\n      .await?,\n      Community::create(\n        pool,\n        &CommunityInsertForm::new(\n          instance.id,\n          \"test_community_3\".to_string(),\n          \"nada3\".to_owned(),\n          \"pubkey\".to_string(),\n        ),\n      )\n      .await?,\n    ];\n\n    let url = Url::parse(\"http://example.com\")?;\n    let site = Site {\n      id: Default::default(),\n      name: String::new(),\n      sidebar: None,\n      published_at: Default::default(),\n      updated_at: None,\n      icon: None,\n      banner: None,\n      summary: None,\n      ap_id: url.clone().into(),\n      last_refreshed_at: Default::default(),\n      inbox_url: url.into(),\n      private_key: None,\n      public_key: String::new(),\n      instance_id: Default::default(),\n      content_warning: None,\n    };\n\n    Ok(Data {\n      instance,\n      local_user,\n      communities,\n      site,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    for Community { id, .. } in data.communities {\n      Community::delete(pool, id).await?;\n    }\n    Person::delete(pool, data.local_user.person_id).await?;\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn follow_state() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n    let community = &data.communities[0];\n\n    let unauthenticated = CommunityView::read(pool, community.id, None, false).await?;\n    assert!(unauthenticated.community_actions.is_none());\n\n    let authenticated =\n      CommunityView::read(pool, community.id, Some(&data.local_user), false).await?;\n    assert!(authenticated.community_actions.is_none());\n\n    let form = CommunityFollowerForm::new(\n      community.id,\n      data.local_user.person_id,\n      CommunityFollowerState::Pending,\n    );\n    CommunityActions::follow(pool, &form).await?;\n\n    let with_pending_follow =\n      CommunityView::read(pool, community.id, Some(&data.local_user), false).await?;\n    assert!(\n      with_pending_follow\n        .community_actions\n        .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::Pending))\n    );\n\n    // mark community private and set follow as approval required\n    Community::update(\n      pool,\n      community.id,\n      &CommunityUpdateForm {\n        visibility: Some(CommunityVisibility::Private),\n        ..Default::default()\n      },\n    )\n    .await?;\n    let form = CommunityFollowerForm::new(\n      community.id,\n      data.local_user.person_id,\n      CommunityFollowerState::ApprovalRequired,\n    );\n    CommunityActions::follow(pool, &form).await?;\n\n    let with_approval_required_follow =\n      CommunityView::read(pool, community.id, Some(&data.local_user), false).await?;\n    assert!(\n      with_approval_required_follow\n        .community_actions\n        .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::ApprovalRequired))\n    );\n\n    let form = CommunityFollowerForm::new(\n      community.id,\n      data.local_user.person_id,\n      CommunityFollowerState::Accepted,\n    );\n    CommunityActions::follow(pool, &form).await?;\n    let with_accepted_follow =\n      CommunityView::read(pool, community.id, Some(&data.local_user), false).await?;\n    assert!(\n      with_accepted_follow\n        .community_actions\n        .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::Accepted))\n    );\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn local_only_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    Community::update(\n      pool,\n      data.communities[0].id,\n      &CommunityUpdateForm {\n        visibility: Some(CommunityVisibility::LocalOnlyPrivate),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let unauthenticated_query = CommunityQuery {\n      sort: Some(CommunitySortType::New),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(data.communities.len() - 1, unauthenticated_query.len());\n\n    let authenticated_query = CommunityQuery {\n      local_user: Some(&data.local_user),\n      sort: Some(CommunitySortType::New),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?;\n    assert_eq!(data.communities.len(), authenticated_query.len());\n\n    let unauthenticated_community =\n      CommunityView::read(pool, data.communities[0].id, None, false).await;\n    assert!(unauthenticated_community.is_err());\n\n    let authenticated_community =\n      CommunityView::read(pool, data.communities[0].id, Some(&data.local_user), false).await;\n    assert!(authenticated_community.is_ok());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn community_sort_name() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let query = CommunityQuery {\n      sort: Some(CommunitySortType::NameAsc),\n      ..Default::default()\n    };\n    let communities = query.list(&data.site, pool).await?;\n    for (i, c) in communities.iter().enumerate().skip(1) {\n      let prev = communities.get(i - 1).ok_or(LemmyErrorType::NotFound)?;\n      assert!(c.community.title.cmp(&prev.community.title).is_ge());\n    }\n\n    let query = CommunityQuery {\n      sort: Some(CommunitySortType::NameDesc),\n      ..Default::default()\n    };\n    let communities = query.list(&data.site, pool).await?;\n    for (i, c) in communities.iter().enumerate().skip(1) {\n      let prev = communities.get(i - 1).ok_or(LemmyErrorType::NotFound)?;\n      assert!(c.community.title.cmp(&prev.community.title).is_le());\n    }\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn can_mod() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Make sure can_mod is false for all of them.\n    CommunityQuery {\n      local_user: Some(&data.local_user),\n      sort: Some(CommunitySortType::New),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?\n    .iter()\n    .for_each(|c| assert!(!c.can_mod));\n\n    let person_id = data.local_user.person_id;\n\n    // Now join the mod team of test community 1 and 2\n    let mod_form_1 = CommunityModeratorForm::new(data.communities[0].id, person_id);\n    CommunityActions::join(pool, &mod_form_1).await?;\n\n    let mod_form_2 = CommunityModeratorForm::new(data.communities[1].id, person_id);\n    CommunityActions::join(pool, &mod_form_2).await?;\n\n    let mod_query = CommunityQuery {\n      local_user: Some(&data.local_user),\n      ..Default::default()\n    }\n    .list(&data.site, pool)\n    .await?\n    .iter()\n    .map(|c| (c.community.name.clone(), c.can_mod))\n    .collect::<HashSet<_>>();\n\n    let expected_communities = HashSet::from([\n      (\"test_community_3\".to_owned(), false),\n      (\"test_community_2\".to_owned(), true),\n      (\"test_community_1\".to_owned(), true),\n    ]);\n    assert_eq!(expected_communities, mod_query);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_multi_community_list() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let tom_form = PersonInsertForm::test_form(data.instance.id, \"tom\");\n    let tom = Person::create(pool, &tom_form).await?;\n\n    let multi_1_form = MultiCommunityInsertForm::new(\n      data.local_user.person_id,\n      data.instance.id,\n      \"multi2\".to_string(),\n      String::new(),\n    );\n    let multi = MultiCommunity::create(pool, &multi_1_form).await?;\n\n    let multi_2_form =\n      MultiCommunityInsertForm::new(tom.id, tom.instance_id, \"multi2\".to_string(), String::new());\n    let multi2 = MultiCommunity::create(pool, &multi_2_form).await?;\n\n    // list all multis\n    let list_all = MultiCommunityQuery::default()\n      .list(pool)\n      .await?\n      .iter()\n      .map(|m| m.multi.id)\n      .collect::<HashSet<_>>();\n\n    assert_eq!(list_all, HashSet::from([multi.id, multi2.id]));\n\n    // list multis by owner\n    let list_owner = MultiCommunityQuery {\n      creator_id: Some(data.local_user.person_id),\n      my_person_id: Some(data.local_user.person_id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(list_owner.len(), 1);\n    assert_eq!(list_owner[0].multi.id, multi.id);\n    assert_eq!(list_owner[0].follow_state, None);\n\n    // Tegan follows multi2\n    let follow_form = MultiCommunityFollowForm {\n      multi_community_id: multi2.id,\n      person_id: data.local_user.person_id,\n      follow_state: CommunityFollowerState::Accepted,\n    };\n    MultiCommunity::follow(pool, &follow_form).await?;\n\n    // list multis followed by user, followed_only\n    let list_followed = MultiCommunityQuery {\n      my_person_id: Some(data.local_user.person_id),\n      listing_type: Some(MultiCommunityListingType::Subscribed),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(list_followed.len(), 1);\n    assert_eq!(list_followed[0].multi.id, multi2.id);\n    assert_eq!(list_followed[0].owner.id, tom.id);\n    assert_eq!(\n      list_followed[0].follow_state,\n      Some(CommunityFollowerState::Accepted)\n    );\n\n    // Unfollow, and make sure its removed\n    MultiCommunity::unfollow(pool, data.local_user.person_id, multi2.id).await?;\n    let list_followed = MultiCommunityQuery {\n      my_person_id: Some(data.local_user.person_id),\n      listing_type: Some(MultiCommunityListingType::Subscribed),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(list_followed.len(), 0);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/community/src/lib.rs",
    "content": "use lemmy_db_schema::source::{\n  community::{Community, CommunityActions},\n  community_tag::CommunityTagsView,\n  multi_community::MultiCommunity,\n  person::Person,\n};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{NullableExpressionMethods, Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::{\n    community_tags_fragment,\n    local_user_community_can_mod,\n  },\n  lemmy_db_schema_file::schema::multi_community_follow,\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community view.\npub struct CommunityView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community_actions: Option<CommunityActions>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = local_user_community_can_mod()\n    )\n  )]\n  pub can_mod: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = community_tags_fragment()\n    )\n  )]\n  pub tags: CommunityTagsView,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct MultiCommunityView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub multi: MultiCommunity,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = multi_community_follow::follow_state.nullable()\n    )\n  )]\n  pub follow_state: Option<CommunityFollowerState>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub owner: Person,\n}\n"
  },
  {
    "path": "crates/db_views/community_follower/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_community_follower\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\", \"lemmy_db_schema_file/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\nchrono = { workspace = true }\nlemmy_diesel_utils = { workspace = true, optional = true }\n\n[dev-dependencies]\n"
  },
  {
    "path": "crates/db_views/community_follower/src/impls.rs",
    "content": "use crate::CommunityFollowerView;\nuse chrono::Utc;\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  QueryDsl,\n  SelectableHelper,\n  dsl::{count_star, exists, not},\n  select,\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema::newtypes::CommunityId;\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::CommunityFollowerState,\n  schema::{community, community_actions, person},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  dburl::DbUrl,\n  utils::functions::lower,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl CommunityFollowerView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    community_actions::table\n      .filter(community_actions::followed_at.is_not_null())\n      .inner_join(community::table)\n      .inner_join(person::table.on(community_actions::person_id.eq(person::id)))\n  }\n  /// return a list of local community ids and remote inboxes that at least one user of the given\n  /// instance has followed\n  pub async fn get_instance_followed_community_inboxes(\n    pool: &mut DbPool<'_>,\n    instance_id: InstanceId,\n    published_since: chrono::DateTime<Utc>,\n  ) -> LemmyResult<Vec<(CommunityId, DbUrl)>> {\n    let conn = &mut get_conn(pool).await?;\n    // In most cases this will fetch the same url many times (the shared inbox url)\n    // PG will only send a single copy to rust, but it has to scan through all follower rows (same\n    // as it was before). So on the PG side it would be possible to optimize this further by\n    // adding e.g. a new table community_followed_instances (community_id, instance_id)\n    // that would work for all instances that support fully shared inboxes.\n    // It would be a bit more complicated though to keep it in sync.\n\n    Self::joins()\n      .filter(person::instance_id.eq(instance_id))\n      .filter(community::local) // this should be a no-op since community_followers table only has\n      // local-person+remote-community or remote-person+local-community\n      .filter(not(person::local))\n      .filter(community_actions::followed_at.gt(published_since.naive_utc()))\n      .select((community::id, person::inbox_url))\n      .distinct() // only need each community_id, inbox combination once\n      .load::<(CommunityId, DbUrl)>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn count_community_followers(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n  ) -> LemmyResult<i32> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(community_actions::community_id.eq(community_id))\n      .select(count_star())\n      .first::<i64>(conn)\n      .await\n      .map(i32::try_from)?\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn for_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(community_actions::person_id.eq(person_id))\n      .filter(community::deleted.eq(false))\n      .filter(community::removed.eq(false))\n      .filter(community::local_removed.eq(false))\n      // Exclude private community follows which still need to be approved by a mod\n      .filter(community_actions::follow_state.ne(CommunityFollowerState::ApprovalRequired))\n      .filter(community_actions::follow_state.ne(CommunityFollowerState::Denied))\n      .select(Self::as_select())\n      .order_by(lower(community::title))\n      .load::<CommunityFollowerView>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn is_follower(\n    community_id: CommunityId,\n    instance_id: InstanceId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      Self::joins()\n        .filter(community_actions::community_id.eq(community_id))\n        .filter(person::instance_id.eq(instance_id))\n        .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotFound.into())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/community_follower/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::{Queryable, Selectable};\nuse lemmy_db_schema::source::{community::Community, person::Person};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community follower.\npub struct CommunityFollowerView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub follower: Person,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PendingFollow {\n  pub person: Person,\n  pub community: Community,\n  pub is_new_instance: bool,\n  pub follow_state: Option<CommunityFollowerState>,\n}\n"
  },
  {
    "path": "crates/db_views/community_follower_approval/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_community_follower_approval\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\", \"lemmy_db_schema_file/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/community_follower_approval/src/api.rs",
    "content": "use lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ListCommunityPendingFollows {\n  /// Only shows the unapproved applications\n  pub unread_only: Option<bool>,\n  // Only for admins, show pending follows for communities which you dont moderate\n  pub all_communities: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/community_follower_approval/src/impls.rs",
    "content": "use crate::PendingFollowerView;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  dsl::{count, exists, sql},\n  pg::sql_types::Array,\n  select,\n  sql_types::Integer,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  newtypes::CommunityId,\n  source::{\n    community::{Community, CommunityActions, community_actions_keys as key},\n    person::Person,\n  },\n  utils::{limit_fetch, queries::selects::person1_select},\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  aliases,\n  enums::{CommunityFollowerState, CommunityVisibility},\n  schema::{community, community_actions, person},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse std::collections::HashMap;\n\ndiesel::alias!(community_actions as follower_community_actions: FollowerCommunityActions);\n\nimpl PendingFollowerView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    let follower_community_actions_join = follower_community_actions\n      .on(community::id.eq(follower_community_actions.field(community_actions::community_id)));\n    let follower_id = aliases::person1.field(person::id);\n    let follower_join = aliases::person1.on(\n      follower_community_actions\n        .field(community_actions::person_id)\n        .eq(follower_id)\n        .and(\n          follower_community_actions\n            .field(community_actions::followed_at)\n            .is_not_null(),\n        )\n        .and(community::id.eq(follower_community_actions.field(community_actions::community_id))),\n    );\n    let person_join = person::table.on(community_actions::person_id.eq(person::id));\n\n    community_actions::table\n      .inner_join(community::table)\n      .inner_join(person_join)\n      .inner_join(follower_community_actions_join)\n      .inner_join(follower_join)\n  }\n\n  pub async fn list_approval_required(\n    pool: &mut DbPool<'_>,\n    mod_id: PersonId,\n    all_communities: bool,\n    unread_only: bool,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n  ) -> LemmyResult<PagedResponse<PendingFollowerView>> {\n    let limit = limit_fetch(limit, None)?;\n\n    let mut query = Self::joins()\n      .filter(community_actions::became_moderator_at.is_not_null())\n      .filter(community::visibility.eq(CommunityVisibility::Private))\n      .select((\n        person1_select(),\n        community::all_columns,\n        follower_community_actions\n          .field(community_actions::follow_state)\n          .nullable(),\n      ))\n      .limit(limit)\n      .into_boxed();\n\n    // if param is false, only return items for communities where user is a mod\n    if !all_communities {\n      query = query.filter(person::id.eq(mod_id));\n    }\n\n    if unread_only {\n      query = query.filter(\n        follower_community_actions\n          .field(community_actions::follow_state)\n          .eq(CommunityFollowerState::ApprovalRequired),\n      );\n    }\n\n    // Sorting by published\n    let paginated_query = Self::paginate(query, &page_cursor, SortDirection::Asc, pool, None)\n      .await?\n      .then_order_by(key::followed_at);\n\n    let conn = &mut get_conn(pool).await?;\n    let mut res: Vec<_> = paginated_query\n      .load::<(Person, Community, Option<CommunityFollowerState>)>(conn)\n      .await?\n      .into_iter()\n      .map(|(person, community, follow_state)| PendingFollowerView {\n        person,\n        community,\n        is_new_instance: true,\n        follow_state,\n      })\n      .collect();\n\n    // For all returned communities, get the list of approved follower instances\n    // TODO: This should be merged into the main query above as a subquery\n    let community_ids: Vec<_> = res.iter().map(|r| r.community.id).collect();\n    let approved_follower_instances: HashMap<_, _> = community_actions::table\n      .inner_join(person::table.on(community_actions::person_id.eq(person::id)))\n      .filter(community_actions::community_id.eq_any(community_ids))\n      .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted))\n      .group_by(community_actions::community_id)\n      .select((\n        community_actions::community_id,\n        sql::<Array<Integer>>(\"array_agg(distinct person.instance_id) instance_ids\"),\n      ))\n      .load::<(CommunityId, Vec<InstanceId>)>(conn)\n      .await?\n      .into_iter()\n      .collect();\n\n    // Check if there is already an approved follower from the same instance. If not, frontends\n    // should show a warning because a malicious admin could leak private community data.\n    for r in &mut res {\n      let instance_ids = approved_follower_instances.get(&r.community.id);\n      if let Some(instance_ids) = instance_ids\n        && instance_ids.contains(&r.person.instance_id)\n      {\n        r.is_new_instance = false;\n      }\n    }\n    paginate_response(res, limit, page_cursor)\n  }\n\n  pub async fn count_approval_required(\n    pool: &mut DbPool<'_>,\n    mod_id: PersonId,\n  ) -> LemmyResult<i64> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(community_actions::became_moderator_at.is_not_null())\n      .filter(community::visibility.eq(CommunityVisibility::Private))\n      .filter(person::id.eq(mod_id))\n      .filter(\n        follower_community_actions\n          .field(community_actions::follow_state)\n          .eq(CommunityFollowerState::ApprovalRequired),\n      )\n      .select(count(community_actions::community_id))\n      .first::<i64>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n  pub async fn check_private_community_action(\n    pool: &mut DbPool<'_>,\n    from_person_id: PersonId,\n    community: &Community,\n  ) -> LemmyResult<()> {\n    if community.visibility != CommunityVisibility::Private {\n      return Ok(());\n    }\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      Self::joins()\n        .filter(community_actions::community_id.eq(community.id))\n        .filter(community_actions::person_id.eq(from_person_id))\n        .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotFound.into())\n  }\n  pub async fn check_has_followers_from_instance(\n    community_id: CommunityId,\n    instance_id: InstanceId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      Self::joins()\n        .filter(community::visibility.eq(CommunityVisibility::Private))\n        .filter(community_actions::community_id.eq(community_id))\n        .filter(aliases::person1.field(person::instance_id).eq(instance_id))\n        .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotFound.into())\n  }\n}\n\nimpl PaginationCursorConversion for PendingFollowerView {\n  type PaginatedType = CommunityActions;\n\n  fn to_cursor(&self) -> CursorData {\n    // This needs a person and community\n    CursorData::new_multi([self.person.id.0, self.community.id.0])\n  }\n  async fn from_cursor(\n    data: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let [person_id, community_id] = data.multi()?;\n    CommunityActions::read(pool, CommunityId(community_id), PersonId(person_id)).await\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use super::*;\n  use crate::PendingFollowerView;\n  use lemmy_db_schema::{\n    assert_length,\n    source::{\n      community::{\n        CommunityActions,\n        CommunityFollowerForm,\n        CommunityInsertForm,\n        CommunityModeratorForm,\n      },\n      instance::Instance,\n      person::PersonInsertForm,\n    },\n    traits::Followable,\n  };\n  use lemmy_db_schema_file::enums::CommunityVisibility;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_has_followers_from_instance() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // insert local community\n    let local_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n    let community_form = CommunityInsertForm {\n      visibility: Some(CommunityVisibility::Private),\n      ..CommunityInsertForm::new(\n        local_instance.id,\n        \"test_community_3\".to_string(),\n        \"nada\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let community = Community::create(pool, &community_form).await?;\n\n    // insert remote user\n    let remote_instance = Instance::read_or_create(pool, \"other_domain.tld\").await?;\n    let person_form =\n      PersonInsertForm::new(\"name\".to_string(), \"pubkey\".to_string(), remote_instance.id);\n    let person = Person::create(pool, &person_form).await?;\n\n    // community has no follower from remote instance, returns error\n    let has_followers = PendingFollowerView::check_has_followers_from_instance(\n      community.id,\n      remote_instance.id,\n      pool,\n    )\n    .await;\n    assert!(has_followers.is_err());\n\n    // insert unapproved follower\n    let mut follower_form = CommunityFollowerForm::new(\n      community.id,\n      person.id,\n      CommunityFollowerState::ApprovalRequired,\n    );\n    CommunityActions::follow(pool, &follower_form).await?;\n\n    // still returns error\n    let has_followers = PendingFollowerView::check_has_followers_from_instance(\n      community.id,\n      remote_instance.id,\n      pool,\n    )\n    .await;\n    assert!(has_followers.is_err());\n\n    // mark follower as accepted\n    follower_form.follow_state = CommunityFollowerState::Accepted;\n    CommunityActions::follow(pool, &follower_form).await?;\n\n    // now returns ok\n    let has_followers = PendingFollowerView::check_has_followers_from_instance(\n      community.id,\n      remote_instance.id,\n      pool,\n    )\n    .await;\n    assert!(has_followers.is_ok());\n\n    Instance::delete(pool, local_instance.id).await?;\n    Instance::delete(pool, remote_instance.id).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_pending_followers() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // insert local community\n    let local_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n    let community_form = CommunityInsertForm {\n      visibility: Some(CommunityVisibility::Private),\n      ..CommunityInsertForm::new(\n        local_instance.id,\n        \"test_community_3\".to_string(),\n        \"nada\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let community = Community::create(pool, &community_form).await?;\n\n    // insert local mod\n    let mod_form =\n      PersonInsertForm::new(\"name\".to_string(), \"pubkey\".to_string(), local_instance.id);\n    let mod_ = Person::create(pool, &mod_form).await?;\n\n    let moderator_form = CommunityModeratorForm::new(community.id, mod_.id);\n    CommunityActions::join(pool, &moderator_form).await?;\n\n    // insert remote user\n    let remote_instance = Instance::read_or_create(pool, \"other_domain.tld\").await?;\n    let person_form =\n      PersonInsertForm::new(\"name\".to_string(), \"pubkey\".to_string(), remote_instance.id);\n    let person = Person::create(pool, &person_form).await?;\n\n    // check that counts are initially 0\n    let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?;\n    assert_eq!(0, count);\n    let list =\n      PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?;\n    assert_length!(0, list);\n\n    // user is not allowed to post\n    let posting_allowed =\n      PendingFollowerView::check_private_community_action(pool, person.id, &community).await;\n    assert!(posting_allowed.is_err());\n\n    // send follow request\n    let follower_form = CommunityFollowerForm::new(\n      community.id,\n      person.id,\n      CommunityFollowerState::ApprovalRequired,\n    );\n    CommunityActions::follow(pool, &follower_form).await?;\n\n    // now there should be a pending follow\n    let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?;\n    assert_eq!(1, count);\n    let list =\n      PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?;\n    assert_length!(1, list);\n    assert_eq!(person.id, list[0].person.id);\n    assert_eq!(community.id, list[0].community.id);\n    assert_eq!(\n      Some(CommunityFollowerState::ApprovalRequired),\n      list[0].follow_state\n    );\n    assert!(list[0].is_new_instance);\n\n    // approve the follow\n    CommunityActions::follow_accepted(pool, community.id, person.id).await?;\n\n    // now the user can post\n    let posting_allowed =\n      PendingFollowerView::check_private_community_action(pool, person.id, &community).await;\n    assert!(posting_allowed.is_ok());\n\n    // check counts again\n    let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?;\n    assert_eq!(0, count);\n    let list =\n      PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?;\n    assert_length!(0, list);\n    let list_all =\n      PendingFollowerView::list_approval_required(pool, mod_.id, false, false, None, None).await?;\n    assert_length!(1, list_all);\n    assert_eq!(person.id, list_all[0].person.id);\n    assert_eq!(community.id, list_all[0].community.id);\n    assert_eq!(\n      Some(CommunityFollowerState::Accepted),\n      list_all[0].follow_state\n    );\n    assert!(!list_all[0].is_new_instance);\n\n    Instance::delete(pool, local_instance.id).await?;\n    Instance::delete(pool, remote_instance.id).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/community_follower_approval/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::Queryable;\nuse lemmy_db_schema::source::{community::Community, person::Person};\nuse lemmy_db_schema_file::enums::CommunityFollowerState;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PendingFollowerView {\n  pub person: Person,\n  pub community: Community,\n  pub is_new_instance: bool,\n  pub follow_state: Option<CommunityFollowerState>,\n}\n"
  },
  {
    "path": "crates/db_views/community_moderator/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_community_moderator\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\n"
  },
  {
    "path": "crates/db_views/community_moderator/src/impls.rs",
    "content": "use crate::{CommunityModeratorView, CommunityPersonBanView};\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  OptionalExtension,\n  QueryDsl,\n  SelectableHelper,\n  dsl::{exists, not},\n  select,\n};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema::{\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::CommunityId,\n  source::local_user::LocalUser,\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{community, community_actions, person},\n};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl CommunityModeratorView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    community_actions::table\n      .filter(community_actions::became_moderator_at.is_not_null())\n      .inner_join(community::table)\n      .inner_join(person::table.on(person::id.eq(community_actions::person_id)))\n  }\n\n  pub async fn check_is_community_moderator(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n    person_id: PersonId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      Self::joins()\n        .filter(community_actions::person_id.eq(person_id))\n        .filter(community_actions::community_id.eq(community_id)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotAModerator.into())\n  }\n\n  pub async fn is_community_moderator_of_any(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    select(exists(\n      Self::joins().filter(community_actions::person_id.eq(person_id)),\n    ))\n    .get_result::<bool>(conn)\n    .await?\n    .then_some(())\n    .ok_or(LemmyErrorType::NotAModerator.into())\n  }\n\n  pub async fn for_community(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(community_actions::community_id.eq(community_id))\n      .select(Self::as_select())\n      .order_by(community_actions::became_moderator_at)\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn top_mod_for_community(\n    pool: &mut DbPool<'_>,\n    community_id: CommunityId,\n  ) -> LemmyResult<Option<PersonId>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(community_actions::community_id.eq(community_id))\n      .select(person::id)\n      .order_by(community_actions::became_moderator_at)\n      .first(conn)\n      .await\n      .optional()\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn for_person(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    local_user: Option<&LocalUser>,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    let mut query = Self::joins()\n      .filter(community_actions::person_id.eq(person_id))\n      .select(Self::as_select())\n      .into_boxed();\n\n    query = local_user.visible_communities_only(query);\n\n    // only show deleted communities to creator\n    if Some(person_id) != local_user.person_id() {\n      query = query.filter(community::deleted.eq(false));\n    }\n\n    // Show removed communities to admins only\n    if !local_user.is_admin() {\n      query = query\n        .filter(community::removed.eq(false))\n        .filter(community::local_removed.eq(false));\n    }\n\n    query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Finds all communities first mods / creators\n  /// Ideally this should be a group by, but diesel doesn't support it yet\n  pub async fn get_community_first_mods(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .select(Self::as_select())\n      // A hacky workaround instead of group_bys\n      // https://stackoverflow.com/questions/24042359/how-to-join-only-one-row-in-joined-table-with-postgres\n      .distinct_on(community_actions::community_id)\n      .order_by((\n        community_actions::community_id,\n        community_actions::became_moderator_at,\n      ))\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl CommunityPersonBanView {\n  pub async fn check(\n    pool: &mut DbPool<'_>,\n    from_person_id: PersonId,\n    from_community_id: CommunityId,\n  ) -> LemmyResult<()> {\n    let conn = &mut get_conn(pool).await?;\n    let find_action = community_actions::table\n      .find((from_person_id, from_community_id))\n      .filter(community_actions::received_ban_at.is_not_null());\n    select(not(exists(find_action)))\n      .get_result::<bool>(conn)\n      .await?\n      .then_some(())\n      .ok_or(LemmyErrorType::PersonIsBannedFromCommunity.into())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/community_moderator/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::{Queryable, Selectable};\nuse lemmy_db_schema::source::{community::Community, person::Person};\nuse serde::{Deserialize, Serialize};\n\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community moderator.\npub struct CommunityModeratorView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub moderator: Person,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n/// A community person ban.\npub struct CommunityPersonBanView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person: Person,\n}\n"
  },
  {
    "path": "crates/db_views/custom_emoji/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_custom_emoji\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\nlemmy_diesel_utils = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/custom_emoji/src/api.rs",
    "content": "use crate::CustomEmojiView;\nuse lemmy_db_schema::newtypes::CustomEmojiId;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a custom emoji.\npub struct CreateCustomEmoji {\n  pub category: String,\n  pub shortcode: String,\n  pub image_url: DbUrl,\n  pub alt_text: String,\n  pub keywords: Vec<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A response for a custom emoji.\npub struct CustomEmojiResponse {\n  pub custom_emoji: CustomEmojiView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete a custom emoji.\npub struct DeleteCustomEmoji {\n  pub id: CustomEmojiId,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit a custom emoji.\npub struct EditCustomEmoji {\n  pub id: CustomEmojiId,\n  pub category: Option<String>,\n  pub shortcode: Option<String>,\n  pub image_url: Option<DbUrl>,\n  pub alt_text: Option<String>,\n  pub keywords: Option<Vec<String>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches a list of custom emojis.\npub struct ListCustomEmojis {\n  pub category: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A response for custom emojis.\npub struct ListCustomEmojisResponse {\n  pub custom_emojis: Vec<CustomEmojiView>,\n}\n"
  },
  {
    "path": "crates/db_views/custom_emoji/src/impls.rs",
    "content": "use crate::CustomEmojiView;\nuse diesel::{ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, dsl::Nullable};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema::{\n  newtypes::CustomEmojiId,\n  source::{custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword},\n};\nuse lemmy_db_schema_file::schema::{custom_emoji, custom_emoji_keyword};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse std::collections::HashMap;\n\ntype SelectionType = (\n  <custom_emoji::table as diesel::Table>::AllColumns,\n  Nullable<<custom_emoji_keyword::table as diesel::Table>::AllColumns>,\n);\n\nfn selection() -> SelectionType {\n  (\n    custom_emoji::all_columns,\n    custom_emoji_keyword::all_columns.nullable(), // (or all the columns if you want)\n  )\n}\ntype CustomEmojiTuple = (CustomEmoji, Option<CustomEmojiKeyword>);\n\n// TODO this type is a mess, it should not be using vectors in a view.\nimpl CustomEmojiView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    custom_emoji::table.left_join(\n      custom_emoji_keyword::table.on(custom_emoji_keyword::custom_emoji_id.eq(custom_emoji::id)),\n    )\n  }\n\n  pub async fn get(pool: &mut DbPool<'_>, emoji_id: CustomEmojiId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let emojis = Self::joins()\n      .filter(custom_emoji::id.eq(emoji_id))\n      .select(selection())\n      .load::<CustomEmojiTuple>(conn)\n      .await?;\n    if let Some(emoji) = CustomEmojiView::from_tuple_to_vec(emojis)\n      .into_iter()\n      .next()\n    {\n      Ok(emoji)\n    } else {\n      Err(LemmyErrorType::NotFound.into())\n    }\n  }\n\n  pub async fn list(pool: &mut DbPool<'_>, category: &Option<String>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = Self::joins().into_boxed();\n\n    if let Some(category) = category {\n      query = query.filter(custom_emoji::category.eq(category))\n    }\n\n    let emojis = query\n      .select(selection())\n      .order(custom_emoji::category)\n      .then_order_by(custom_emoji::id)\n      .load::<CustomEmojiTuple>(conn)\n      .await?;\n\n    Ok(CustomEmojiView::from_tuple_to_vec(emojis))\n  }\n\n  fn from_tuple_to_vec(items: Vec<CustomEmojiTuple>) -> Vec<Self> {\n    let mut result = Vec::new();\n    let mut hash: HashMap<CustomEmojiId, Vec<CustomEmojiKeyword>> = HashMap::new();\n    for (emoji, keyword) in &items {\n      let emoji_id: CustomEmojiId = emoji.id;\n      if let std::collections::hash_map::Entry::Vacant(e) = hash.entry(emoji_id) {\n        e.insert(Vec::new());\n        result.push(CustomEmojiView {\n          custom_emoji: emoji.clone(),\n          keywords: Vec::new(),\n        })\n      }\n      if let Some(item_keyword) = &keyword\n        && let Some(keywords) = hash.get_mut(&emoji_id)\n      {\n        keywords.push(item_keyword.clone())\n      }\n    }\n    for emoji in &mut result {\n      if let Some(keywords) = hash.get_mut(&emoji.custom_emoji.id) {\n        emoji.keywords.clone_from(keywords);\n      }\n    }\n    result\n  }\n}\n"
  },
  {
    "path": "crates/db_views/custom_emoji/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::Queryable;\nuse lemmy_db_schema::source::{\n  custom_emoji::CustomEmoji,\n  custom_emoji_keyword::CustomEmojiKeyword,\n};\nuse serde::{Deserialize, Serialize};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A custom emoji view.\npub struct CustomEmojiView {\n  pub custom_emoji: CustomEmoji,\n  pub keywords: Vec<CustomEmojiKeyword>,\n}\n"
  },
  {
    "path": "crates/db_views/local_image/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_local_image\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nurl = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/local_image/src/api.rs",
    "content": "use lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct DeleteImageParams {\n  pub filename: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ImageGetParams {\n  pub file_type: Option<String>,\n  pub max_size: Option<i32>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ImageProxyParams {\n  pub url: String,\n  pub file_type: Option<String>,\n  pub max_size: Option<i32>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Get your user's image / media uploads.\npub struct ListMedia {\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct UploadImageResponse {\n  pub image_url: Url,\n  pub filename: String,\n}\n"
  },
  {
    "path": "crates/db_views/local_image/src/impls.rs",
    "content": "use crate::LocalImageView;\nuse diesel::{ExpressionMethods, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  source::images::{LocalImage, local_image_keys as key},\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{local_image, person, post},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl LocalImageView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    local_image::table\n      .inner_join(person::table)\n      .left_join(post::table)\n  }\n\n  pub async fn get_all_paged_by_person_id(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    cursor_data: Option<PaginationCursor>,\n    limit: Option<i64>,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    let limit = limit_fetch(limit, None)?;\n\n    let query = Self::joins()\n      .filter(local_image::person_id.eq(person_id))\n      .select(Self::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    let paginated_query = Self::paginate(query, &cursor_data, SortDirection::Asc, pool, None)\n      .await?\n      .then_order_by(key::pictrs_alias);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n\n    paginate_response(res, limit, cursor_data)\n  }\n\n  pub async fn get_all_by_person_id(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n  ) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(local_image::person_id.eq(person_id))\n      .select(Self::as_select())\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn get_all_paged(\n    pool: &mut DbPool<'_>,\n    cursor_data: Option<PaginationCursor>,\n    limit: Option<i64>,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    let limit = limit_fetch(limit, None)?;\n\n    let query = Self::joins()\n      .select(Self::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    let paginated_query =\n      Self::paginate(query, &cursor_data, SortDirection::Asc, pool, None).await?;\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, cursor_data)\n  }\n}\n\nimpl PaginationCursorConversion for LocalImageView {\n  type PaginatedType = LocalImage;\n  fn to_cursor(&self) -> CursorData {\n    // Use pictrs alias\n    CursorData::new_plain(self.local_image.pictrs_alias.clone())\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n\n    // This isn't an id, but a string\n    let alias = cursor.plain();\n\n    let token = local_image::table\n      .select(Self::PaginatedType::as_select())\n      .filter(local_image::pictrs_alias.eq(alias))\n      .first(conn)\n      .await?;\n\n    Ok(token)\n  }\n}\n"
  },
  {
    "path": "crates/db_views/local_image/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::{Queryable, Selectable};\nuse lemmy_db_schema::source::{images::LocalImage, person::Person, post::Post};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A local image view.\npub struct LocalImageView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub local_image: LocalImage,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person: Person,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub post: Option<Post>,\n}\n"
  },
  {
    "path": "crates/db_views/local_user/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_local_user\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"actix-web\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"i-love-jesus\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\nactix-web = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/local_user/src/api.rs",
    "content": "use lemmy_db_schema::LocalUserSortType;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct AdminListUsers {\n  pub banned_only: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub sort: Option<LocalUserSortType>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/local_user/src/impls.rs",
    "content": "use crate::LocalUserView;\nuse actix_web::{FromRequest, HttpMessage, HttpRequest, dev::Payload};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::asc_if;\nuse lemmy_db_schema::{\n  LocalUserSortType,\n  newtypes::{LocalUserId, OAuthProviderId},\n  source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm},\n    person::{Person, PersonInsertForm, person_keys},\n  },\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  aliases::creator_home_instance_actions,\n  joins::creator_home_instance_actions_join,\n  schema::{instance_actions, local_user, oauth_account, person},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n  utils::{\n    functions::{coalesce, lower},\n    now,\n  },\n};\nuse lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse std::future::{Ready, ready};\n\nimpl LocalUserView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    local_user::table\n      .inner_join(person::table)\n      .left_join(creator_home_instance_actions_join())\n  }\n\n  pub async fn read(pool: &mut DbPool<'_>, local_user_id: LocalUserId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(local_user::id.eq(local_user_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(person::id.eq(person_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read_from_name(pool: &mut DbPool<'_>, name: &str) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(lower(person::name).eq(name.to_lowercase()))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn find_by_email_or_name(\n    pool: &mut DbPool<'_>,\n    name_or_email: &str,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(\n        lower(person::name)\n          .eq(lower(name_or_email.to_lowercase()))\n          .or(lower(coalesce(local_user::email, \"\")).eq(name_or_email.to_lowercase())),\n      )\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn find_by_email(pool: &mut DbPool<'_>, from_email: &str) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(lower(coalesce(local_user::email, \"\")).eq(from_email.to_lowercase()))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn find_by_oauth_id(\n    pool: &mut DbPool<'_>,\n    oauth_provider_id: OAuthProviderId,\n    oauth_user_id: &str,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .inner_join(oauth_account::table)\n      .filter(oauth_account::oauth_provider_id.eq(oauth_provider_id))\n      .filter(oauth_account::oauth_user_id.eq(oauth_user_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn list_admins_with_emails(pool: &mut DbPool<'_>) -> LemmyResult<Vec<Self>> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(local_user::email.is_not_null())\n      .filter(local_user::admin.eq(true))\n      .select(Self::as_select())\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn create_test_user(\n    pool: &mut DbPool<'_>,\n    name: &str,\n    bio: &str,\n    admin: bool,\n  ) -> LemmyResult<Self> {\n    let instance_id = Instance::read_or_create(pool, \"example.com\").await?.id;\n    let person_form = PersonInsertForm {\n      display_name: Some(name.to_owned()),\n      bio: Some(bio.to_owned()),\n      ..PersonInsertForm::test_form(instance_id, name)\n    };\n    let person = Person::create(pool, &person_form).await?;\n\n    let user_form = match admin {\n      true => LocalUserInsertForm::test_form_admin(person.id),\n      false => LocalUserInsertForm::test_form(person.id),\n    };\n    let local_user = LocalUser::create(pool, &user_form, vec![]).await?;\n\n    LocalUserView::read(pool, local_user.id).await\n  }\n}\n\n#[derive(Default)]\npub struct LocalUserQuery {\n  pub banned_only: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub sort: Option<LocalUserSortType>,\n}\n\nimpl LocalUserQuery {\n  // TODO: add filters and sorts\n  pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult<PagedResponse<LocalUserView>> {\n    let limit = self.limit.unwrap_or(i64::MAX);\n    let mut query = LocalUserView::joins()\n      .filter(person::deleted.eq(false))\n      .limit(limit)\n      .select(LocalUserView::as_select())\n      .into_boxed();\n\n    if self.banned_only.unwrap_or_default() {\n      let actions = creator_home_instance_actions;\n\n      query = query.filter(\n        actions\n          .field(instance_actions::received_ban_at)\n          .is_not_null()\n          .and(\n            actions\n              .field(instance_actions::ban_expires_at)\n              .is_null()\n              .or(\n                actions\n                  .field(instance_actions::ban_expires_at)\n                  .gt(now().nullable()),\n              ),\n          ),\n      );\n    }\n\n    // Only sort by ascending for Old\n    let sort = self.sort.unwrap_or_default();\n    let sort_direction = asc_if(sort == LocalUserSortType::Old);\n\n    let paginated_query =\n      LocalUserView::paginate(query, &self.page_cursor, sort_direction, pool, None)\n        .await?\n        .then_order_by(person_keys::published_at)\n        // Tie breaker\n        .then_order_by(person_keys::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query.load::<LocalUserView>(conn).await?;\n    paginate_response(res, limit, self.page_cursor)\n  }\n}\n\nimpl FromRequest for LocalUserView {\n  type Error = LemmyError;\n  type Future = Ready<Result<Self, Self::Error>>;\n\n  fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {\n    ready(match req.extensions().get::<LocalUserView>() {\n      Some(c) => Ok(c.clone()),\n      None => Err(LemmyErrorType::IncorrectLogin.into()),\n    })\n  }\n}\n\nimpl PaginationCursorConversion for LocalUserView {\n  type PaginatedType = Person;\n\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.person.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Person::read(pool, PersonId(cursor.id()?)).await\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use super::*;\n  use lemmy_db_schema::{\n    assert_length,\n    source::{\n      instance::{Instance, InstanceActions, InstanceBanForm},\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n    },\n    traits::Bannable,\n  };\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    alice: Person,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let alice_form = PersonInsertForm {\n      local: Some(true),\n      ..PersonInsertForm::test_form(instance.id, \"alice\")\n    };\n    let alice = Person::create(pool, &alice_form).await?;\n    let alice_local_user_form = LocalUserInsertForm::test_form(alice.id);\n    LocalUser::create(pool, &alice_local_user_form, vec![]).await?;\n\n    Ok(Data { alice })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.alice.instance_id).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn list_banned() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    InstanceActions::ban(\n      pool,\n      &InstanceBanForm::new(data.alice.id, data.alice.instance_id, None),\n    )\n    .await?;\n\n    let list = LocalUserQuery {\n      banned_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_length!(1, list);\n    assert_eq!(list[0].person.id, data.alice.id);\n\n    cleanup(data, pool).await\n  }\n}\n"
  },
  {
    "path": "crates/db_views/local_user/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::{local_user::LocalUser, person::Person};\nuse serde::{Deserialize, Serialize};\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::{creator_home_ban_expires, creator_home_banned},\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A local user view.\npub struct LocalUserView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub local_user: LocalUser,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person: Person,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_home_banned()\n    )\n  )]\n  pub banned: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_home_ban_expires()\n     )\n  )]\n  pub ban_expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_views/modlog/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_modlog\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\", \"lemmy_db_schema_file/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/modlog/src/api.rs",
    "content": "use lemmy_db_schema::{\n  ModlogKindFilter,\n  newtypes::{CommentId, CommunityId, ModlogId, PostId},\n};\nuse lemmy_db_schema_file::{PersonId, enums::ListingType};\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches the modlog.\npub struct GetModlog {\n  /// Filter by the moderator.\n  pub mod_person_id: Option<PersonId>,\n  /// Filter by the community.\n  pub community_id: Option<CommunityId>,\n  /// Filter by the modlog action type.\n  pub type_: Option<ModlogKindFilter>,\n  /// Filter by listing type. When not using All, it will remove the non-community modlog entries,\n  /// such as site bans, instance blocks, adding an admin, etc.\n  pub listing_type: Option<ListingType>,\n  /// Filter by the other / modded person.\n  pub other_person_id: Option<PersonId>,\n  /// Filter by post. Will include comments of that post.\n  pub post_id: Option<PostId>,\n  /// Filter by comment.\n  pub comment_id: Option<CommentId>,\n  /// When `true` show all. When `false` or `None`, hide bulk actions (default).\n  pub show_bulk: Option<bool>,\n  /// Return only child entries triggered by this parent modlog action.\n  pub bulk_action_parent_id: Option<ModlogId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/modlog/src/impls.rs",
    "content": "use crate::ModlogView;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  ModlogKindFilter,\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::{CommentId, CommunityId, ModlogId, PostId},\n  source::{\n    local_user::LocalUser,\n    modlog::{Modlog, modlog_keys as key},\n  },\n  utils::{\n    limit_fetch,\n    queries::filters::{\n      filter_is_subscribed,\n      filter_not_unlisted_or_is_subscribed,\n      filter_suggested_communities,\n    },\n  },\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  aliases,\n  enums::ListingType,\n  schema::{comment, community, community_actions, instance, modlog, person, post},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::LemmyResult;\n\nimpl ModlogView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>) -> _ {\n    // The query for the admin / mod person\n    let moderator_join = person::table.on(modlog::mod_id.eq(person::id));\n\n    // The modded / other person\n    let target_person = aliases::person1.field(person::id).nullable();\n    let target_person_join = aliases::person1.on(modlog::target_person_id.eq(target_person));\n\n    let community_actions_join = community_actions::table.on(\n      community_actions::community_id\n        .eq(community::id)\n        .and(community_actions::person_id.nullable().eq(my_person_id)),\n    );\n\n    modlog::table\n      .inner_join(moderator_join)\n      .left_join(target_person_join)\n      .left_join(comment::table.on(comment::id.nullable().eq(modlog::target_comment_id)))\n      .left_join(post::table.on(post::id.nullable().eq(modlog::target_post_id)))\n      .left_join(community::table.on(community::id.nullable().eq(modlog::target_community_id)))\n      .left_join(instance::table.on(instance::id.nullable().eq(modlog::target_instance_id)))\n      .left_join(community_actions_join)\n  }\n}\n\nimpl PaginationCursorConversion for ModlogView {\n  type PaginatedType = Modlog;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.modlog.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let query = modlog::table\n      .select(Self::PaginatedType::as_select())\n      .filter(modlog::id.eq(cursor.id()?));\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\n#[derive(Default)]\n/// Querying / filtering the modlog.\npub struct ModlogQuery<'a> {\n  pub type_: Option<ModlogKindFilter>,\n  pub listing_type: Option<ListingType>,\n  pub comment_id: Option<CommentId>,\n  pub post_id: Option<PostId>,\n  pub community_id: Option<CommunityId>,\n  pub hide_modlog_names: Option<bool>,\n  pub local_user: Option<&'a LocalUser>,\n  pub mod_person_id: Option<PersonId>,\n  pub target_person_id: Option<PersonId>,\n  pub show_bulk: Option<bool>,\n  pub bulk_action_parent_id: Option<ModlogId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\nimpl ModlogQuery<'_> {\n  pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult<PagedResponse<ModlogView>> {\n    let limit = limit_fetch(self.limit, None)?;\n\n    let target_person = aliases::person1.field(person::id);\n    let my_person_id = self.local_user.person_id();\n\n    let mut query = ModlogView::joins(my_person_id)\n      .select(ModlogView::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(mod_person_id) = self.mod_person_id {\n      query = query.filter(person::id.eq(mod_person_id));\n    };\n\n    if let Some(target_person_id) = self.target_person_id {\n      query = query.filter(target_person.eq(target_person_id));\n    };\n\n    if let Some(community_id) = self.community_id {\n      query = query.filter(community::id.eq(community_id))\n    }\n\n    if let Some(post_id) = self.post_id {\n      query = query.filter(post::id.eq(post_id))\n    }\n\n    if let Some(comment_id) = self.comment_id {\n      query = query.filter(comment::id.eq(comment_id))\n    }\n\n    // `show_bulk`: true => show all entries; false/None => hide bulk child entries.\n    // When bulk_action_parent_id is provided the caller is looking into a bulk\n    // action, so skip null guard\n    if let Some(bulk_action_parent_id) = self.bulk_action_parent_id {\n      query = query.filter(modlog::bulk_action_parent_id.eq(bulk_action_parent_id))\n    } else if !self.show_bulk.unwrap_or_default() {\n      query = query.filter(modlog::bulk_action_parent_id.is_null())\n    }\n\n    if let Some(type_) = self.type_ {\n      query = match type_ {\n        ModlogKindFilter::All => query,\n        ModlogKindFilter::Other(kind) => query.filter(modlog::kind.eq(kind)),\n      };\n    }\n\n    query = match self.listing_type.unwrap_or(ListingType::All) {\n      ListingType::All => query,\n      ListingType::Subscribed => query.filter(filter_is_subscribed()),\n      ListingType::Local => query\n        .filter(community::local.eq(true))\n        .filter(filter_not_unlisted_or_is_subscribed()),\n      ListingType::ModeratorView => {\n        query.filter(community_actions::became_moderator_at.is_not_null())\n      }\n      ListingType::Suggested => query.filter(filter_suggested_communities()),\n    };\n\n    // Sorting by published\n    let paginated_query =\n      ModlogView::paginate(query, &self.page_cursor, SortDirection::Desc, pool, None)\n        .await?\n        .then_order_by(key::published_at)\n        // Tie breaker\n        .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query.load::<ModlogView>(conn).await?;\n\n    let hide_modlog_names = self.hide_modlog_names.unwrap_or_default();\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .map(|u| u.hide_mod_name(hide_modlog_names))\n      .collect();\n\n    paginate_response(out, limit, self.page_cursor)\n  }\n}\n\nimpl ModlogView {\n  /// Hides modlog names by setting the moderator to None.\n  pub fn hide_mod_name(self, hide_modlog_names: bool) -> Self {\n    if hide_modlog_names {\n      Self {\n        moderator: None,\n        ..self\n      }\n    } else {\n      self\n    }\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use super::*;\n  use lemmy_db_schema::source::{\n    comment::{Comment, CommentInsertForm},\n    community::{Community, CommunityInsertForm},\n    instance::Instance,\n    modlog::{Modlog, ModlogInsertForm},\n    person::{Person, PersonInsertForm},\n    post::{Post, PostInsertForm},\n  };\n  use lemmy_db_schema_file::enums::ModlogKind;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    timmy: Person,\n    sara: Person,\n    jessica: Person,\n    community: Community,\n    community_2: Community,\n    post: Post,\n    post_2: Post,\n    comment: Comment,\n    comment_2: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_form = PersonInsertForm::test_form(instance.id, \"timmy_rcv\");\n    let timmy = Person::create(pool, &timmy_form).await?;\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara_rcv\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let jessica_form = PersonInsertForm::test_form(instance.id, \"jessica_mrv\");\n    let jessica = Person::create(pool, &jessica_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"test community crv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let community_form_2 = CommunityInsertForm::new(\n      instance.id,\n      \"test community crv 2\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community_2 = Community::create(pool, &community_form_2).await?;\n\n    let post_form = PostInsertForm::new(\"A test post crv\".into(), timmy.id, community.id);\n    let post = Post::create(pool, &post_form).await?;\n\n    let new_post_2 = PostInsertForm::new(\"A test post crv 2\".into(), sara.id, community_2.id);\n    let post_2 = Post::create(pool, &new_post_2).await?;\n\n    // Timmy creates a comment\n    let comment_form = CommentInsertForm::new(timmy.id, post.id, \"A test comment rv\".into());\n    let comment = Comment::create(pool, &comment_form, None).await?;\n\n    // jessica creates a comment\n    let comment_form_2 =\n      CommentInsertForm::new(jessica.id, post_2.id, \"A test comment rv 2\".into());\n    let comment_2 = Comment::create(pool, &comment_form_2, None).await?;\n\n    Ok(Data {\n      instance,\n      timmy,\n      sara,\n      jessica,\n      community,\n      community_2,\n      post,\n      post_2,\n      comment,\n      comment_2,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn admin_types() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form =\n      ModlogInsertForm::admin_allow_instance(data.timmy.id, data.instance.id, true, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form =\n      ModlogInsertForm::admin_block_instance(data.timmy.id, data.instance.id, true, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_purge_comment(\n      data.timmy.id,\n      &data.comment,\n      data.community.id,\n      \"reason\",\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_purge_community(data.timmy.id, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_purge_person(data.timmy.id, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_purge_post(data.timmy.id, data.community.id, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_change_community_visibility(data.timmy.id, data.community.id);\n    Modlog::create(pool, &[form]).await?;\n\n    // A 2nd mod hide community, but to a different community, and with jessica\n    let form =\n      ModlogInsertForm::mod_change_community_visibility(data.jessica.id, data.community_2.id);\n    Modlog::create(pool, &[form]).await?;\n\n    let modlog = ModlogQuery::default().list(pool).await?.items;\n    assert_eq!(8, modlog.len());\n\n    let v = &modlog[0];\n    assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind);\n    assert_eq!(\n      Some(data.community_2.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[1];\n    assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind);\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[2];\n    assert_eq!(ModlogKind::AdminPurgePost, v.modlog.kind);\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[3];\n    assert_eq!(ModlogKind::AdminPurgePerson, v.modlog.kind);\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[4];\n    assert_eq!(ModlogKind::AdminPurgeCommunity, v.modlog.kind);\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[5];\n    assert_eq!(ModlogKind::AdminPurgeComment, v.modlog.kind);\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    // Make sure the report types are correct\n    let v = &modlog[6]; // TODO: why index 2 again?\n    assert_eq!(ModlogKind::AdminBlockInstance, v.modlog.kind);\n    assert_eq!(\n      Some(data.instance.id),\n      v.target_instance.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[7];\n    assert_eq!(ModlogKind::AdminAllowInstance, v.modlog.kind);\n    assert_eq!(\n      Some(data.instance.id),\n      v.target_instance.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    // Filter by admin\n    let modlog_admin_filter = ModlogQuery {\n      mod_person_id: Some(data.timmy.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    // Only one is jessica\n    assert_eq!(7, modlog_admin_filter.len());\n\n    // Filter by community\n    let modlog_community_filter = ModlogQuery {\n      community_id: Some(data.community.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n\n    // Should be 2, and not jessicas\n    assert_eq!(3, modlog_community_filter.len());\n\n    // Filter by type\n    let modlog_type_filter = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(\n        ModlogKind::ModChangeCommunityVisibility,\n      )),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n\n    // 2 of these, one is jessicas\n    assert_eq!(2, modlog_type_filter.len());\n\n    let v = &modlog[0];\n    assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind);\n    assert_eq!(\n      Some(data.community_2.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[1];\n    assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind);\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn mod_types() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form = ModlogInsertForm::admin_add(&data.timmy, data.jessica.id, false);\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_add_to_community(\n      data.timmy.id,\n      data.community.id,\n      data.jessica.id,\n      false,\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_ban(&data.timmy, data.jessica.id, true, None, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_ban_from_community(\n      data.timmy.id,\n      data.community.id,\n      data.jessica.id,\n      true,\n      None,\n      \"reason\",\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_feature_post_community(data.timmy.id, &data.post, true);\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_feature_post_site(&data.timmy, &data.post, true);\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_lock_post(data.timmy.id, &data.post, true, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_lock_comment(\n      data.timmy.id,\n      &data.comment,\n      data.community.id,\n      true,\n      \"reason\",\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_remove_comment(\n      data.timmy.id,\n      &data.comment,\n      data.community.id,\n      true,\n      \"reason\",\n      None,\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::admin_remove_community(\n      &data.timmy,\n      data.community.id,\n      None,\n      true,\n      \"reason\",\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, \"reason\", None);\n    Modlog::create(pool, &[form]).await?;\n\n    let form =\n      ModlogInsertForm::mod_transfer_community(data.timmy.id, data.community.id, data.jessica.id);\n    Modlog::create(pool, &[form]).await?;\n\n    // A few extra ones to test different filters\n    let form =\n      ModlogInsertForm::mod_transfer_community(data.jessica.id, data.community_2.id, data.sara.id);\n    Modlog::create(pool, &[form]).await?;\n\n    let form =\n      ModlogInsertForm::mod_remove_post(data.jessica.id, &data.post_2, true, \"reason\", None);\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_remove_comment(\n      data.jessica.id,\n      &data.comment_2,\n      data.community_2.id,\n      true,\n      \"reason\",\n      None,\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_create_comment_warning(\n      data.jessica.id,\n      &data.comment,\n      data.community.id,\n      \"reason\",\n    );\n    Modlog::create(pool, &[form]).await?;\n\n    let form = ModlogInsertForm::mod_create_post_warning(data.jessica.id, &data.post_2, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    // The all view\n    let modlog = ModlogQuery::default().list(pool).await?;\n    assert_eq!(17, modlog.len());\n\n    let v = &modlog[0];\n    assert_eq!(ModlogKind::ModWarnPost, v.modlog.kind);\n    assert_eq!(Some(data.post_2.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(\n      Some(data.post_2.community_id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.post_2.creator_id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[1];\n    assert_eq!(ModlogKind::ModWarnComment, v.modlog.kind);\n    assert_eq!(\n      Some(data.comment.id),\n      v.target_comment.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.comment.creator_id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[2];\n    assert_eq!(ModlogKind::ModRemoveComment, v.modlog.kind);\n    assert_eq!(\n      Some(data.comment_2.id),\n      v.target_comment.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[3];\n    assert_eq!(ModlogKind::ModRemovePost, v.modlog.kind);\n    assert_eq!(Some(data.post_2.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.sara.id), v.target_person.as_ref().map(|a| a.id));\n    assert_eq!(\n      Some(data.community_2.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[4];\n    assert_eq!(ModlogKind::ModTransferCommunity, v.modlog.kind);\n    assert_eq!(\n      Some(data.community_2.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.sara.id), v.target_person.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[5];\n    assert_eq!(ModlogKind::ModTransferCommunity, v.modlog.kind);\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[6];\n    assert_eq!(ModlogKind::ModRemovePost, v.modlog.kind);\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.timmy.id), v.target_person.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[7];\n    assert_eq!(ModlogKind::AdminRemoveCommunity, v.modlog.kind);\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[8];\n    assert_eq!(ModlogKind::ModRemoveComment, v.modlog.kind);\n    assert_eq!(\n      Some(data.comment.id),\n      v.target_comment.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n\n    let v = &modlog[9];\n    assert_eq!(ModlogKind::ModLockComment, v.modlog.kind);\n    assert_eq!(\n      Some(data.comment.id),\n      v.target_comment.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[10];\n    assert_eq!(ModlogKind::ModLockPost, v.modlog.kind);\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[11];\n    assert_eq!(ModlogKind::AdminFeaturePostSite, v.modlog.kind);\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[12];\n    assert_eq!(ModlogKind::ModFeaturePostCommunity, v.modlog.kind);\n    assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id));\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[13];\n    assert_eq!(ModlogKind::ModBanFromCommunity, v.modlog.kind);\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[14];\n    assert_eq!(ModlogKind::AdminBan, v.modlog.kind);\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[15];\n    assert_eq!(ModlogKind::ModAddToCommunity, v.modlog.kind);\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(\n      Some(data.community.id),\n      v.target_community.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    let v = &modlog[16];\n    assert_eq!(ModlogKind::AdminAdd, v.modlog.kind);\n    assert_eq!(\n      Some(data.jessica.id),\n      v.target_person.as_ref().map(|a| a.id)\n    );\n    assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id));\n\n    // Filter by moderator\n    let modlog_mod_timmy_filter = ModlogQuery {\n      mod_person_id: Some(data.timmy.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(12, modlog_mod_timmy_filter.len());\n\n    let modlog_mod_jessica_filter = ModlogQuery {\n      mod_person_id: Some(data.jessica.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(5, modlog_mod_jessica_filter.len());\n\n    // Filter by target_person\n    // Gets a little complicated because things aren't directly linked,\n    // you have to go into the item to see who created it.\n\n    let modlog_modded_timmy_filter = ModlogQuery {\n      target_person_id: Some(data.timmy.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(5, modlog_modded_timmy_filter.len());\n\n    let modlog_modded_jessica_filter = ModlogQuery {\n      target_person_id: Some(data.jessica.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(6, modlog_modded_jessica_filter.len());\n\n    let modlog_modded_sara_filter = ModlogQuery {\n      target_person_id: Some(data.sara.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(3, modlog_modded_sara_filter.len());\n\n    // Filter by community\n    let modlog_community_filter = ModlogQuery {\n      community_id: Some(data.community.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(11, modlog_community_filter.len());\n\n    let modlog_community_2_filter = ModlogQuery {\n      community_id: Some(data.community_2.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(4, modlog_community_2_filter.len());\n\n    // Filter by post\n    let modlog_post_filter = ModlogQuery {\n      post_id: Some(data.post.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(7, modlog_post_filter.len());\n\n    let modlog_post_2_filter = ModlogQuery {\n      post_id: Some(data.post_2.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(3, modlog_post_2_filter.len());\n\n    // Filter by comment\n    let modlog_comment_filter = ModlogQuery {\n      comment_id: Some(data.comment.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(3, modlog_comment_filter.len());\n\n    let modlog_comment_2_filter = ModlogQuery {\n      comment_id: Some(data.comment_2.id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(1, modlog_comment_2_filter.len());\n\n    // Filter by type\n    let modlog_type_filter = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(2, modlog_type_filter.len());\n\n    // Assert that the types are correct\n    assert_eq!(\n      ModlogKind::ModRemoveComment,\n      modlog_type_filter[0].modlog.kind,\n    );\n    assert_eq!(\n      ModlogKind::ModRemoveComment,\n      modlog_type_filter[1].modlog.kind,\n    );\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn hide_modlog_names() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form =\n      ModlogInsertForm::admin_allow_instance(data.timmy.id, data.instance.id, true, \"reason\");\n    Modlog::create(pool, &[form]).await?;\n\n    let modlog = ModlogQuery::default().list(pool).await?;\n    assert_eq!(1, modlog.len());\n\n    assert_eq!(ModlogKind::AdminAllowInstance, modlog[0].modlog.kind);\n    assert_eq!(\n      Some(data.timmy.id),\n      modlog[0].moderator.as_ref().map(|a| a.id)\n    );\n\n    // Filter out the names\n    let modlog_hide_names_filter = ModlogQuery {\n      hide_modlog_names: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?;\n    assert_eq!(1, modlog_hide_names_filter.len());\n\n    assert_eq!(\n      ModlogKind::AdminAllowInstance,\n      modlog_hide_names_filter[0].modlog.kind\n    );\n    assert!(modlog_hide_names_filter[0].moderator.is_none());\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  /// Verifies that a single (non-bulk) modlog entry has bulk_action_parent_id == None by default.\n  #[tokio::test]\n  #[serial]\n  async fn individual_modlog_is_not_bulk() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form = ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, \"reason\", None);\n    Modlog::create(pool, &[form]).await?;\n\n    let modlog = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(1, modlog.len());\n    assert!(modlog[0].modlog.bulk_action_parent_id.is_none());\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  /// Verifies bulk entries are linked to their parent and can be queried by parent ID or show_bulk.\n  #[tokio::test]\n  #[serial]\n  async fn bulk_modlog_has_parent_id() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Create a ban entry to serve as the parent\n    let ban_form =\n      ModlogInsertForm::admin_ban(&data.timmy, data.sara.id, true, None, \"banning sara\");\n    let ban_action = Modlog::create(pool, &[ban_form]).await?;\n    let parent_id = ban_action[0].id;\n\n    // Create two bulk post removals linked to the ban\n    let post_form_1 = ModlogInsertForm::mod_remove_post(\n      data.timmy.id,\n      &data.post,\n      true,\n      \"bulk remove\",\n      Some(parent_id),\n    );\n    let post_form_2 = ModlogInsertForm::mod_remove_post(\n      data.timmy.id,\n      &data.post_2,\n      true,\n      \"bulk remove\",\n      Some(parent_id),\n    );\n    Modlog::create(pool, &[post_form_1, post_form_2]).await?;\n\n    // Create one individual (non-bulk) post removal for mixed-dataset tests\n    let individual_form =\n      ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, \"individual remove\", None);\n    Modlog::create(pool, &[individual_form]).await?;\n\n    // show_bulk: Some(true) now includes bulk and non-bulk (show all)\n    let all_with_show_true = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      show_bulk: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    // parent-linked two bulk + one individual = 3 total\n    assert_eq!(3, all_with_show_true.len());\n\n    // bulk_action_parent_id filter returns only children of that ban\n    let children = ModlogQuery {\n      bulk_action_parent_id: Some(parent_id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, children.len());\n\n    // show_bulk: Some(false) returns only the non-bulk entry\n    let non_bulk = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      show_bulk: Some(false),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(1, non_bulk.len());\n    assert!(non_bulk[0].modlog.bulk_action_parent_id.is_none());\n    // show_bulk: None behaves like false (hide bulk) and returns only the non-bulk entry\n    let none_behaviour = ModlogQuery {\n      type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(1, none_behaviour.len());\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  /// Verifies that bulk_action_parent_id filter isolates children of one parent from another.\n  #[tokio::test]\n  #[serial]\n  async fn bulk_action_parent_id_isolation() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Two separate ban entries as independent parents\n    let ban_form_a = ModlogInsertForm::admin_ban(&data.timmy, data.sara.id, true, None, \"ban sara\");\n    let ban_a = Modlog::create(pool, &[ban_form_a]).await?;\n    let parent_a_id = ban_a[0].id;\n\n    let ban_form_b =\n      ModlogInsertForm::admin_ban(&data.timmy, data.jessica.id, true, None, \"ban jessica\");\n    let ban_b = Modlog::create(pool, &[ban_form_b]).await?;\n    let parent_b_id = ban_b[0].id;\n\n    // Two post removals linked to parent A\n    let post_form_1 = ModlogInsertForm::mod_remove_post(\n      data.timmy.id,\n      &data.post,\n      true,\n      \"bulk A\",\n      Some(parent_a_id),\n    );\n    let post_form_2 = ModlogInsertForm::mod_remove_post(\n      data.timmy.id,\n      &data.post_2,\n      true,\n      \"bulk A\",\n      Some(parent_a_id),\n    );\n    Modlog::create(pool, &[post_form_1, post_form_2]).await?;\n\n    // Two comment removals linked to parent B\n    let comment_form_1 = ModlogInsertForm::mod_remove_comment(\n      data.timmy.id,\n      &data.comment,\n      data.community.id,\n      true,\n      \"bulk B\",\n      Some(parent_b_id),\n    );\n    let comment_form_2 = ModlogInsertForm::mod_remove_comment(\n      data.timmy.id,\n      &data.comment_2,\n      data.community.id,\n      true,\n      \"bulk B\",\n      Some(parent_b_id),\n    );\n    Modlog::create(pool, &[comment_form_1, comment_form_2]).await?;\n\n    // Filter by parent A\n    let children_of_a = ModlogQuery {\n      bulk_action_parent_id: Some(parent_a_id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, children_of_a.len());\n    assert!(\n      children_of_a\n        .iter()\n        .all(|e| e.modlog.bulk_action_parent_id == Some(parent_a_id))\n    );\n\n    // Filter by parent B\n    let children_of_b = ModlogQuery {\n      bulk_action_parent_id: Some(parent_b_id),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(2, children_of_b.len());\n    assert!(\n      children_of_b\n        .iter()\n        .all(|e| e.modlog.bulk_action_parent_id == Some(parent_b_id))\n    );\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/modlog/src/lib.rs",
    "content": "use lemmy_db_schema::source::{\n  comment::Comment,\n  community::Community,\n  instance::Instance,\n  modlog::Modlog,\n  person::Person,\n  post::Post,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{NullableExpressionMethods, Queryable, Selectable, dsl::Nullable},\n  lemmy_db_schema::{Person1AliasAllColumnsTuple, utils::queries::selects::person1_select},\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export, optional_fields))]\n#[skip_serializing_none]\npub struct ModlogView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub modlog: Modlog,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub moderator: Option<Person>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = Nullable<Person1AliasAllColumnsTuple>,\n      select_expression = person1_select().nullable()\n    )\n  )]\n  pub target_person: Option<Person>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub target_instance: Option<Instance>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub target_community: Option<Community>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub target_post: Option<Post>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub target_comment: Option<Comment>,\n}\n"
  },
  {
    "path": "crates/db_views/notification/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_notification\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_private_message/full\",\n  \"lemmy_db_views_post/full\",\n  \"lemmy_db_views_comment/full\",\n  \"lemmy_db_views_modlog/full\",\n  \"lemmy_db_views_notification_sql\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_private_message = { workspace = true }\nlemmy_db_views_post = { workspace = true }\nlemmy_db_views_comment = { workspace = true }\nlemmy_db_views_modlog = { workspace = true }\nlemmy_db_views_notification_sql = { workspace = true, optional = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nserde_with = { workspace = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/notification/src/api.rs",
    "content": "use lemmy_db_schema::newtypes::NotificationId;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Mark a comment reply as read.\npub struct MarkNotificationAsRead {\n  pub notification_id: NotificationId,\n  pub read: bool,\n}\n"
  },
  {
    "path": "crates/db_views/notification/src/impls.rs",
    "content": "use crate::{CommentView, NotificationData, NotificationView, NotificationViewInternal};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  PgExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  NotificationTypeFilter,\n  newtypes::NotificationId,\n  source::{\n    notification::{Notification, notification_keys},\n    person::Person,\n  },\n  utils::{limit_fetch, queries::filters::filter_blocked},\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  schema::{notification, person},\n};\nuse lemmy_db_views_modlog::ModlogView;\nuse lemmy_db_views_notification_sql::notification_joins;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl NotificationView {\n  /// Gets the number of unread mentions\n  pub async fn get_unread_count(\n    pool: &mut DbPool<'_>,\n    my_person: &Person,\n    show_bot_accounts: bool,\n  ) -> LemmyResult<i64> {\n    use diesel::dsl::count;\n    let conn = &mut get_conn(pool).await?;\n\n    let unread_filter = notification::read.eq(false);\n\n    let mut query = notification_joins(my_person.id, my_person.instance_id)\n      // Filter for your user\n      .filter(notification::recipient_id.eq(my_person.id))\n      // Filter unreads\n      .filter(unread_filter)\n      // Don't count replies from blocked users\n      .filter(filter_blocked())\n      .select(count(notification::id))\n      .into_boxed();\n\n    // These filters need to be kept in sync with the filters in queries().list()\n    if !show_bot_accounts {\n      query = query.filter(person::bot_account.is_distinct_from(true));\n    }\n\n    query\n      .first::<i64>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    id: NotificationId,\n    my_person: &Person,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n\n    let res = notification_joins(my_person.id, my_person.instance_id)\n      .filter(notification::id.eq(id))\n      .select(NotificationViewInternal::as_select())\n      .get_result::<NotificationViewInternal>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    // TODO: should pass this in as param\n    let hide_modlog_names = true;\n    map_to_enum(res, hide_modlog_names, my_person).ok_or(LemmyErrorType::NotFound.into())\n  }\n}\n\nimpl PaginationCursorConversion for NotificationView {\n  type PaginatedType = Notification;\n\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.notification.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let query = notification::table\n      .select(Self::PaginatedType::as_select())\n      .filter(notification::id.eq(cursor.id()?));\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\n#[derive(Default)]\npub struct NotificationQuery {\n  pub type_: Option<NotificationTypeFilter>,\n  pub unread_only: Option<bool>,\n  pub show_bot_accounts: Option<bool>,\n  pub hide_modlog_names: Option<bool>,\n  pub creator_id: Option<PersonId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub no_limit: Option<bool>,\n}\n\nimpl NotificationQuery {\n  pub fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    my_person: &Person,\n  ) -> impl Future<Output = LemmyResult<PagedResponse<NotificationView>>> {\n    Box::pin(async move {\n      let limit = limit_fetch(self.limit, self.no_limit)?;\n      let mut query = notification_joins(my_person.id, my_person.instance_id)\n        .select(NotificationViewInternal::as_select())\n        .limit(limit)\n        .into_boxed();\n\n      // Filters\n      if self.unread_only.unwrap_or_default() {\n        query = query\n          // The recipient filter (IE only show replies to you)\n          .filter(notification::recipient_id.eq(my_person.id))\n          .filter(notification::read.eq(false));\n      } else {\n        // A special case for private messages: show messages FROM you also.\n        // Use a not-null checks to catch the others\n        query = query.filter(\n          notification::recipient_id.eq(my_person.id).or(\n            notification::private_message_id.is_not_null().and(\n              notification::recipient_id\n                .eq(my_person.id)\n                .or(person::id.eq(my_person.id)),\n            ),\n          ),\n        );\n      }\n\n      if !self.show_bot_accounts.unwrap_or_default() {\n        query = query.filter(person::bot_account.is_distinct_from(true));\n      };\n\n      // Dont show replies from blocked users or instances\n      query = query.filter(filter_blocked());\n\n      if let Some(type_) = self.type_ {\n        query = match type_ {\n          NotificationTypeFilter::All => query,\n          NotificationTypeFilter::Other(kind) => query.filter(notification::kind.eq(kind)),\n        }\n      }\n\n      if let Some(creator_id) = self.creator_id {\n        query = query.filter(notification::creator_id.eq(creator_id));\n      }\n\n      // Sorting by published\n      let paginated_query = Box::pin(NotificationView::paginate(\n        query,\n        &self.page_cursor,\n        SortDirection::Desc,\n        pool,\n        None,\n      ))\n      .await?\n      .then_order_by(notification_keys::published_at)\n      // Tie breaker\n      .then_order_by(notification_keys::id);\n\n      let conn = &mut get_conn(pool).await?;\n      let res = paginated_query\n        .load::<NotificationViewInternal>(conn)\n        .await?;\n\n      let hide_modlog_names = self.hide_modlog_names.unwrap_or_default();\n      let res = res\n        .into_iter()\n        .filter_map(|r| map_to_enum(r, hide_modlog_names, my_person))\n        .collect();\n      paginate_response(res, limit, self.page_cursor)\n    })\n  }\n}\n\nfn map_to_enum(\n  v: NotificationViewInternal,\n  hide_modlog_name: bool,\n  my_person: &Person,\n) -> Option<NotificationView> {\n  let data = if let (Some(modlog), Some(creator)) = (v.modlog.clone(), v.creator.clone()) {\n    let m = ModlogView {\n      modlog,\n      moderator: Some(creator),\n      target_person: Some(v.recipient),\n      target_community: v.community,\n      target_post: v.post,\n      target_comment: v.comment,\n      target_instance: v.instance,\n    };\n    let m = m.hide_mod_name(hide_modlog_name);\n    NotificationData::ModAction(m)\n  } else if let (Some(comment), Some(post), Some(community), Some(creator)) = (\n    v.comment.clone(),\n    v.post.clone(),\n    v.community.clone(),\n    v.creator.clone(),\n  ) {\n    NotificationData::Comment(CommentView {\n      comment,\n      post,\n      community,\n      creator,\n      community_actions: v.community_actions,\n      person_actions: v.person_actions,\n      comment_actions: v.comment_actions,\n      tags: v.tags,\n      creator_banned_from_community: v.creator_banned_from_community,\n      creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      creator_is_admin: v.creator_is_admin,\n      can_mod: v.can_mod,\n      creator_banned: v.creator_banned,\n      creator_ban_expires_at: v.creator_ban_expires_at,\n      creator_is_moderator: v.creator_is_moderator,\n    })\n  } else if let (Some(post), Some(community), Some(creator)) =\n    (v.post.clone(), v.community.clone(), v.creator.clone())\n  {\n    NotificationData::Post(PostView {\n      post,\n      community,\n      creator,\n      image_details: v.image_details,\n      community_actions: v.community_actions,\n      post_actions: v.post_actions,\n      person_actions: v.person_actions,\n      tags: v.tags,\n      creator_banned_from_community: v.creator_banned_from_community,\n      creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      creator_is_admin: v.creator_is_admin,\n      can_mod: v.can_mod,\n      creator_banned: v.creator_banned,\n      creator_ban_expires_at: v.creator_ban_expires_at,\n      creator_is_moderator: v.creator_is_moderator,\n    })\n  } else if let (Some(mut private_message), Some(creator)) =\n    (v.private_message.clone(), v.creator.clone())\n  {\n    private_message.clear_deleted_by_recipient(Some(my_person));\n    NotificationData::PrivateMessage(PrivateMessageView {\n      private_message,\n      creator,\n      recipient: v.recipient,\n    })\n  } else {\n    return None;\n  };\n\n  let notification = if hide_modlog_name {\n    // Set the creator_id to zero if you're hiding modlog names.\n    // The mod view hiding is above.\n    Notification {\n      creator_id: PersonId(0),\n      ..v.notification\n    }\n  } else {\n    v.notification\n  };\n  Some(NotificationView { notification, data })\n}\n"
  },
  {
    "path": "crates/db_views/notification/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\n#[cfg(feature = \"full\")]\nuse lemmy_db_schema::source::{\n  comment::{Comment, CommentActions},\n  community::{Community, CommunityActions},\n  community_tag::CommunityTagsView,\n  images::ImageDetails,\n  instance::Instance,\n  modlog::Modlog,\n  person::{Person, PersonActions},\n  post::{Post, PostActions},\n  private_message::PrivateMessage,\n};\nuse lemmy_db_schema::{NotificationTypeFilter, source::notification::Notification};\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_comment::CommentView;\nuse lemmy_db_views_modlog::ModlogView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_db_views_private_message::PrivateMessageView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::{\n    Person1AliasAllColumnsTuple,\n    utils::queries::selects::{\n      CreatorLocalHomeBanExpiresType,\n      creator_is_admin,\n      creator_is_moderator,\n      creator_local_home_ban_expires,\n      creator_local_home_banned,\n      local_user_can_mod,\n    },\n    utils::queries::selects::{\n      creator_ban_expires_from_community,\n      creator_banned_from_community,\n      person1_select,\n      post_community_tags_fragment,\n    },\n  },\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\npub mod tests;\n\n#[cfg(feature = \"full\")]\n#[derive(Clone, Debug, Queryable, Selectable)]\n#[diesel(check_for_backend(diesel::pg::Pg))]\nstruct NotificationViewInternal {\n  #[diesel(embed)]\n  notification: Notification,\n  #[diesel(embed)]\n  private_message: Option<PrivateMessage>,\n  #[diesel(embed)]\n  comment: Option<Comment>,\n  #[diesel(embed)]\n  post: Option<Post>,\n  #[diesel(embed)]\n  community: Option<Community>,\n  #[diesel(embed)]\n  instance: Option<Instance>,\n  #[diesel(embed)]\n  creator: Option<Person>,\n  #[diesel(\n    select_expression_type = Person1AliasAllColumnsTuple,\n    select_expression = person1_select()\n  )]\n  recipient: Person,\n  #[diesel(embed)]\n  image_details: Option<ImageDetails>,\n  #[diesel(embed)]\n  community_actions: Option<CommunityActions>,\n  #[diesel(embed)]\n  post_actions: Option<PostActions>,\n  #[diesel(embed)]\n  person_actions: Option<PersonActions>,\n  #[diesel(embed)]\n  comment_actions: Option<CommentActions>,\n  #[diesel(embed)]\n  modlog: Option<Modlog>,\n  #[diesel(select_expression = post_community_tags_fragment())]\n  tags: CommunityTagsView,\n  #[diesel(select_expression = creator_is_admin())]\n  creator_is_admin: bool,\n  #[diesel(select_expression = local_user_can_mod())]\n  can_mod: bool,\n  #[diesel(select_expression = creator_local_home_banned())]\n  creator_banned: bool,\n  #[diesel(\n    select_expression_type = CreatorLocalHomeBanExpiresType,\n    select_expression = creator_local_home_ban_expires()\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[diesel(select_expression = creator_is_moderator())]\n  creator_is_moderator: bool,\n  #[diesel(select_expression = creator_banned_from_community())]\n  creator_banned_from_community: bool,\n  #[diesel(select_expression = creator_ban_expires_from_community())]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub struct NotificationView {\n  pub notification: Notification,\n  pub data: NotificationData,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"type_\", rename_all = \"snake_case\")]\npub enum NotificationData {\n  Comment(CommentView),\n  Post(PostView),\n  PrivateMessage(PrivateMessageView),\n  ModAction(ModlogView),\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Get your inbox (replies, comment mentions, post mentions, and messages)\npub struct ListNotifications {\n  pub type_: Option<NotificationTypeFilter>,\n  pub unread_only: Option<bool>,\n  pub creator_id: Option<PersonId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/notification/src/tests.rs",
    "content": "use crate::{NotificationData, NotificationView, impls::NotificationQuery};\nuse lemmy_db_schema::{\n  assert_length,\n  source::{\n    comment::{Comment, CommentInsertForm},\n    community::{Community, CommunityInsertForm},\n    instance::Instance,\n    modlog::{Modlog, ModlogInsertForm},\n    notification::{Notification, NotificationInsertForm},\n    person::{Person, PersonInsertForm},\n    post::{Post, PostInsertForm},\n    private_message::{PrivateMessage, PrivateMessageInsertForm},\n  },\n};\nuse lemmy_db_schema_file::enums::NotificationType;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, build_db_pool_for_tests},\n  traits::Crud,\n};\nuse lemmy_utils::error::LemmyResult;\nuse pretty_assertions::assert_eq;\nuse serial_test::serial;\n\nstruct Data {\n  alice: Person,\n  bob: Person,\n}\n\nasync fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n  let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n  let alice_form = PersonInsertForm::test_form(instance.id, \"alice2\");\n  let alice = Person::create(pool, &alice_form).await?;\n\n  let bob_form = PersonInsertForm::test_form(instance.id, \"bob2\");\n  let bob = Person::create(pool, &bob_form).await?;\n\n  Ok(Data { alice, bob })\n}\n\nasync fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  Instance::delete(pool, data.bob.instance_id).await?;\n  Ok(())\n}\n\n#[tokio::test]\n#[serial]\nasync fn test_private_message() -> LemmyResult<()> {\n  let pool = &build_db_pool_for_tests();\n  let pool = &mut pool.into();\n  let data = init_data(pool).await?;\n\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(0, count);\n  let notifs = NotificationQuery::default().list(pool, &data.alice).await?;\n  assert_length!(0, notifs);\n\n  let form = &PrivateMessageInsertForm::new(data.bob.id, data.alice.id, \"my message\".to_string());\n  let pm = PrivateMessage::create(pool, form).await?;\n  let form = NotificationInsertForm::new_private_message(&pm);\n  Notification::create(pool, &[form]).await?;\n\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(1, count);\n  let notifs = NotificationQuery::default().list(pool, &data.alice).await?;\n  assert_length!(1, notifs);\n  assert_eq!(Some(pm.id), notifs[0].notification.private_message_id);\n  assert_eq!(pm.recipient_id, notifs[0].notification.recipient_id);\n  assert!(!notifs[0].notification.read);\n  let NotificationData::PrivateMessage(notif_pm) = &notifs[0].data else {\n    panic!();\n  };\n  assert_eq!(pm, notif_pm.private_message);\n\n  cleanup(data, pool).await\n}\n\n#[tokio::test]\n#[serial]\nasync fn test_post() -> LemmyResult<()> {\n  let pool = &build_db_pool_for_tests();\n  let pool = &mut pool.into();\n  let data = init_data(pool).await?;\n\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(0, count);\n  let notifs = NotificationQuery::default().list(pool, &data.alice).await?;\n  assert_length!(0, notifs);\n\n  let community_form = CommunityInsertForm::new(\n    data.alice.instance_id,\n    \"comm\".to_string(),\n    \"title\".to_string(),\n    \"pubkey\".to_string(),\n  );\n  let community = Community::create(pool, &community_form).await?;\n\n  let post_form = PostInsertForm::new(\"title\".to_string(), data.bob.id, community.id);\n  let post = Post::create(pool, &post_form).await?;\n\n  let notif_form =\n    NotificationInsertForm::new_post(&post, data.alice.id, NotificationType::Subscribed);\n  Notification::create(pool, &[notif_form]).await?;\n\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(1, count);\n  let notifs1 = NotificationQuery::default().list(pool, &data.alice).await?;\n  assert_length!(1, notifs1);\n  assert_eq!(Some(post.id), notifs1[0].notification.post_id);\n  assert!(!notifs1[0].notification.read);\n  let NotificationData::Post(notif_post) = &notifs1[0].data else {\n    panic!();\n  };\n  assert_eq!(post, notif_post.post);\n  Notification::mark_read_by_id_and_person(pool, notifs1[0].notification.id, data.alice.id, true)\n    .await?;\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(0, count);\n\n  // create a notification entry for removed post\n  let mod_remove_post_form =\n    ModlogInsertForm::mod_remove_post(data.bob.id, &post, true, \"reason\", None);\n  let mod_remove_post = &Modlog::create(pool, &[mod_remove_post_form]).await?[0];\n  let notif_form =\n    NotificationInsertForm::new_mod_action(mod_remove_post.id, data.alice.id, data.bob.id);\n  Notification::create(pool, &[notif_form]).await?;\n\n  let count = NotificationView::get_unread_count(pool, &data.alice, false).await?;\n  assert_eq!(1, count);\n  let notifs2 = NotificationQuery {\n    unread_only: Some(true),\n    ..Default::default()\n  }\n  .list(pool, &data.alice)\n  .await?;\n  assert_length!(1, notifs2);\n  assert_eq!(Some(mod_remove_post.id), notifs2[0].notification.modlog_id);\n  assert!(!notifs2[0].notification.read);\n  let NotificationData::ModAction(notif_remove_post) = &notifs2[0].data else {\n    panic!();\n  };\n  assert_eq!(mod_remove_post, &notif_remove_post.modlog);\n\n  Notification::delete(pool, notifs1[0].notification.id).await?;\n  Notification::delete(pool, notifs2[0].notification.id).await?;\n  cleanup(data, pool).await\n}\n\n#[tokio::test]\n#[serial]\nasync fn test_modlog() -> LemmyResult<()> {\n  let pool = &build_db_pool_for_tests();\n  let pool = &mut pool.into();\n  let data = init_data(pool).await?;\n\n  // create a community and post\n  let form = CommunityInsertForm::new(\n    data.alice.instance_id,\n    \"test\".to_string(),\n    \"test\".to_string(),\n    String::new(),\n  );\n  let community = Community::create(pool, &form).await?;\n\n  let form = PostInsertForm {\n    ..PostInsertForm::new(\"123\".to_string(), data.bob.id, community.id)\n  };\n  let post = Post::create(pool, &form).await?;\n\n  let form = CommentInsertForm {\n    removed: Some(true),\n    ..CommentInsertForm::new(data.bob.id, post.id, String::new())\n  };\n  let comment = Comment::create(pool, &form, None).await?;\n\n  // remove the comment and check notifs\n  let form = ModlogInsertForm::mod_remove_comment(\n    data.alice.id,\n    &comment,\n    community.id,\n    true,\n    \"rule 1\",\n    None,\n  );\n  let modlog = &Modlog::create(pool, &[form]).await?[0];\n\n  let form = NotificationInsertForm::new_mod_action(modlog.id, data.bob.id, data.alice.id);\n  let notification = &Notification::create(pool, &[form]).await?[0];\n\n  let notifs = NotificationQuery::default().list(pool, &data.bob).await?;\n  assert_length!(1, notifs);\n  let NotificationData::ModAction(m) = &notifs[0].data else {\n    panic!();\n  };\n  assert_eq!(notification, &notifs[0].notification);\n  assert_eq!(modlog, &m.modlog);\n  assert_eq!(Some(data.alice.id), m.moderator.as_ref().map(|m| m.id));\n  assert_eq!(Some(data.bob.id), m.target_person.as_ref().map(|p| p.id));\n  assert_eq!(Some(comment.id), m.target_comment.as_ref().map(|c| c.id));\n\n  cleanup(data, pool).await\n}\n"
  },
  {
    "path": "crates/db_views/notification_sql/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_notification_sql\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\npublish = false\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[dependencies]\nlemmy_db_schema_file = { workspace = true, features = [\"full\"] }\ndiesel = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/notification_sql/src/lib.rs",
    "content": "use diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  dsl::not,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  aliases,\n  joins::{\n    creator_community_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    creator_local_user_admin_join,\n    image_details_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_instance_communities_actions_join,\n    my_instance_persons_actions_join_1,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{comment, community, instance, modlog, notification, person, post, private_message},\n};\n\n#[diesel::dsl::auto_type(no_type_alias)]\npub fn notification_joins(person_id: PersonId, instance_id: InstanceId) -> _ {\n  let item_creator_join = person::table.on(notification::creator_id.eq(person::id));\n\n  // No need to join on `modlog::target_person_id` as it is identical to\n  // `notification::recipient_id`.\n  let recipient_person = aliases::person1.field(person::id);\n  let recipient_join = aliases::person1.on(notification::recipient_id.eq(recipient_person));\n\n  let comment_join = comment::table.on(\n    notification::comment_id\n      .eq(comment::id.nullable())\n      // Filter out the deleted / removed\n      .and(not(comment::deleted))\n      .and(not(comment::removed))\n      .or(modlog::target_comment_id.eq(comment::id.nullable())),\n  );\n\n  let post_join = post::table.on(\n    notification::post_id\n      .eq(post::id.nullable())\n      .or(comment::post_id.eq(post::id))\n      // Filter out the deleted / removed\n      .and(not(post::deleted))\n      .and(not(post::removed))\n      .or(modlog::target_post_id.eq(post::id.nullable())),\n  );\n\n  let community_join = community::table.on(\n    post::community_id\n      .eq(community::id)\n      .or(modlog::target_community_id.eq(community::id.nullable())),\n  );\n\n  let private_message_join = private_message::table.on(\n    notification::private_message_id\n      .eq(private_message::id.nullable())\n      // Filter out the deleted / removed\n      .and(not(private_message::deleted))\n      // Also hide messages deleted by the recipient, but only for them\n      .and(not(\n        private_message::deleted_by_recipient.and(recipient_person.eq(person_id)),\n      ))\n      .and(not(private_message::removed)),\n  );\n\n  let instance_join = instance::table.on(modlog::target_instance_id.eq(instance::id.nullable()));\n\n  let my_community_actions_join: my_community_actions_join =\n    my_community_actions_join(Some(person_id));\n  let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(person_id));\n  let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(Some(person_id));\n  let my_instance_communities_actions_join: my_instance_communities_actions_join =\n    my_instance_communities_actions_join(Some(person_id));\n  let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 =\n    my_instance_persons_actions_join_1(Some(person_id));\n  let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(person_id));\n  let creator_local_instance_actions_join: creator_local_instance_actions_join =\n    creator_local_instance_actions_join(instance_id);\n  let my_local_user_admin_join: my_local_user_admin_join =\n    my_local_user_admin_join(Some(person_id));\n\n  // Note: avoid adding any more joins here as it will significantly slow down compilation.\n  notification::table\n    .left_join(modlog::table)\n    .left_join(comment_join)\n    .left_join(post_join)\n    .left_join(community_join)\n    .left_join(instance_join)\n    .left_join(image_details_join())\n    .inner_join(item_creator_join)\n    .inner_join(recipient_join)\n    // The private message join must come after recipient, as it uses it to filter out deleted by\n    // recipient.\n    .left_join(private_message_join)\n    .left_join(creator_community_actions_join())\n    .left_join(creator_local_user_admin_join())\n    .left_join(creator_home_instance_actions_join())\n    .left_join(creator_local_instance_actions_join)\n    .left_join(my_local_user_admin_join)\n    .left_join(my_community_actions_join)\n    .left_join(my_instance_communities_actions_join)\n    .left_join(my_instance_persons_actions_join_1)\n    .left_join(my_post_actions_join)\n    .left_join(my_person_actions_join)\n    .left_join(my_comment_actions_join)\n}\n"
  },
  {
    "path": "crates/db_views/person/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_person\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_community_moderator/full\",\n  \"lemmy_diesel_utils/full\",\n  \"lemmy_db_views_community/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_views_community_moderator/ts-rs\",\n  \"lemmy_db_views_community/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_community_moderator = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nlemmy_db_views_community = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/person/src/api.rs",
    "content": "use crate::PersonView;\nuse lemmy_db_schema::source::site::Site;\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_db_views_community::MultiCommunityView;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Adds an admin to a site.\npub struct AddAdmin {\n  pub person_id: PersonId,\n  pub added: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The response of current admins.\npub struct AddAdminResponse {\n  pub admins: Vec<PersonView>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Ban a person from the site.\npub struct BanPerson {\n  pub person_id: PersonId,\n  pub ban: bool,\n  /// Optionally remove or restore all their data. Useful for new troll accounts.\n  /// If ban is true, then this means remove. If ban is false, it means restore.\n  pub remove_or_restore_data: Option<bool>,\n  pub reason: String,\n  /// A time that the ban will expire, in unix epoch seconds.\n  ///\n  /// An i64 unix timestamp is used for a simpler API client implementation.\n  pub expires_at: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Block a person.\npub struct BlockPerson {\n  pub person_id: PersonId,\n  pub block: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A person response for actions done to a person.\npub struct PersonResponse {\n  pub person_view: PersonView,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets a person's details.\n///\n/// Either person_id, or username are required.\npub struct GetPersonDetails {\n  pub person_id: Option<PersonId>,\n  /// Example: dessalines , or dessalines@xyz.tld\n  pub username: Option<String>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A person's details response.\npub struct GetPersonDetailsResponse {\n  pub person_view: PersonView,\n  pub site: Option<Site>,\n  pub moderates: Vec<CommunityModeratorView>,\n  pub multi_communities_created: Vec<MultiCommunityView>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Purges a person from the database. This will delete all content attached to that person.\npub struct PurgePerson {\n  pub person_id: PersonId,\n  pub reason: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Make a note for a person.\n///\n/// An empty string deletes the note.\npub struct NotePerson {\n  pub person_id: PersonId,\n  pub note: String,\n}\n"
  },
  {
    "path": "crates/db_views/person/src/impls.rs",
    "content": "use crate::PersonView;\nuse diesel::{ExpressionMethods, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema::source::person::Person;\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  joins::{\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    my_person_actions_join,\n  },\n  schema::{local_user, person},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{CursorData, PaginationCursorConversion},\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl PaginationCursorConversion for PersonView {\n  type PaginatedType = Person;\n\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.person.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Person::read(pool, PersonId(cursor.id()?)).await\n  }\n}\n\nimpl PersonView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n\n    person::table\n      .left_join(local_user::table)\n      .left_join(my_person_actions_join)\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    person_id: PersonId,\n    my_person_id: Option<PersonId>,\n    local_instance_id: InstanceId,\n    is_admin: bool,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let mut query = Self::joins(my_person_id, local_instance_id)\n      .filter(person::id.eq(person_id))\n      .select(Self::as_select())\n      .into_boxed();\n\n    if !is_admin {\n      query = query.filter(person::deleted.eq(false))\n    }\n\n    query\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn list_admins(\n    my_person_id: Option<PersonId>,\n    local_instance_id: InstanceId,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Vec<PersonView>> {\n    let conn = &mut get_conn(pool).await?;\n\n    Self::joins(my_person_id, local_instance_id)\n      .filter(person::deleted.eq(false))\n      .filter(local_user::admin)\n      // Order by admin created date (ie old)\n      .then_order_by(person::published_at.asc())\n      // Tie breaker\n      .then_order_by(person::id)\n      .select(Self::as_select())\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use super::*;\n  use lemmy_db_schema::{\n    assert_length,\n    source::{\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n      person::{Person, PersonActions, PersonInsertForm, PersonNoteForm, PersonUpdateForm},\n    },\n  };\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    alice: Person,\n    alice_local_user: LocalUser,\n    bob: Person,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let alice_form = PersonInsertForm {\n      local: Some(true),\n      ..PersonInsertForm::test_form(instance.id, \"alice\")\n    };\n    let alice = Person::create(pool, &alice_form).await?;\n    let alice_local_user_form = LocalUserInsertForm::test_form(alice.id);\n    let alice_local_user = LocalUser::create(pool, &alice_local_user_form, vec![]).await?;\n\n    let bob_form = PersonInsertForm {\n      bot_account: Some(true),\n      local: Some(false),\n      ..PersonInsertForm::test_form(instance.id, \"bob\")\n    };\n    let bob = Person::create(pool, &bob_form).await?;\n\n    Ok(Data {\n      alice,\n      alice_local_user,\n      bob,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.bob.instance_id).await?;\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn exclude_deleted() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    Person::update(\n      pool,\n      data.alice.id,\n      &PersonUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false).await;\n    assert!(read.is_err());\n\n    // only admin can view deleted users\n    let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, true).await;\n    assert!(read.is_ok());\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn list_admins() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    LocalUser::update(\n      pool,\n      data.alice_local_user.id,\n      &LocalUserUpdateForm {\n        admin: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    let list = PersonView::list_admins(None, data.alice.instance_id, pool).await?;\n    assert_length!(1, list);\n    assert_eq!(list[0].person.id, data.alice.id);\n\n    let is_admin = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false)\n      .await?\n      .is_admin;\n    assert!(is_admin);\n\n    let is_admin = PersonView::read(pool, data.bob.id, None, data.alice.instance_id, false)\n      .await?\n      .is_admin;\n    assert!(!is_admin);\n\n    cleanup(data, pool).await\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn note() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let note_str = \"Bob hates cats.\";\n\n    let note_form = PersonNoteForm::new(data.alice.id, data.bob.id, note_str.to_string());\n    let inserted_note = PersonActions::note(pool, &note_form).await?;\n    assert_eq!(Some(note_str.to_string()), inserted_note.note);\n\n    let read = PersonView::read(\n      pool,\n      data.bob.id,\n      Some(data.alice.id),\n      data.alice.instance_id,\n      false,\n    )\n    .await?;\n\n    assert!(\n      read\n        .person_actions\n        .is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some())\n    );\n\n    cleanup(data, pool).await\n  }\n}\n"
  },
  {
    "path": "crates/db_views/person/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::person::{Person, PersonActions};\nuse serde::{Deserialize, Serialize};\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{NullableExpressionMethods, Queryable, Selectable, helper_types::Nullable},\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeBanExpiresType,\n    creator_local_home_ban_expires,\n    creator_local_home_banned,\n  },\n  lemmy_db_schema_file::schema::local_user,\n  lemmy_diesel_utils::utils::functions::coalesce,\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A person view.\npub struct PersonView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person: Person,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = coalesce<diesel::sql_types::Bool, Nullable<local_user::admin>, bool>,\n      select_expression = coalesce(local_user::admin.nullable(), false)\n    )\n  )]\n  pub is_admin: bool,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person_actions: Option<PersonActions>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_local_home_banned()\n    )\n  )]\n  pub banned: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = CreatorLocalHomeBanExpiresType,\n      select_expression = creator_local_home_ban_expires()\n     )\n  )]\n  pub ban_expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_views/person_content_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_person_content_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_views_post_comment_combined/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_schema_file/ts-rs\",\n  \"lemmy_db_views_post_comment_combined/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_views_post_comment_combined = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nderive-new = { workspace = true }\nserde_with = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/person_content_combined/src/api.rs",
    "content": "use lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets your hidden posts.\npub struct ListPersonHidden {\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets your read posts.\npub struct ListPersonRead {\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/person_content_combined/src/impls.rs",
    "content": "use crate::LocalUserView;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  self,\n  PersonContentType,\n  impls::local_user::LocalUserOptionHelper,\n  source::combined::person_content::{PersonContentCombined, person_content_combined_keys as key},\n  traits::InternalToCombinedView,\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{CommunityFollowerState, CommunityVisibility},\n  joins::{\n    community_join,\n    creator_community_actions_join,\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    creator_local_user_admin_join,\n    image_details_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{comment, community, community_actions, person, person_content_combined, post},\n};\nuse lemmy_db_views_post_comment_combined::{\n  PostCommentCombinedView,\n  PostCommentCombinedViewInternal,\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\nstruct PostCommentCombinedViewWrapper(PostCommentCombinedView);\n\nimpl PaginationCursorConversion for PostCommentCombinedViewWrapper {\n  type PaginatedType = PersonContentCombined;\n\n  fn to_cursor(&self) -> CursorData {\n    let (prefix, id) = match &self.0 {\n      PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0),\n      PostCommentCombinedView::Post(v) => ('P', v.post.id.0),\n    };\n    CursorData::new_with_prefix(prefix, id)\n  }\n\n  async fn from_cursor(\n    data: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = person_content_combined::table\n      .select(Self::PaginatedType::as_select())\n      .into_boxed();\n\n    let (prefix, id) = data.id_and_prefix()?;\n    query = match prefix {\n      'C' => query.filter(person_content_combined::comment_id.eq(id)),\n      'P' => query.filter(person_content_combined::post_id.eq(id)),\n      _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()),\n    };\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\n#[derive(derive_new::new)]\npub struct PersonContentCombinedQuery {\n  pub creator_id: PersonId,\n  #[new(default)]\n  pub type_: Option<PersonContentType>,\n  #[new(default)]\n  pub page_cursor: Option<PaginationCursor>,\n  #[new(default)]\n  pub limit: Option<i64>,\n  #[new(default)]\n  pub no_limit: Option<bool>,\n}\n\nimpl PersonContentCombinedQuery {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let comment_join =\n      comment::table.on(person_content_combined::comment_id.eq(comment::id.nullable()));\n\n    let post_join = post::table.on(\n      person_content_combined::post_id\n        .eq(post::id.nullable())\n        .or(comment::post_id.eq(post::id)),\n    );\n\n    let item_creator_join = person::table.on(person_content_combined::creator_id.eq(person::id));\n\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(my_person_id);\n    let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id);\n    let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    person_content_combined::table\n      .left_join(comment_join)\n      .inner_join(post_join)\n      .inner_join(item_creator_join)\n      .inner_join(community_join())\n      .left_join(image_details_join())\n      .left_join(creator_community_actions_join())\n      .left_join(creator_local_user_admin_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(my_local_user_admin_join)\n      .left_join(my_community_actions_join)\n      .left_join(my_post_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_comment_actions_join)\n  }\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    user: Option<&LocalUserView>,\n    local_instance_id: InstanceId,\n  ) -> LemmyResult<PagedResponse<PostCommentCombinedView>> {\n    let my_local_user = user.as_ref().map(|u| &u.local_user);\n    let my_person_id = my_local_user.person_id();\n\n    let limit = limit_fetch(self.limit, self.no_limit)?;\n    // Notes: since the post_id and comment_id are optional columns,\n    // many joins must use an OR condition.\n    // For example, the creator must be the person table joined to either:\n    // - post.creator_id\n    // - comment.creator_id\n    let mut query = Self::joins(my_person_id, local_instance_id)\n      // The creator id filter\n      .filter(person_content_combined::creator_id.eq(self.creator_id))\n      .select(PostCommentCombinedViewInternal::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(type_) = self.type_ {\n      query = match type_ {\n        PersonContentType::All => query,\n        PersonContentType::Comments => {\n          query.filter(person_content_combined::comment_id.is_not_null())\n        }\n        PersonContentType::Posts => query.filter(person_content_combined::post_id.is_not_null()),\n      }\n    }\n\n    // Check permissions to view private community content.\n    // Specifically, if the community is private then only accepted followers may view its\n    // content, otherwise it is filtered out. Admins can view private community content\n    // without restriction.\n    if !my_local_user.is_admin() {\n      query = query.filter(\n        community::visibility\n          .ne(CommunityVisibility::Private)\n          .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n      );\n    }\n\n    // Sorting by published\n    let paginated_query = PostCommentCombinedViewWrapper::paginate(\n      query,\n      &self.page_cursor,\n      SortDirection::Desc,\n      pool,\n      None,\n    )\n    .await?\n    .then_order_by(key::published_at)\n    // Tie breaker\n    .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<PostCommentCombinedViewInternal>(conn)\n      .await?;\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .filter_map(InternalToCombinedView::map_to_enum)\n      .map(PostCommentCombinedViewWrapper)\n      .collect();\n\n    let res = paginate_response(out, limit, self.page_cursor)?;\n    Ok(PagedResponse {\n      items: res.items.into_iter().map(|i| i.0).collect(),\n      next_page: res.next_page,\n      prev_page: res.prev_page,\n    })\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use crate::impls::PersonContentCombinedQuery;\n  use lemmy_db_schema::{\n    source::{\n      comment::{Comment, CommentInsertForm},\n      community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm},\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n    },\n    traits::Followable,\n  };\n  use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};\n  use lemmy_db_views_local_user::LocalUserView;\n  use lemmy_db_views_post_comment_combined::PostCommentCombinedView;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    private_community: Community,\n    timmy: Person,\n    timmy_view: LocalUserView,\n    sara: Person,\n    timmy_post: Post,\n    timmy_post_2: Post,\n    sara_post: Post,\n    timmy_comment: Comment,\n    sara_comment: Comment,\n    sara_comment_2: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_form = PersonInsertForm::test_form(instance.id, \"timmy_pcv\");\n    let timmy = Person::create(pool, &timmy_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id);\n    let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n    let timmy_view = LocalUserView {\n      local_user: timmy_local_user,\n      person: timmy.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara_pcv\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"test community pcv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let private_community_form = CommunityInsertForm {\n      visibility: Some(CommunityVisibility::Private),\n      ..CommunityInsertForm::new(\n        instance.id,\n        \"private community pcv\".to_string(),\n        \"nada\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let private_community = Community::create(pool, &private_community_form).await?;\n\n    let timmy_post_form = PostInsertForm::new(\"timmy post prv\".into(), timmy.id, community.id);\n    let timmy_post = Post::create(pool, &timmy_post_form).await?;\n\n    let timmy_post_form_2 = PostInsertForm::new(\"timmy post prv 2\".into(), timmy.id, community.id);\n    let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?;\n\n    let sara_post_form = PostInsertForm::new(\"sara post prv\".into(), sara.id, community.id);\n    let sara_post = Post::create(pool, &sara_post_form).await?;\n\n    let timmy_private_comm_post_form = PostInsertForm::new(\n      \"timmy private post prv 2\".into(),\n      timmy.id,\n      private_community.id,\n    );\n    let timmy_private_comm_post = Post::create(pool, &timmy_private_comm_post_form).await?;\n\n    let timmy_comment_form =\n      CommentInsertForm::new(timmy.id, timmy_post.id, \"timmy comment prv\".into());\n    let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?;\n\n    let sara_comment_form =\n      CommentInsertForm::new(sara.id, timmy_post.id, \"sara comment prv\".into());\n    let sara_comment = Comment::create(pool, &sara_comment_form, None).await?;\n\n    let sara_comment_form_2 =\n      CommentInsertForm::new(sara.id, timmy_post_2.id, \"sara comment prv 2\".into());\n    let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?;\n\n    let timmy_private_comm_comment_form = CommentInsertForm::new(\n      timmy.id,\n      timmy_private_comm_post.id,\n      \"timmy private comment prv\".into(),\n    );\n    let _timmy_private_comm_comment =\n      Comment::create(pool, &timmy_private_comm_comment_form, None).await?;\n\n    Ok(Data {\n      instance,\n      private_community,\n      timmy,\n      timmy_view,\n      sara,\n      timmy_post,\n      timmy_post_2,\n      sara_post,\n      timmy_comment,\n      sara_comment,\n      sara_comment_2,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn combined() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Do a batch read of timmy\n    let timmy_content = PersonContentCombinedQuery::new(data.timmy.id)\n      .list(pool, None, data.instance.id)\n      .await?;\n    assert_eq!(3, timmy_content.len());\n\n    // Make sure the types are correct\n    if let PostCommentCombinedView::Comment(v) = &timmy_content[0] {\n      assert_eq!(data.timmy_comment.id, v.comment.id);\n      assert_eq!(data.timmy.id, v.creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Post(v) = &timmy_content[1] {\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Post(v) = &timmy_content[2] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Do a batch read of sara\n    let sara_content = PersonContentCombinedQuery::new(data.sara.id)\n      .list(pool, None, data.instance.id)\n      .await?;\n    assert_eq!(3, sara_content.len());\n\n    // Make sure the report types are correct\n    if let PostCommentCombinedView::Comment(v) = &sara_content[0] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.sara.id, v.creator.id);\n      // This one was to timmy_post_2\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &sara_content[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara.id, v.creator.id);\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Post(v) = &sara_content[2] {\n      assert_eq!(data.sara_post.id, v.post.id);\n      assert_eq!(data.sara.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn private_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Make sure timmy can't see private content\n    let timmy_content = PersonContentCombinedQuery::new(data.timmy.id)\n      .list(pool, Some(&data.timmy_view), data.instance.id)\n      .await?;\n    assert_eq!(3, timmy_content.len());\n\n    // Approve timmy to the community\n    let follow_form = CommunityFollowerForm::new(\n      data.private_community.id,\n      data.timmy.id,\n      CommunityFollowerState::ApprovalRequired,\n    );\n\n    CommunityActions::follow(pool, &follow_form).await?;\n    CommunityActions::approve_private_community_follower(\n      pool,\n      data.private_community.id,\n      data.timmy.id,\n      data.sara.id,\n      CommunityFollowerState::Accepted,\n    )\n    .await?;\n\n    // Make sure timmy can now see the content\n    let timmy_content_after_approved = PersonContentCombinedQuery::new(data.timmy.id)\n      .list(pool, Some(&data.timmy_view), data.instance.id)\n      .await?;\n    assert_eq!(5, timmy_content_after_approved.len());\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/person_content_combined/src/lib.rs",
    "content": "use lemmy_db_schema::PersonContentType;\nuse lemmy_db_schema_file::PersonId;\n#[cfg(feature = \"full\")]\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets a person's content (posts and comments)\n///\n/// Either person_id, or username are required.\npub struct ListPersonContent {\n  pub type_: Option<PersonContentType>,\n  pub person_id: Option<PersonId>,\n  /// Example: dessalines , or dessalines@xyz.tld\n  pub username: Option<String>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/person_liked_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_person_liked_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n  \"lemmy_db_views_post_comment_combined/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_views_post_comment_combined/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_views_post_comment_combined = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/person_liked_combined/src/impls.rs",
    "content": "use crate::{LocalUserView, Serialize};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  dsl::not,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  LikeType,\n  PersonContentType,\n  source::combined::person_liked::{PersonLikedCombined, person_liked_combined_keys as key},\n  traits::InternalToCombinedView,\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  joins::{\n    community_join,\n    creator_community_actions_join,\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    creator_local_user_admin_join,\n    image_details_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{comment, person, person_liked_combined, post},\n};\nuse lemmy_db_views_post_comment_combined::{\n  PostCommentCombinedView,\n  PostCommentCombinedViewInternal,\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::Deserialize;\n\n#[derive(Default)]\npub struct PersonLikedCombinedQuery {\n  pub type_: Option<PersonContentType>,\n  pub like_type: Option<LikeType>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub no_limit: Option<bool>,\n}\n\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\nstruct PostCommentCombinedViewWrapper(PostCommentCombinedView);\n\nimpl PaginationCursorConversion for PostCommentCombinedViewWrapper {\n  type PaginatedType = PersonLikedCombined;\n\n  fn to_cursor(&self) -> CursorData {\n    let (prefix, id) = match &self.0 {\n      PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0),\n      PostCommentCombinedView::Post(v) => ('P', v.post.id.0),\n    };\n    CursorData::new_with_prefix(prefix, id)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let (prefix, id) = cursor.id_and_prefix()?;\n\n    let mut query = person_liked_combined::table\n      .select(Self::PaginatedType::as_select())\n      .into_boxed();\n\n    query = match prefix {\n      'C' => query.filter(person_liked_combined::comment_id.eq(id)),\n      'P' => query.filter(person_liked_combined::post_id.eq(id)),\n      _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()),\n    };\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\nimpl PersonLikedCombinedQuery {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  pub(crate) fn joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ {\n    let comment_join =\n      comment::table.on(person_liked_combined::comment_id.eq(comment::id.nullable()));\n\n    let post_join = post::table.on(\n      person_liked_combined::post_id\n        .eq(post::id.nullable())\n        .or(comment::post_id.eq(post::id)),\n    );\n\n    let item_creator_join = person::table.on(person_liked_combined::creator_id.eq(person::id));\n\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(Some(my_person_id));\n    let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person_id));\n    let my_comment_actions_join: my_comment_actions_join =\n      my_comment_actions_join(Some(my_person_id));\n    let my_local_user_admin_join: my_local_user_admin_join =\n      my_local_user_admin_join(Some(my_person_id));\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person_id));\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    person_liked_combined::table\n      .left_join(comment_join)\n      .inner_join(post_join)\n      .inner_join(community_join())\n      .inner_join(item_creator_join)\n      .left_join(image_details_join())\n      .left_join(creator_community_actions_join())\n      .left_join(creator_local_user_admin_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      // The my_'s have to come last to avoid stack overflows\n      .left_join(my_post_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_comment_actions_join)\n      .left_join(my_community_actions_join)\n      .left_join(my_local_user_admin_join)\n  }\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    user: &LocalUserView,\n  ) -> LemmyResult<PagedResponse<PostCommentCombinedView>> {\n    let my_person_id = user.local_user.person_id;\n    let local_instance_id = user.person.instance_id;\n\n    let limit = limit_fetch(self.limit, self.no_limit)?;\n\n    let mut query = Self::joins(my_person_id, local_instance_id)\n      .filter(person_liked_combined::person_id.eq(my_person_id))\n      .select(PostCommentCombinedViewInternal::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(type_) = self.type_ {\n      query = match type_ {\n        PersonContentType::All => query,\n        PersonContentType::Comments => {\n          query.filter(person_liked_combined::comment_id.is_not_null())\n        }\n        PersonContentType::Posts => query.filter(person_liked_combined::post_id.is_not_null()),\n      }\n    }\n\n    if let Some(like_type) = self.like_type {\n      query = match like_type {\n        LikeType::All => query,\n        LikeType::LikedOnly => query.filter(person_liked_combined::vote_is_upvote),\n        LikeType::DislikedOnly => query.filter(not(person_liked_combined::vote_is_upvote)),\n      }\n    }\n\n    // Sorting by liked desc\n    let paginated_query = PostCommentCombinedViewWrapper::paginate(\n      query,\n      &self.page_cursor,\n      SortDirection::Desc,\n      pool,\n      None,\n    )\n    .await?\n    .then_order_by(key::voted_at)\n    // Tie breaker\n    .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<PostCommentCombinedViewInternal>(conn)\n      .await?;\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .filter_map(InternalToCombinedView::map_to_enum)\n      .map(PostCommentCombinedViewWrapper)\n      .collect();\n\n    let res = paginate_response(out, limit, self.page_cursor)?;\n    Ok(PagedResponse {\n      items: res.items.into_iter().map(|i| i.0).collect(),\n      next_page: res.next_page,\n      prev_page: res.prev_page,\n    })\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use crate::{LocalUserView, impls::PersonLikedCombinedQuery};\n  use lemmy_db_schema::{\n    LikeType,\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm},\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    },\n    traits::Likeable,\n  };\n  use lemmy_db_views_post_comment_combined::PostCommentCombinedView;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    timmy: Person,\n    timmy_view: LocalUserView,\n    sara: Person,\n    timmy_post: Post,\n    sara_comment: Comment,\n    sara_comment_2: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_form = PersonInsertForm::test_form(instance.id, \"timmy_pcv\");\n    let timmy = Person::create(pool, &timmy_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id);\n    let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n    let timmy_view = LocalUserView {\n      local_user: timmy_local_user,\n      person: timmy.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara_pcv\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"test community pcv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let timmy_post_form = PostInsertForm::new(\"timmy post prv\".into(), timmy.id, community.id);\n    let timmy_post = Post::create(pool, &timmy_post_form).await?;\n\n    let timmy_post_form_2 = PostInsertForm::new(\"timmy post prv 2\".into(), timmy.id, community.id);\n    let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?;\n\n    let sara_post_form = PostInsertForm::new(\"sara post prv\".into(), sara.id, community.id);\n    let _sara_post = Post::create(pool, &sara_post_form).await?;\n\n    let timmy_comment_form =\n      CommentInsertForm::new(timmy.id, timmy_post.id, \"timmy comment prv\".into());\n    let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?;\n\n    let sara_comment_form =\n      CommentInsertForm::new(sara.id, timmy_post.id, \"sara comment prv\".into());\n    let sara_comment = Comment::create(pool, &sara_comment_form, None).await?;\n\n    let sara_comment_form_2 =\n      CommentInsertForm::new(sara.id, timmy_post_2.id, \"sara comment prv 2\".into());\n    let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?;\n\n    Ok(Data {\n      instance,\n      timmy,\n      timmy_view,\n      sara,\n      timmy_post,\n      sara_comment,\n      sara_comment_2,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_combined() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Do a batch read of timmy liked\n    let timmy_liked = PersonLikedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(0, timmy_liked.len());\n\n    // Like a few things\n    let like_sara_comment_2 =\n      CommentLikeForm::new(data.sara_comment_2.id, data.timmy.id, Some(true));\n    CommentActions::like(pool, &like_sara_comment_2).await?;\n\n    let dislike_sara_comment =\n      CommentLikeForm::new(data.sara_comment.id, data.timmy.id, Some(false));\n    CommentActions::like(pool, &dislike_sara_comment).await?;\n\n    let post_like_form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, Some(true));\n    PostActions::like(pool, &post_like_form).await?;\n\n    let timmy_liked_all = PersonLikedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(3, timmy_liked_all.len());\n\n    // Make sure the types and order are correct\n    if let PostCommentCombinedView::Post(v) = &timmy_liked_all[0] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n      assert_eq!(\n        Some(true),\n        v.post_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &timmy_liked_all[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n      assert_eq!(\n        Some(false),\n        v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &timmy_liked_all[2] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n      assert_eq!(\n        Some(true),\n        v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    let timmy_disliked = PersonLikedCombinedQuery {\n      like_type: Some(LikeType::DislikedOnly),\n      ..PersonLikedCombinedQuery::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n    assert_eq!(1, timmy_disliked.len());\n\n    if let PostCommentCombinedView::Comment(v) = &timmy_disliked[0] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n      assert_eq!(\n        Some(false),\n        v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Try doing the opposite of the previous comment/post like or dislike,\n    // to verify person_like_combined update on conflict triggers are working.\n    let like_sara_comment = CommentLikeForm::new(data.sara_comment.id, data.timmy.id, Some(true));\n    CommentActions::like(pool, &like_sara_comment).await?;\n\n    let post_dislike_form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, Some(false));\n    PostActions::like(pool, &post_dislike_form).await?;\n\n    let timmy_likes_opposite = PersonLikedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(3, timmy_likes_opposite.len());\n\n    if let PostCommentCombinedView::Post(v) = &timmy_likes_opposite[0] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n      assert_eq!(\n        Some(false),\n        v.post_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &timmy_likes_opposite[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n      assert_eq!(\n        Some(true),\n        v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Try unliking 2 things\n    let form = CommentLikeForm::new(data.sara_comment.id, data.timmy.id, None);\n    CommentActions::like(pool, &form).await?;\n    let form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, None);\n    PostActions::like(pool, &form).await?;\n\n    let timmy_likes_removed = PersonLikedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(1, timmy_likes_removed.len());\n\n    if let PostCommentCombinedView::Comment(v) = &timmy_likes_removed[0] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/person_liked_combined/src/lib.rs",
    "content": "use lemmy_db_schema::{LikeType, PersonContentType};\n#[cfg(feature = \"full\")]\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets your liked / disliked posts\npub struct ListPersonLiked {\n  pub type_: Option<PersonContentType>,\n  pub like_type: Option<LikeType>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/person_saved_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_person_saved_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n  \"lemmy_db_views_post_comment_combined/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_views_post_comment_combined/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_views_post_comment_combined = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nserde_with = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/person_saved_combined/src/impls.rs",
    "content": "use crate::LocalUserView;\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  PersonContentType,\n  source::combined::person_saved::{PersonSavedCombined, person_saved_combined_keys as key},\n  traits::InternalToCombinedView,\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  joins::{\n    community_join,\n    creator_community_actions_join,\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    creator_local_user_admin_join,\n    image_details_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{comment, person, person_saved_combined, post},\n};\nuse lemmy_db_views_post_comment_combined::{\n  PostCommentCombinedView,\n  PostCommentCombinedViewInternal,\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Default)]\npub struct PersonSavedCombinedQuery {\n  pub type_: Option<PersonContentType>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  pub no_limit: Option<bool>,\n}\n\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\nstruct PostCommentCombinedViewWrapper(PostCommentCombinedView);\n\nimpl PaginationCursorConversion for PostCommentCombinedViewWrapper {\n  type PaginatedType = PersonSavedCombined;\n\n  fn to_cursor(&self) -> CursorData {\n    let (prefix, id) = match &self.0 {\n      PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0),\n      PostCommentCombinedView::Post(v) => ('P', v.post.id.0),\n    };\n    CursorData::new_with_prefix(prefix, id)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let (prefix, id) = cursor.id_and_prefix()?;\n\n    let mut query = person_saved_combined::table\n      .select(Self::PaginatedType::as_select())\n      .into_boxed();\n\n    query = match prefix {\n      'C' => query.filter(person_saved_combined::comment_id.eq(id)),\n      'P' => query.filter(person_saved_combined::post_id.eq(id)),\n      _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()),\n    };\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\nimpl PersonSavedCombinedQuery {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ {\n    let comment_join =\n      comment::table.on(person_saved_combined::comment_id.eq(comment::id.nullable()));\n\n    let post_join = post::table.on(\n      person_saved_combined::post_id\n        .eq(post::id.nullable())\n        .or(comment::post_id.eq(post::id)),\n    );\n\n    let item_creator_join = person::table.on(person_saved_combined::creator_id.eq(person::id));\n\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(Some(my_person_id));\n    let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person_id));\n    let my_comment_actions_join: my_comment_actions_join =\n      my_comment_actions_join(Some(my_person_id));\n    let my_local_user_admin_join: my_local_user_admin_join =\n      my_local_user_admin_join(Some(my_person_id));\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person_id));\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    person_saved_combined::table\n      .left_join(comment_join)\n      .inner_join(post_join)\n      .inner_join(item_creator_join)\n      .inner_join(community_join())\n      .left_join(image_details_join())\n      .left_join(creator_community_actions_join())\n      .left_join(creator_local_user_admin_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(my_community_actions_join)\n      .left_join(my_local_user_admin_join)\n      .left_join(my_post_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_comment_actions_join)\n  }\n\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    user: &LocalUserView,\n  ) -> LemmyResult<PagedResponse<PostCommentCombinedView>> {\n    let my_person_id = user.local_user.person_id;\n    let local_instance_id = user.person.instance_id;\n\n    let limit = limit_fetch(self.limit, self.no_limit)?;\n\n    let mut query = Self::joins(my_person_id, local_instance_id)\n      .filter(person_saved_combined::person_id.eq(my_person_id))\n      .select(PostCommentCombinedViewInternal::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(type_) = self.type_ {\n      query = match type_ {\n        PersonContentType::All => query,\n        PersonContentType::Comments => {\n          query.filter(person_saved_combined::comment_id.is_not_null())\n        }\n        PersonContentType::Posts => query.filter(person_saved_combined::post_id.is_not_null()),\n      }\n    }\n\n    // Sorting by saved desc\n    let paginated_query = PostCommentCombinedViewWrapper::paginate(\n      query,\n      &self.page_cursor,\n      SortDirection::Desc,\n      pool,\n      None,\n    )\n    .await?\n    .then_order_by(key::saved_at)\n    // Tie breaker\n    .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<PostCommentCombinedViewInternal>(conn)\n      .await?;\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .filter_map(InternalToCombinedView::map_to_enum)\n      .map(PostCommentCombinedViewWrapper)\n      .collect();\n\n    let res = paginate_response(out, limit, self.page_cursor)?;\n\n    Ok(PagedResponse {\n      items: res.items.into_iter().map(|i| i.0).collect(),\n      next_page: res.next_page,\n      prev_page: res.prev_page,\n    })\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use super::*;\n  use crate::{LocalUserView, impls::PersonSavedCombinedQuery};\n  use lemmy_db_schema::{\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentSavedForm},\n      community::{Community, CommunityInsertForm},\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostSavedForm},\n    },\n    traits::Saveable,\n  };\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    timmy: Person,\n    timmy_view: LocalUserView,\n    sara: Person,\n    timmy_post: Post,\n    sara_comment: Comment,\n    sara_comment_2: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_form = PersonInsertForm::test_form(instance.id, \"timmy_pcv\");\n    let timmy = Person::create(pool, &timmy_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id);\n    let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n    let timmy_view = LocalUserView {\n      local_user: timmy_local_user,\n      person: timmy.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara_pcv\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      instance.id,\n      \"test community pcv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &community_form).await?;\n\n    let timmy_post_form = PostInsertForm::new(\"timmy post prv\".into(), timmy.id, community.id);\n    let timmy_post = Post::create(pool, &timmy_post_form).await?;\n\n    let timmy_post_form_2 = PostInsertForm::new(\"timmy post prv 2\".into(), timmy.id, community.id);\n    let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?;\n\n    let sara_post_form = PostInsertForm::new(\"sara post prv\".into(), sara.id, community.id);\n    let _sara_post = Post::create(pool, &sara_post_form).await?;\n\n    let timmy_comment_form =\n      CommentInsertForm::new(timmy.id, timmy_post.id, \"timmy comment prv\".into());\n    let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?;\n\n    let sara_comment_form =\n      CommentInsertForm::new(sara.id, timmy_post.id, \"sara comment prv\".into());\n    let sara_comment = Comment::create(pool, &sara_comment_form, None).await?;\n\n    let sara_comment_form_2 =\n      CommentInsertForm::new(sara.id, timmy_post_2.id, \"sara comment prv 2\".into());\n    let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?;\n\n    Ok(Data {\n      instance,\n      timmy,\n      timmy_view,\n      sara,\n      timmy_post,\n      sara_comment,\n      sara_comment_2,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_combined() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Do a batch read of timmy saved\n    let timmy_saved = PersonSavedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(0, timmy_saved.len());\n\n    // Save a few things\n    let save_sara_comment_2 =\n      CommentSavedForm::new(data.timmy_view.person.id, data.sara_comment_2.id);\n    CommentActions::save(pool, &save_sara_comment_2).await?;\n\n    let save_sara_comment = CommentSavedForm::new(data.timmy_view.person.id, data.sara_comment.id);\n    CommentActions::save(pool, &save_sara_comment).await?;\n\n    let post_save_form = PostSavedForm::new(data.timmy_post.id, data.timmy.id);\n    PostActions::save(pool, &post_save_form).await?;\n\n    let timmy_saved = PersonSavedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(3, timmy_saved.len());\n\n    // Make sure the types and order are correct\n    if let PostCommentCombinedView::Post(v) = &timmy_saved[0] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.post.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &timmy_saved[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let PostCommentCombinedView::Comment(v) = &timmy_saved[2] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Try unsaving 2 things\n    CommentActions::unsave(pool, &save_sara_comment).await?;\n    PostActions::unsave(pool, &post_save_form).await?;\n\n    let timmy_saved = PersonSavedCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_eq!(1, timmy_saved.len());\n\n    if let PostCommentCombinedView::Comment(v) = &timmy_saved[0] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.sara.id, v.comment.creator_id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/person_saved_combined/src/lib.rs",
    "content": "use lemmy_db_schema::PersonContentType;\n#[cfg(feature = \"full\")]\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets your saved posts and comments\npub struct ListPersonSaved {\n  pub type_: Option<PersonContentType>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/post/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_post\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\", \"lemmy_db_schema_file/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nchrono = { workspace = true }\ntracing = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\n\n[dev-dependencies]\nlemmy_db_views_local_user = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\nurl = { workspace = true }\ntest-context = \"0.5.5\"\ndiesel-uplete.workspace = true\n"
  },
  {
    "path": "crates/db_views/post/src/api.rs",
    "content": "use crate::PostView;\nuse lemmy_db_schema::{\n  PostFeatureType,\n  newtypes::{CommunityId, CommunityTagId, LanguageId, MultiCommunityId, PostId},\n};\nuse lemmy_db_schema_file::enums::{ListingType, PostNotificationsMode, PostSortType};\nuse lemmy_diesel_utils::{dburl::DbUrl, pagination::PaginationCursor};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a post.\npub struct CreatePost {\n  pub name: String,\n  pub community_id: CommunityId,\n  pub url: Option<String>,\n  /// An optional body for the post in markdown.\n  pub body: Option<String>,\n  /// An optional alt_text, usable for image posts.\n  pub alt_text: Option<String>,\n  /// A honeypot to catch bots. Should be None.\n  pub honeypot: Option<String>,\n  pub nsfw: Option<bool>,\n  pub language_id: Option<LanguageId>,\n  /// Instead of fetching a thumbnail, use a custom one.\n  pub custom_thumbnail: Option<String>,\n  pub tags: Option<Vec<CommunityTagId>>,\n  /// Time when this post should be scheduled. Null means publish immediately.\n  pub scheduled_publish_time_at: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Like a post.\npub struct CreatePostLike {\n  pub post_id: PostId,\n  /// True means Upvote, False means Downvote, and None means remove vote.\n  pub is_upvote: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete a post.\npub struct DeletePost {\n  pub post_id: PostId,\n  pub deleted: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit a post.\npub struct EditPost {\n  pub post_id: PostId,\n  pub name: Option<String>,\n  pub url: Option<String>,\n  /// An optional body for the post in markdown.\n  pub body: Option<String>,\n  /// An optional alt_text, usable for image posts.\n  pub alt_text: Option<String>,\n  pub nsfw: Option<bool>,\n  pub language_id: Option<LanguageId>,\n  /// Instead of fetching a thumbnail, use a custom one.\n  pub custom_thumbnail: Option<String>,\n  /// Time when this post should be scheduled. Null means publish immediately.\n  pub scheduled_publish_time_at: Option<i64>,\n  pub tags: Option<Vec<CommunityTagId>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Mods can change some metadata for posts\npub struct ModEditPost {\n  pub post_id: PostId,\n  pub nsfw: Option<bool>,\n  pub tags: Option<Vec<CommunityTagId>>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Feature a post (stickies / pins to the top).\npub struct FeaturePost {\n  pub post_id: PostId,\n  pub featured: bool,\n  pub feature_type: PostFeatureType,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Change notification settings for a post\npub struct EditPostNotifications {\n  pub post_id: PostId,\n  pub mode: PostNotificationsMode,\n}\n\n#[skip_serializing_none]\n#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Get a list of posts.\npub struct GetPosts {\n  pub type_: Option<ListingType>,\n  pub sort: Option<PostSortType>,\n  /// Filter to within a given time range, in seconds.\n  /// IE 60 would give results for the past minute.\n  /// Use Zero to override the local_site and local_user time_range.\n  pub time_range_seconds: Option<i32>,\n  pub community_id: Option<CommunityId>,\n  pub community_name: Option<String>,\n  pub multi_community_id: Option<MultiCommunityId>,\n  pub multi_community_name: Option<String>,\n  pub show_hidden: Option<bool>,\n  /// If true, then show the read posts (even if your user setting is to hide them)\n  pub show_read: Option<bool>,\n  /// If true, then show the nsfw posts (even if your user setting is to hide them)\n  pub show_nsfw: Option<bool>,\n  /// If false, then show posts with media attached (even if your user setting is to hide them)\n  pub hide_media: Option<bool>,\n  /// Whether to automatically mark fetched posts as read.\n  pub mark_as_read: Option<bool>,\n  /// If true, then only show posts with no comments\n  pub no_comments_only: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  /// For backwards compat with API v3 (not available on API v4)\n  #[serde(skip)]\n  pub page: Option<i64>,\n  pub limit: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Get metadata for a given site.\npub struct GetSiteMetadata {\n  pub url: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The site metadata response.\npub struct GetSiteMetadataResponse {\n  pub metadata: LinkMetadata,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Default, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Site metadata, from its opengraph tags.\npub struct LinkMetadata {\n  #[serde(flatten)]\n  pub opengraph_data: OpenGraphData,\n  pub content_type: Option<String>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Hide a post from list views\npub struct HidePost {\n  pub post_id: PostId,\n  pub hide: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// List post likes. Admins-only.\npub struct ListPostLikes {\n  pub post_id: PostId,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Lock a post (prevent new comments).\npub struct LockPost {\n  pub post_id: PostId,\n  pub locked: bool,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Mark a post as read.\npub struct MarkPostAsRead {\n  pub post_id: PostId,\n  pub read: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Default, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Site metadata, from its opengraph tags.\npub struct OpenGraphData {\n  pub title: Option<String>,\n  pub description: Option<String>,\n  pub image: Option<DbUrl>,\n  pub image_width: Option<u16>,\n  pub image_height: Option<u16>,\n  pub embed_video_url: Option<DbUrl>,\n  pub video_width: Option<u16>,\n  pub video_height: Option<u16>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PostResponse {\n  pub post_view: PostView,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Purges a post from the database. This will delete all content attached to that post.\npub struct PurgePost {\n  pub post_id: PostId,\n  pub reason: String,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Remove a post (only doable by mods).\npub struct RemovePost {\n  pub post_id: PostId,\n  pub removed: bool,\n  pub reason: String,\n  /// Setting this will override whatever `removed` was set to,\n  /// leave as null or unset to act just on the post itself.\n  pub remove_children: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Save / bookmark a post.\npub struct SavePost {\n  pub post_id: PostId,\n  pub save: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Mark several posts as read.\npub struct MarkManyPostsAsRead {\n  pub post_ids: Vec<PostId>,\n  pub read: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Creates a warning against a post and notifies the user.\npub struct CreatePostWarning {\n  pub post_id: PostId,\n  pub reason: String,\n}\n"
  },
  {
    "path": "crates/db_views/post/src/db_perf/mod.rs",
    "content": "mod series;\n\nuse crate::{db_perf::series::ValuesFromSeries, impls::PostQuery};\nuse diesel::{\n  ExpressionMethods,\n  IntoSql,\n  dsl::{self, sql},\n  sql_types,\n};\nuse diesel_async::{RunQueryDsl, SimpleAsyncConnection};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityInsertForm},\n  instance::Instance,\n  person::{Person, PersonInsertForm},\n  site::Site,\n};\nuse lemmy_db_schema_file::{enums::PostSortType, schema::post};\nuse lemmy_diesel_utils::{\n  connection::{build_db_pool, get_conn},\n  traits::Crud,\n  utils::now,\n};\nuse lemmy_utils::error::LemmyResult;\nuse serial_test::serial;\nuse std::{fmt::Display, num::NonZeroU32, str::FromStr};\nuse url::Url;\n\n#[derive(Debug)]\nstruct CmdArgs {\n  communities: NonZeroU32,\n  people: NonZeroU32,\n  posts: NonZeroU32,\n  read_post_pages: u32,\n  explain_insertions: bool,\n}\n\nfn get_option<T: FromStr + Display>(suffix: &str, default: T) -> Result<T, T::Err> {\n  let name = format!(\"LEMMY_{suffix}\");\n  if let Some(value) = std::env::var_os(&name) {\n    value.to_string_lossy().parse()\n  } else {\n    println!(\"🔧 using default env var {name}={default}\");\n    Ok(default)\n  }\n}\n\n#[tokio::test]\n#[serial]\nasync fn db_perf() -> LemmyResult<()> {\n  let args = CmdArgs {\n    communities: get_option(\"COMMUNITIES\", 3.try_into()?)?,\n    people: get_option(\"PEOPLE\", 3.try_into()?)?,\n    posts: get_option(\"POSTS\", 100000.try_into()?)?,\n    read_post_pages: get_option(\"READ_POST_PAGES\", 0)?,\n    explain_insertions: get_option(\"EXPLAIN_INSERTIONS\", false)?,\n  };\n  let pool = &build_db_pool()?;\n  let pool = &mut pool.into();\n  let conn = &mut get_conn(pool).await?;\n\n  if args.explain_insertions {\n    // log_nested_statements is enabled to log trigger execution\n    conn\n      .batch_execute(\n        \"SET auto_explain.log_min_duration = 0; SET auto_explain.log_nested_statements = on;\",\n      )\n      .await?;\n  }\n\n  let instance = Instance::read_or_create(&mut conn.into(), \"reddit.com\").await?;\n\n  println!(\"🫃 creating {} people\", args.people);\n  let mut person_ids = vec![];\n  for i in 0..args.people.get() {\n    let form = PersonInsertForm::test_form(instance.id, &format!(\"p{i}\"));\n    person_ids.push(Person::create(&mut conn.into(), &form).await?.id);\n  }\n\n  println!(\"🌍 creating {} communities\", args.communities);\n  let mut community_ids = vec![];\n  for i in 0..args.communities.get() {\n    let form = CommunityInsertForm::new(\n      instance.id,\n      format!(\"c{i}\"),\n      i.to_string(),\n      \"pubkey\".to_string(),\n    );\n    community_ids.push(Community::create(&mut conn.into(), &form).await?.id);\n  }\n\n  let post_batches = args.people.get() * args.communities.get();\n  let posts_per_batch = args.posts.get() / post_batches;\n  let num_posts: usize = (post_batches * posts_per_batch).try_into()?;\n  println!(\n    \"📜 creating {} posts ({} featured in community)\",\n    num_posts, post_batches\n  );\n  let mut num_inserted_posts = 0;\n  // TODO: progress bar\n  for person_id in &person_ids {\n    for community_id in &community_ids {\n      let n = dsl::insert_into(post::table)\n        .values(ValuesFromSeries {\n          start: 1,\n          stop: posts_per_batch.into(),\n          selection: (\n            \"AAAAAAAAAAA\".into_sql::<sql_types::Text>(),\n            person_id.into_sql::<sql_types::Integer>(),\n            community_id.into_sql::<sql_types::Integer>(),\n            series::current_value.eq(1),\n            now()\n              - sql::<sql_types::Interval>(\"make_interval(secs => \")\n                .bind::<sql_types::BigInt, _>(series::current_value)\n                .sql(\")\"),\n          ),\n        })\n        .into_columns((\n          post::name,\n          post::creator_id,\n          post::community_id,\n          post::featured_community,\n          post::published_at,\n        ))\n        .execute(conn)\n        .await?;\n      num_inserted_posts += n;\n    }\n  }\n  // Make sure the println above shows the correct amount\n  assert_eq!(num_inserted_posts, num_posts);\n\n  // Manually trigger and wait for a statistics update to ensure consistent and high amount of\n  // accuracy in the statistics used for query planning\n  println!(\"🧮 updating database statistics\");\n  conn.batch_execute(\"ANALYZE;\").await?;\n\n  // Enable auto_explain\n  conn\n    .batch_execute(\n      \"SET auto_explain.log_min_duration = 0; SET auto_explain.log_nested_statements = off;\",\n    )\n    .await?;\n\n  // TODO: show execution duration stats\n  let mut page_cursor = None;\n  for page_num in 1..=args.read_post_pages {\n    println!(\n      \"👀 getting page {page_num} of posts (pagination cursor used: {})\",\n      page_cursor.is_some()\n    );\n\n    // TODO: include local_user\n    let post_views = PostQuery {\n      community_id: community_ids.as_slice().first().cloned(),\n      sort: Some(PostSortType::New),\n      limit: Some(20),\n      page_cursor,\n      ..Default::default()\n    }\n    .list(&site()?, &mut conn.into())\n    .await?;\n\n    if let Some(cursor) = post_views.next_page {\n      println!(\"👀 getting pagination cursor data for next page\");\n      page_cursor = Some(cursor);\n    } else {\n      println!(\"👀 reached empty page\");\n      break;\n    }\n  }\n\n  // Delete everything, which might prevent problems if this is not run using scripts/db_perf.sh\n  Instance::delete(&mut conn.into(), instance.id).await?;\n\n  if let Ok(path) = std::env::var(\"PGDATA\") {\n    println!(\"🪵 query plans written in {path}/log\");\n  }\n\n  Ok(())\n}\n\nfn site() -> LemmyResult<Site> {\n  Ok(Site {\n    id: Default::default(),\n    name: String::new(),\n    sidebar: None,\n    published_at: Default::default(),\n    updated_at: None,\n    icon: None,\n    banner: None,\n    summary: None,\n    ap_id: Url::parse(\"http://example.com\")?.into(),\n    last_refreshed_at: Default::default(),\n    inbox_url: Url::parse(\"http://example.com\")?.into(),\n    private_key: None,\n    public_key: String::new(),\n    instance_id: Default::default(),\n    content_warning: None,\n  })\n}\n"
  },
  {
    "path": "crates/db_views/post/src/db_perf/series.rs",
    "content": "use diesel::{\n  AppearsOnTable,\n  Expression,\n  Insertable,\n  QueryId,\n  SelectableExpression,\n  dsl,\n  expression::{ValidGrouping, is_aggregate},\n  pg::Pg,\n  query_builder::{AsQuery, AstPass, QueryFragment},\n  result::Error,\n  sql_types,\n};\n\n/// Gererates a series of rows for insertion.\n///\n/// An inclusive range is created from `start` and `stop`. A row for each number is generated using\n/// `selection`, which can be a tuple. [`current_value`] is an expression that gets the current\n/// value.\n///\n/// For example, if there's a `numbers` table with a `number` column, this inserts all numbers from\n/// 1 to 10 in a single statement:\n///\n/// ```\n/// dsl::insert_into(numbers::table)\n///   .values(ValuesFromSeries {\n///     start: 1,\n///     stop: 10,\n///     selection: series::current_value,\n///   })\n///   .into_columns(numbers::number)\n/// ```\n#[derive(QueryId)]\npub struct ValuesFromSeries<S> {\n  pub start: i64,\n  pub stop: i64,\n  pub selection: S,\n}\n\nimpl<S: QueryFragment<Pg>> QueryFragment<Pg> for ValuesFromSeries<S> {\n  fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Pg>) -> Result<(), Error> {\n    self.selection.walk_ast(out.reborrow())?;\n    out.push_sql(\" FROM generate_series(\");\n    out.push_bind_param::<sql_types::BigInt, _>(&self.start)?;\n    out.push_sql(\", \");\n    out.push_bind_param::<sql_types::BigInt, _>(&self.stop)?;\n    out.push_sql(\")\");\n\n    Ok(())\n  }\n}\n\nimpl<S: Expression> Expression for ValuesFromSeries<S> {\n  type SqlType = S::SqlType;\n}\n\nimpl<T, S: AppearsOnTable<current_value>> AppearsOnTable<T> for ValuesFromSeries<S> {}\n\nimpl<T, S: SelectableExpression<current_value>> SelectableExpression<T> for ValuesFromSeries<S> {}\n\nimpl<T, S: SelectableExpression<current_value>> Insertable<T> for ValuesFromSeries<S>\nwhere\n  dsl::select<Self>: AsQuery + Insertable<T>,\n{\n  type Values = <dsl::select<Self> as Insertable<T>>::Values;\n\n  fn values(self) -> Self::Values {\n    dsl::select(self).values()\n  }\n}\n\nimpl<S: ValidGrouping<(), IsAggregate = is_aggregate::No>> ValidGrouping<()>\n  for ValuesFromSeries<S>\n{\n  type IsAggregate = is_aggregate::No;\n}\n\n#[expect(non_camel_case_types)]\n#[derive(QueryId, Clone, Copy, Debug)]\npub struct current_value;\n\nimpl QueryFragment<Pg> for current_value {\n  fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Pg>) -> Result<(), Error> {\n    out.push_identifier(\"generate_series\")?;\n\n    Ok(())\n  }\n}\n\nimpl Expression for current_value {\n  type SqlType = sql_types::BigInt;\n}\n\nimpl AppearsOnTable<current_value> for current_value {}\n\nimpl SelectableExpression<current_value> for current_value {}\n\nimpl ValidGrouping<()> for current_value {\n  type IsAggregate = is_aggregate::No;\n}\n"
  },
  {
    "path": "crates/db_views/post/src/impls.rs",
    "content": "use crate::PostView;\nuse diesel::{\n  self,\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  PgTextExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  TextExpressionMethods,\n  debug_query,\n  dsl::{exists, not},\n  pg::Pg,\n  query_builder::AsQuery,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::{SortDirection, asc_if};\nuse lemmy_db_schema::{\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::{CommunityId, MultiCommunityId, PostId},\n  source::{\n    community::CommunityActions,\n    local_user::LocalUser,\n    person::Person,\n    post::{Post, PostActions, post_actions_keys as pa_key, post_keys as key},\n    site::Site,\n  },\n  utils::{\n    limit_fetch,\n    queries::filters::{\n      filter_blocked,\n      filter_is_subscribed,\n      filter_not_unlisted_or_is_subscribed,\n      filter_suggested_communities,\n    },\n  },\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{CommunityFollowerState, CommunityVisibility, ListingType, PostSortType},\n  joins::{\n    creator_community_actions_join,\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    image_details_join,\n    my_community_actions_join,\n    my_instance_communities_actions_join,\n    my_instance_persons_actions_join_1,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{\n    community,\n    community_actions,\n    local_user_language,\n    multi_community_entry,\n    person,\n    post,\n    post_actions,\n  },\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n  utils::{CoalesceKey, Commented, now, seconds_to_pg_interval},\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse tracing::debug;\n\nimpl PaginationCursorConversion for PostView {\n  type PaginatedType = Post;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.post.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Post::read(pool, PostId(cursor.id()?)).await\n  }\n}\n\n/// This dummy struct is necessary to allow pagination using PostAction keys\nstruct PostViewDummy(PostActions);\nimpl PaginationCursorConversion for PostViewDummy {\n  type PaginatedType = PostActions;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_multi([self.0.post_id.0, self.0.person_id.0])\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let [post_id, person_id] = cursor.multi()?;\n    PostActions::read(pool, PostId(post_id), PersonId(person_id)).await\n  }\n}\n\nimpl PostView {\n  // TODO while we can abstract the joins into a function, the selects are currently impossible to\n  // do, because they rely on a few types that aren't yet publicly exported in diesel:\n  // https://github.com/diesel-rs/diesel/issues/4462\n\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(my_person_id);\n    let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id);\n    let my_instance_communities_actions_join: my_instance_communities_actions_join =\n      my_instance_communities_actions_join(my_person_id);\n    let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 =\n      my_instance_persons_actions_join_1(my_person_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    post::table\n      .inner_join(person::table)\n      .inner_join(community::table)\n      .left_join(image_details_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(creator_community_actions_join())\n      .left_join(my_community_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_post_actions_join)\n      .left_join(my_instance_communities_actions_join)\n      .left_join(my_instance_persons_actions_join_1)\n      .left_join(my_local_user_admin_join)\n  }\n\n  #[diesel::dsl::auto_type(no_type_alias)]\n  /// This uses the post_actions table as the base, for faster filtering for some queries\n  fn post_action_joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let community_join = community::table.on(post::community_id.eq(community::id));\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(my_person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id);\n    let my_instance_communities_actions_join: my_instance_communities_actions_join =\n      my_instance_communities_actions_join(my_person_id);\n    let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 =\n      my_instance_persons_actions_join_1(my_person_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    post_actions::table\n      .inner_join(post::table)\n      .inner_join(person::table)\n      .inner_join(community_join)\n      .left_join(image_details_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_community_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(creator_community_actions_join())\n      .left_join(my_community_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_instance_communities_actions_join)\n      .left_join(my_instance_persons_actions_join_1)\n      .left_join(my_local_user_admin_join)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    my_local_user: Option<&'_ LocalUser>,\n    local_instance_id: InstanceId,\n    is_mod_or_admin: bool,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let my_person_id = my_local_user.person_id();\n\n    let mut query = Self::joins(my_person_id, local_instance_id)\n      .filter(post::id.eq(post_id))\n      .select(Self::as_select())\n      .into_boxed();\n\n    // Hide deleted and removed for non-admins or mods\n    if !is_mod_or_admin {\n      query = query\n        .filter(\n          community::removed\n            .eq(false)\n            .or(post::creator_id.nullable().eq(my_person_id)),\n        )\n        .filter(\n          post::removed\n            .eq(false)\n            .or(post::creator_id.nullable().eq(my_person_id)),\n        )\n        .filter(\n          community::deleted\n            .eq(false)\n            .or(post::creator_id.nullable().eq(my_person_id)),\n        )\n        // Posts deleted by the creator are still visible if they have any comments. If there\n        // are no comments only the creator can see it.\n        .filter(\n          post::deleted\n            .eq(false)\n            .or(post::creator_id.nullable().eq(my_person_id))\n            .or(post::comments.gt(0)),\n        )\n        // private communities can only by browsed by accepted followers\n        .filter(\n          community::visibility\n            .ne(CommunityVisibility::Private)\n            .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n        );\n    }\n\n    query = my_local_user.visible_communities_only(query);\n\n    Commented::new(query)\n      .text(\"PostView::read\")\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// List all the read posts for your person, ordered by the read date.\n  pub async fn list_read(\n    pool: &mut DbPool<'_>,\n    my_person: &Person,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n    no_limit: Option<bool>,\n  ) -> LemmyResult<PagedResponse<PostView>> {\n    let limit = limit_fetch(limit, no_limit)?;\n    let query = PostView::post_action_joins(Some(my_person.id), my_person.instance_id)\n      .filter(post_actions::person_id.eq(my_person.id))\n      .filter(post_actions::read_at.is_not_null())\n      .filter(filter_blocked())\n      .limit(limit)\n      .select(PostView::as_select())\n      .into_boxed();\n\n    // Sorting by the read date\n    let paginated_query =\n      PostViewDummy::paginate(query, &page_cursor, SortDirection::Desc, pool, None)\n        .await?\n        .then_order_by(pa_key::read_at)\n        // Tie breaker\n        .then_order_by(pa_key::post_id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, page_cursor)\n  }\n\n  /// List all the hidden posts for your person, ordered by the hide date.\n  pub async fn list_hidden(\n    pool: &mut DbPool<'_>,\n    my_person: &Person,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n    no_limit: Option<bool>,\n  ) -> LemmyResult<PagedResponse<PostView>> {\n    let limit = limit_fetch(limit, no_limit)?;\n    let query = PostView::post_action_joins(Some(my_person.id), my_person.instance_id)\n      .filter(post_actions::person_id.eq(my_person.id))\n      .filter(post_actions::hidden_at.is_not_null())\n      .filter(filter_blocked())\n      .limit(limit)\n      .select(PostView::as_select())\n      .into_boxed();\n\n    // Sorting by the hidden date\n    let paginated_query =\n      PostViewDummy::paginate(query, &page_cursor, SortDirection::Desc, pool, None)\n        .await?\n        .then_order_by(pa_key::hidden_at)\n        // Tie breaker\n        .then_order_by(pa_key::post_id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<Self>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, page_cursor)\n  }\n}\n\n#[derive(Clone, Default)]\npub struct PostQuery<'a> {\n  pub listing_type: Option<ListingType>,\n  pub sort: Option<PostSortType>,\n  pub time_range_seconds: Option<i32>,\n  pub community_id: Option<CommunityId>,\n  pub multi_community_id: Option<MultiCommunityId>,\n  pub local_user: Option<&'a LocalUser>,\n  pub show_hidden: Option<bool>,\n  pub show_read: Option<bool>,\n  pub show_nsfw: Option<bool>,\n  pub hide_media: Option<bool>,\n  pub no_comments_only: Option<bool>,\n  pub keyword_blocks: Option<Vec<String>>,\n  pub page_cursor: Option<PaginationCursor>,\n  /// For backwards compat with API v3 (not available on API v4).\n  pub page: Option<i64>,\n  pub limit: Option<i64>,\n}\n\nimpl PostQuery<'_> {\n  async fn prefetch_cursor_before_data(\n    &self,\n    site: &Site,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Option<Post>> {\n    // first get one page for the most popular community to get an upper bound for the page end for\n    // the real query. the reason this is needed is that when fetching posts for a single\n    // community PostgreSQL can optimize the query to use an index on e.g. (=, >=, >=, >=) and\n    // fetch only LIMIT rows but for the followed-communities query it has to query the index on\n    // (IN, >=, >=, >=) which it currently can't do at all (as of PG 16). see the discussion\n    // here: https://github.com/LemmyNet/lemmy/issues/2877#issuecomment-1673597190\n    //\n    // the results are correct no matter which community we fetch these for, since it basically\n    // covers the \"worst case\" of the whole page consisting of posts from one community\n    // but using the largest community decreases the pagination-frame so make the real query more\n    // efficient.\n\n    // If its a subscribed type, you need to prefetch both the largest community, and the upper\n    // bound post for the cursor.\n    Ok(if self.listing_type == Some(ListingType::Subscribed) {\n      if let Some(person_id) = self.local_user.person_id() {\n        let largest_subscribed =\n          CommunityActions::fetch_largest_subscribed_community(pool, person_id).await?;\n\n        let upper_bound_results: Vec<PostView> = self\n          .clone()\n          .list_inner(site, None, largest_subscribed, pool)\n          .await?\n          .items;\n\n        let limit = limit_fetch(self.limit, None)?;\n\n        // take last element of array. if this query returned less than LIMIT elements,\n        // the heuristic is invalid since we can't guarantee the full query will return >= LIMIT\n        // results (return original query)\n        let len: i64 = upper_bound_results.len().try_into()?;\n        if len < limit {\n          None\n        } else {\n          if self\n            .page_cursor\n            .clone()\n            .and_then(|c| c.is_back().ok())\n            .unwrap_or_default()\n          {\n            // for backward pagination, get first element instead\n            upper_bound_results.into_iter().next()\n          } else {\n            upper_bound_results.into_iter().next_back()\n          }\n          .map(|pv| pv.post)\n        }\n      } else {\n        None\n      }\n    } else {\n      None\n    })\n  }\n\n  async fn list_inner(\n    self,\n    site: &Site,\n    cursor_before_data: Option<Post>,\n    largest_subscribed_for_prefetch: Option<CommunityId>,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<PagedResponse<PostView>> {\n    let o = self;\n    let limit = limit_fetch(o.limit, None)?;\n\n    let my_person_id = o.local_user.person_id();\n    let my_local_user_id = o.local_user.local_user_id();\n\n    let mut query = PostView::joins(my_person_id, site.instance_id)\n      .select(PostView::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(page) = o.page {\n      query = query.offset(limit * (page - 1));\n    }\n\n    // hide posts from deleted communities\n    query = query.filter(community::deleted.eq(false));\n\n    // only creator can see deleted posts and unpublished scheduled posts\n    if let Some(person_id) = my_person_id {\n      query = query.filter(post::deleted.eq(false).or(post::creator_id.eq(person_id)));\n      query = query.filter(\n        post::scheduled_publish_time_at\n          .is_null()\n          .or(post::creator_id.eq(person_id)),\n      );\n    } else {\n      query = query\n        .filter(post::deleted.eq(false))\n        .filter(post::scheduled_publish_time_at.is_null());\n    }\n\n    match (o.community_id, o.multi_community_id) {\n      (Some(id), None) => {\n        query = query.filter(post::community_id.eq(id));\n      }\n      (None, Some(id)) => {\n        let communities = multi_community_entry::table\n          .filter(multi_community_entry::multi_community_id.eq(id))\n          .select(multi_community_entry::community_id);\n        query = query.filter(post::community_id.eq_any(communities))\n      }\n      (Some(_), Some(_)) => {\n        return Err(LemmyErrorType::CannotCombineCommunityIdAndMultiCommunityId.into());\n      }\n      (None, None) => {\n        if let (Some(ListingType::Subscribed), Some(id)) =\n          (o.listing_type, largest_subscribed_for_prefetch)\n        {\n          query = query.filter(post::community_id.eq(id));\n        }\n      }\n    }\n\n    match o.listing_type.unwrap_or_default() {\n      // TODO we might have much better performance by using post::community_id.eq_any()\n      ListingType::Subscribed => query = query.filter(filter_is_subscribed()),\n      ListingType::Local => {\n        query = query\n          .filter(community::local.eq(true))\n          .filter(filter_not_unlisted_or_is_subscribed());\n      }\n      ListingType::All => query = query.filter(filter_not_unlisted_or_is_subscribed()),\n      ListingType::ModeratorView => {\n        query = query.filter(community_actions::became_moderator_at.is_not_null());\n      }\n      ListingType::Suggested => query = query.filter(filter_suggested_communities()),\n    }\n\n    if !o.show_nsfw.unwrap_or(o.local_user.show_nsfw(site)) {\n      query = query\n        .filter(post::nsfw.eq(false))\n        .filter(community::nsfw.eq(false));\n    };\n\n    if !o.local_user.show_bot_accounts() {\n      query = query.filter(person::bot_account.eq(false));\n    };\n\n    // Filter to show only posts with no comments\n    if o.no_comments_only.unwrap_or_default() {\n      query = query.filter(post::comments.eq(0));\n    };\n\n    if !o.show_read.unwrap_or(o.local_user.show_read_posts()) {\n      query = query.filter(post_actions::read_at.is_null());\n    }\n\n    // Hide the hidden posts\n    if !o.show_hidden.unwrap_or_default() {\n      query = query.filter(post_actions::hidden_at.is_null());\n    }\n\n    if o.hide_media.unwrap_or(o.local_user.hide_media()) {\n      query = query.filter(not(\n        post::url_content_type.is_not_null().and(\n          post::url_content_type\n            .like(\"image/%\")\n            .or(post::url_content_type.like(\"video/%\")),\n        ),\n      ));\n    }\n\n    query = o.local_user.visible_communities_only(query);\n    query = query.filter(\n      post::federation_pending\n        .eq(false)\n        .or(post::creator_id.nullable().eq(my_person_id)),\n    );\n\n    if !o.local_user.is_admin() {\n      query = query\n        .filter(\n          community::visibility\n            .ne(CommunityVisibility::Private)\n            .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)),\n        )\n        // only show removed posts to admin\n        .filter(community::removed.eq(false))\n        .filter(community::local_removed.eq(false))\n        .filter(post::removed.eq(false));\n    }\n\n    // Dont filter blocks or missing languages for moderator view type\n    if o.listing_type.unwrap_or_default() != ListingType::ModeratorView {\n      // Filter out the rows with missing languages if user is logged in\n      if o.local_user.is_some() {\n        query = query.filter(exists(\n          local_user_language::table.filter(\n            post::language_id.eq(local_user_language::language_id).and(\n              local_user_language::local_user_id\n                .nullable()\n                .eq(my_local_user_id),\n            ),\n          ),\n        ));\n      }\n\n      query = query.filter(filter_blocked());\n\n      if let Some(keyword_blocks) = o.keyword_blocks {\n        for keyword in keyword_blocks {\n          let pattern = format!(\"%{}%\", keyword);\n          query = query.filter(post::name.not_ilike(pattern.clone()));\n          query = query.filter(post::url.is_null().or(post::url.not_ilike(pattern.clone())));\n          query = query.filter(\n            post::body\n              .is_null()\n              .or(post::body.not_ilike(pattern.clone())),\n          );\n        }\n      }\n    }\n\n    // Filter by the time range\n    if let Some(time_range_seconds) = o.time_range_seconds {\n      query =\n        query.filter(post::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)));\n    }\n\n    // Only sort by ascending for Old\n    let sort = o.sort.unwrap_or(PostSortType::Hot);\n    let sort_direction = asc_if(sort == PostSortType::Old);\n\n    let mut pq = PostView::paginate(\n      query,\n      &o.page_cursor,\n      sort_direction,\n      pool,\n      cursor_before_data,\n    )\n    .await?;\n\n    // featured posts first\n    // Don't do for new / old sorts\n    if sort != PostSortType::New && sort != PostSortType::Old {\n      pq = if o.community_id.is_none() || largest_subscribed_for_prefetch.is_some() {\n        pq.then_order_by(key::featured_local)\n      } else {\n        pq.then_order_by(key::featured_community)\n      };\n    }\n\n    // then use the main sort\n    pq = match sort {\n      PostSortType::Active => pq.then_order_by(key::hot_rank_active),\n      PostSortType::Hot => pq.then_order_by(key::hot_rank),\n      PostSortType::Scaled => pq.then_order_by(key::scaled_rank),\n      PostSortType::Controversial => pq.then_order_by(key::controversy_rank),\n      PostSortType::New | PostSortType::Old => pq.then_order_by(key::published_at),\n      PostSortType::NewComments => {\n        pq.then_order_by(CoalesceKey(key::newest_comment_time_at, key::published_at))\n      }\n      PostSortType::MostComments => pq.then_order_by(key::comments),\n      PostSortType::Top => pq.then_order_by(key::score),\n    };\n\n    // use publish as fallback. especially useful for hot rank which reaches zero after some days.\n    // necessary because old posts can be fetched over federation and inserted with high post id\n    pq = match sort {\n      // A second time-based sort would not be very useful\n      PostSortType::New | PostSortType::Old | PostSortType::NewComments => pq,\n      _ => pq.then_order_by(key::published_at),\n    };\n\n    // finally use unique post id as tie breaker\n    pq = pq.then_order_by(key::id);\n\n    // Convert to as_query to be able to use in commented.\n    let query = pq.as_query();\n\n    debug!(\"Post View Query: {:?}\", debug_query::<Pg, _>(&query));\n    let conn = &mut get_conn(pool).await?;\n    let res = Commented::new(query)\n      .text(\"PostQuery::list\")\n      .load::<PostView>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, o.page_cursor)\n  }\n\n  pub async fn list(\n    &self,\n    site: &Site,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<PagedResponse<PostView>> {\n    let cursor_before_data = self.prefetch_cursor_before_data(site, pool).await?;\n\n    self\n      .clone()\n      .list_inner(site, cursor_before_data, None, pool)\n      .await\n  }\n}\n"
  },
  {
    "path": "crates/db_views/post/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityActions},\n  community_tag::CommunityTagsView,\n  images::ImageDetails,\n  person::{Person, PersonActions},\n  post::{Post, PostActions},\n};\nuse serde::{Deserialize, Serialize};\n#[cfg(test)]\nmod db_perf;\n#[cfg(test)]\nmod test;\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::post_select_remove_deletes,\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeBanExpiresType,\n    creator_ban_expires_from_community,\n    creator_banned_from_community,\n    creator_is_moderator,\n    creator_local_home_ban_expires,\n    creator_local_home_community_banned,\n    local_user_can_mod_post,\n    post_community_tags_fragment,\n    post_creator_is_admin,\n  },\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A post view.\npub struct PostView {\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = post_select_remove_deletes()\n    )\n  )]\n  pub post: Post,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community: Community,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub image_details: Option<ImageDetails>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub community_actions: Option<CommunityActions>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub person_actions: Option<PersonActions>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub post_actions: Option<PostActions>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = post_creator_is_admin()\n    )\n  )]\n  pub creator_is_admin: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = post_community_tags_fragment()\n    )\n  )]\n  pub tags: CommunityTagsView,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = local_user_can_mod_post()\n    )\n  )]\n  pub can_mod: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_local_home_community_banned()\n    )\n  )]\n  pub creator_banned: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = CreatorLocalHomeBanExpiresType,\n      select_expression = creator_local_home_ban_expires()\n     )\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_is_moderator()\n    )\n  )]\n  pub creator_is_moderator: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_banned_from_community()\n    )\n  )]\n  pub creator_banned_from_community: bool,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression = creator_ban_expires_from_community()\n    )\n  )]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_views/post/src/test.rs",
    "content": "#![expect(clippy::indexing_slicing, clippy::expect_used, clippy::unreachable)]\n\nuse crate::{PostView, impls::PostQuery};\nuse chrono::{DateTime, Days, Utc};\nuse diesel_async::SimpleAsyncConnection;\nuse diesel_uplete::UpleteCount;\nuse lemmy_db_schema::{\n  impls::actor_language::UNDETERMINED_ID,\n  newtypes::{LanguageId, PostId},\n  source::{\n    actor_language::LocalUserLanguage,\n    comment::{Comment, CommentInsertForm},\n    community::{\n      Community,\n      CommunityActions,\n      CommunityBlockForm,\n      CommunityFollowerForm,\n      CommunityInsertForm,\n      CommunityModeratorForm,\n      CommunityPersonBanForm,\n      CommunityUpdateForm,\n    },\n    community_tag::{CommunityTag, CommunityTagInsertForm, PostCommunityTag},\n    instance::{\n      Instance,\n      InstanceActions,\n      InstanceBanForm,\n      InstanceCommunitiesBlockForm,\n      InstancePersonsBlockForm,\n    },\n    keyword_block::LocalUserKeywordBlock,\n    language::Language,\n    local_site::{LocalSite, LocalSiteUpdateForm},\n    local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n    multi_community::{MultiCommunity, MultiCommunityInsertForm},\n    person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonNoteForm},\n    post::{Post, PostActions, PostHideForm, PostInsertForm, PostLikeForm, PostUpdateForm},\n    site::Site,\n  },\n  test_data::TestData,\n  traits::{Bannable, Blockable, Followable, Likeable},\n};\nuse lemmy_db_schema_file::enums::{\n  CommunityFollowerState,\n  CommunityVisibility,\n  ListingType,\n  PostSortType,\n  TagColor,\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{\n  connection::{ActualDbPool, DbPool, build_db_pool, get_conn},\n  pagination::PaginationCursor,\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};\nuse pretty_assertions::assert_eq;\nuse serial_test::serial;\nuse std::{\n  collections::HashSet,\n  time::{Duration, Instant},\n};\nuse test_context::{AsyncTestContext, test_context};\nuse url::Url;\n\nconst POST_BY_BLOCKED_PERSON: &str = \"post by blocked person\";\nconst POST_BY_BOT: &str = \"post by bot\";\nconst POST: &str = \"post\";\nconst POST_WITH_TAGS: &str = \"post with tags\";\nconst POST_KEYWORD_BLOCKED: &str = \"blocked_keyword\";\n\nfn names(post_views: &[PostView]) -> Vec<&str> {\n  post_views.iter().map(|i| i.post.name.as_str()).collect()\n}\n\nstruct Data {\n  pool: ActualDbPool,\n  instance: Instance,\n  tegan: LocalUserView,\n  john: LocalUserView,\n  bot: LocalUserView,\n  community: Community,\n  post: Post,\n  bot_post: Post,\n  post_with_tags: Post,\n  tag_1: CommunityTag,\n  tag_2: CommunityTag,\n  site: Site,\n}\n\nimpl Data {\n  fn pool(&self) -> ActualDbPool {\n    self.pool.clone()\n  }\n  pub fn pool2(&self) -> DbPool<'_> {\n    DbPool::Pool(&self.pool)\n  }\n  fn default_post_query(&self) -> PostQuery<'_> {\n    PostQuery {\n      sort: Some(PostSortType::New),\n      local_user: Some(&self.tegan.local_user),\n      ..Default::default()\n    }\n  }\n\n  async fn setup_inner() -> LemmyResult<Data> {\n    let actual_pool = build_db_pool()?;\n    let pool = &mut (&actual_pool).into();\n    let data = TestData::create(pool).await?;\n\n    let tegan_person_form = PersonInsertForm::test_form(data.instance.id, \"tegan\");\n    let inserted_tegan_person = Person::create(pool, &tegan_person_form).await?;\n    let tegan_local_user_form = LocalUserInsertForm {\n      admin: Some(true),\n      ..LocalUserInsertForm::test_form(inserted_tegan_person.id)\n    };\n    let inserted_tegan_local_user = LocalUser::create(pool, &tegan_local_user_form, vec![]).await?;\n\n    let bot_person_form = PersonInsertForm {\n      bot_account: Some(true),\n      ..PersonInsertForm::test_form(data.instance.id, \"mybot\")\n    };\n    let inserted_bot_person = Person::create(pool, &bot_person_form).await?;\n    let inserted_bot_local_user = LocalUser::create(\n      pool,\n      &LocalUserInsertForm::test_form(inserted_bot_person.id),\n      vec![],\n    )\n    .await?;\n\n    let new_community = CommunityInsertForm::new(\n      data.instance.id,\n      \"test_community_3\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community = Community::create(pool, &new_community).await?;\n\n    // Test a person block, make sure the post query doesn't include their post\n    let john_person_form = PersonInsertForm::test_form(data.instance.id, \"john\");\n    let inserted_john_person = Person::create(pool, &john_person_form).await?;\n    let inserted_john_local_user = LocalUser::create(\n      pool,\n      &LocalUserInsertForm::test_form(inserted_john_person.id),\n      vec![],\n    )\n    .await?;\n\n    let post_from_blocked_person = PostInsertForm {\n      language_id: Some(LanguageId(1)),\n      ..PostInsertForm::new(\n        POST_BY_BLOCKED_PERSON.to_string(),\n        inserted_john_person.id,\n        community.id,\n      )\n    };\n    Post::create(pool, &post_from_blocked_person).await?;\n\n    // block that person\n    let person_block = PersonBlockForm::new(inserted_tegan_person.id, inserted_john_person.id);\n    PersonActions::block(pool, &person_block).await?;\n\n    LocalUserKeywordBlock::update(\n      pool,\n      vec![POST_KEYWORD_BLOCKED.to_string()],\n      inserted_tegan_local_user.id,\n    )\n    .await?;\n\n    // Two community post tags\n    let tag_1 = CommunityTag::create(\n      pool,\n      &CommunityTagInsertForm {\n        ap_id: Url::parse(&format!(\"{}/tags/test_tag1\", community.ap_id))?.into(),\n        name: \"Test Tag 1\".into(),\n        display_name: None,\n        summary: None,\n        community_id: community.id,\n        deleted: Some(false),\n        color: Some(TagColor::Color01),\n      },\n    )\n    .await?;\n    let tag_2 = CommunityTag::create(\n      pool,\n      &CommunityTagInsertForm {\n        ap_id: Url::parse(&format!(\"{}/tags/test_tag2\", community.ap_id))?.into(),\n        name: \"Test Tag 2\".into(),\n        display_name: None,\n        summary: None,\n        community_id: community.id,\n        deleted: Some(false),\n        color: Some(TagColor::Color02),\n      },\n    )\n    .await?;\n\n    // A sample post\n    let new_post = PostInsertForm {\n      language_id: Some(LanguageId(47)),\n      ..PostInsertForm::new(POST.to_string(), inserted_tegan_person.id, community.id)\n    };\n\n    let post = Post::create(pool, &new_post).await?;\n\n    let new_bot_post = PostInsertForm::new(\n      POST_BY_BOT.to_string(),\n      inserted_bot_person.id,\n      community.id,\n    );\n    let bot_post = Post::create(pool, &new_bot_post).await?;\n\n    // A sample post with tags\n    let new_post = PostInsertForm {\n      language_id: Some(LanguageId(47)),\n      ..PostInsertForm::new(\n        POST_WITH_TAGS.to_string(),\n        inserted_tegan_person.id,\n        community.id,\n      )\n    };\n\n    let post_with_tags = Post::create(pool, &new_post).await?;\n    PostCommunityTag::update(pool, &post_with_tags, &[tag_1.id, tag_2.id]).await?;\n\n    let tegan = LocalUserView {\n      local_user: inserted_tegan_local_user,\n      person: inserted_tegan_person,\n      banned: false,\n      ban_expires_at: None,\n    };\n    let john = LocalUserView {\n      local_user: inserted_john_local_user,\n      person: inserted_john_person,\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let bot = LocalUserView {\n      local_user: inserted_bot_local_user,\n      person: inserted_bot_person,\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    Ok(Data {\n      pool: actual_pool,\n      instance: data.instance,\n      tegan,\n      john,\n      bot,\n      community,\n      post,\n      bot_post,\n      post_with_tags,\n      tag_1,\n      tag_2,\n      site: data.site,\n    })\n  }\n  async fn teardown_inner(data: Data) -> LemmyResult<()> {\n    let pool = &mut data.pool2();\n    let num_deleted = Post::delete(pool, data.post.id).await?;\n    Community::delete(pool, data.community.id).await?;\n    Person::delete(pool, data.tegan.person.id).await?;\n    Person::delete(pool, data.bot.person.id).await?;\n    Person::delete(pool, data.john.person.id).await?;\n    Site::delete(pool, data.site.id).await?;\n    Instance::delete(pool, data.instance.id).await?;\n    assert_eq!(1, num_deleted);\n\n    Ok(())\n  }\n}\nimpl AsyncTestContext for Data {\n  async fn setup() -> Self {\n    Data::setup_inner().await.expect(\"setup failed\")\n  }\n  async fn teardown(self) {\n    Data::teardown_inner(self).await.expect(\"teardown failed\")\n  }\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_with_person(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let local_user_form = LocalUserUpdateForm {\n    show_bot_accounts: Some(false),\n    ..Default::default()\n  };\n  LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?;\n  data.tegan.local_user.show_bot_accounts = false;\n\n  let mut read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?\n  .items;\n  // remove tags post\n  read_post_listing.remove(0);\n\n  let post_listing_single_with_person = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(\n    vec![post_listing_single_with_person.clone()],\n    read_post_listing\n  );\n  assert_eq!(data.post.id, post_listing_single_with_person.post.id);\n\n  let local_user_form = LocalUserUpdateForm {\n    show_bot_accounts: Some(true),\n    ..Default::default()\n  };\n  LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?;\n  data.tegan.local_user.show_bot_accounts = true;\n\n  let post_listings_with_bots = PostQuery {\n    community_id: Some(data.community.id),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?;\n  // should include bot post which has \"undetermined\" language\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_with_bots)\n  );\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_no_person(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let read_post_listing_multiple_no_person = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: None,\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?;\n\n  let read_post_listing_single_no_person =\n    PostView::read(pool, data.post.id, None, data.instance.id, false).await?;\n\n  // Should be 2 posts, with the bot post, and the blocked\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON],\n    names(&read_post_listing_multiple_no_person)\n  );\n\n  assert!(\n    read_post_listing_multiple_no_person\n      .get(2)\n      .is_some_and(|x| x.post.id == data.post.id)\n  );\n  assert_eq!(false, read_post_listing_single_no_person.can_mod);\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_block_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let community_block = CommunityBlockForm::new(data.community.id, data.tegan.person.id);\n  CommunityActions::block(pool, &community_block).await?;\n\n  let read_post_listings_with_person_after_block = PostQuery {\n    community_id: Some(data.community.id),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?;\n  // Should be 0 posts after the community block\n  assert_eq!(read_post_listings_with_person_after_block.items, vec![]);\n\n  CommunityActions::unblock(pool, &community_block).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_like(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let post_like_form = PostLikeForm::new(data.post.id, data.tegan.person.id, Some(true));\n\n  let inserted_post_like = PostActions::like(pool, &post_like_form).await?;\n\n  assert_eq!(\n    (data.post.id, data.tegan.person.id, Some(true)),\n    (\n      inserted_post_like.post_id,\n      inserted_post_like.person_id,\n      inserted_post_like.vote_is_upvote,\n    )\n  );\n\n  let post_listing_single_with_person = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(\n    (true, true, 1, 1, 1),\n    (\n      post_listing_single_with_person\n        .post_actions\n        .is_some_and(|t| t.vote_is_upvote == Some(true)),\n      // Make sure person actions is none so you don't get a voted_at for your own user\n      post_listing_single_with_person.person_actions.is_none(),\n      post_listing_single_with_person.post.score,\n      post_listing_single_with_person.post.upvotes,\n      post_listing_single_with_person.creator.post_score,\n    )\n  );\n\n  let local_user_form = LocalUserUpdateForm {\n    show_bot_accounts: Some(false),\n    ..Default::default()\n  };\n  LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?;\n  data.tegan.local_user.show_bot_accounts = false;\n\n  let mut read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?\n  .items;\n  read_post_listing.remove(0);\n  assert_eq!(\n    post_listing_single_with_person.post.id,\n    read_post_listing[0].post.id\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn person_note(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let note_str = \"Tegan loves cats.\";\n\n  let note_form = PersonNoteForm::new(\n    data.john.person.id,\n    data.tegan.person.id,\n    note_str.to_string(),\n  );\n  let inserted_note = PersonActions::note(pool, &note_form).await?;\n  assert_eq!(Some(note_str.to_string()), inserted_note.note);\n\n  let post_listing = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.john.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert!(\n    post_listing\n      .person_actions\n      .is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some())\n  );\n\n  let note_removed =\n    PersonActions::delete_note(pool, data.john.person.id, data.tegan.person.id).await?;\n\n  let post_listing = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.john.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(UpleteCount::only_deleted(1), note_removed);\n  assert!(post_listing.person_actions.is_none());\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_person_vote_totals(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Create a 2nd bot post, to do multiple votes\n  let bot_post_2 = PostInsertForm::new(\n    \"Bot post 2\".to_string(),\n    data.bot.person.id,\n    data.community.id,\n  );\n  let bot_post_2 = Post::create(pool, &bot_post_2).await?;\n\n  let post_like_form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, Some(true));\n  let inserted_post_like = PostActions::like(pool, &post_like_form).await?;\n\n  assert_eq!(\n    (data.bot_post.id, data.tegan.person.id, Some(true)),\n    (\n      inserted_post_like.post_id,\n      inserted_post_like.person_id,\n      inserted_post_like.vote_is_upvote,\n    )\n  );\n\n  let inserted_person_like = PersonActions::like(\n    pool,\n    data.tegan.person.id,\n    data.bot.person.id,\n    None,\n    Some(true),\n  )\n  .await?;\n\n  assert_eq!(\n    (data.tegan.person.id, data.bot.person.id, Some(1), Some(0),),\n    (\n      inserted_person_like.person_id,\n      inserted_person_like.target_id,\n      inserted_person_like.upvotes,\n      inserted_person_like.downvotes,\n    )\n  );\n\n  let post_listing = PostView::read(\n    pool,\n    data.bot_post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(\n    (true, true, true, 1, 1, 1),\n    (\n      post_listing\n        .post_actions\n        .is_some_and(|t| t.vote_is_upvote == Some(true)),\n      post_listing\n        .person_actions\n        .as_ref()\n        .is_some_and(|t| t.upvotes == Some(1)),\n      post_listing\n        .person_actions\n        .as_ref()\n        .is_some_and(|t| t.downvotes == Some(0)),\n      post_listing.post.score,\n      post_listing.post.upvotes,\n      post_listing.creator.post_score,\n    )\n  );\n\n  // Do a 2nd like to another post\n  let post_2_like_form = PostLikeForm::new(bot_post_2.id, data.tegan.person.id, Some(true));\n  PostActions::like(pool, &post_2_like_form).await?;\n\n  let inserted_person_like_2 = PersonActions::like(\n    pool,\n    data.tegan.person.id,\n    data.bot.person.id,\n    None,\n    Some(true),\n  )\n  .await?;\n  assert_eq!(\n    (data.tegan.person.id, data.bot.person.id, Some(2), Some(0),),\n    (\n      inserted_person_like_2.person_id,\n      inserted_person_like_2.target_id,\n      inserted_person_like_2.upvotes,\n      inserted_person_like_2.downvotes,\n    )\n  );\n\n  // Remove the like\n  let form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, None);\n  PostActions::like(pool, &form).await?;\n\n  let person_like_removed = PersonActions::like(\n    pool,\n    data.tegan.person.id,\n    data.bot.person.id,\n    Some(true),\n    None,\n  )\n  .await?;\n  assert_eq!(\n    (data.tegan.person.id, data.bot.person.id, Some(1), Some(0),),\n    (\n      person_like_removed.person_id,\n      person_like_removed.target_id,\n      person_like_removed.upvotes,\n      person_like_removed.downvotes,\n    )\n  );\n\n  // Now do a downvote\n  let post_like_form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, Some(false));\n  PostActions::like(pool, &post_like_form).await?;\n  let inserted_person_dislike = PersonActions::like(\n    pool,\n    data.tegan.person.id,\n    data.bot.person.id,\n    None,\n    Some(false),\n  )\n  .await?;\n  assert_eq!(\n    (data.tegan.person.id, data.bot.person.id, Some(1), Some(1),),\n    (\n      inserted_person_dislike.person_id,\n      inserted_person_dislike.target_id,\n      inserted_person_dislike.upvotes,\n      inserted_person_dislike.downvotes,\n    )\n  );\n\n  let post_listing = PostView::read(\n    pool,\n    data.bot_post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(\n    (true, true, true, -1, 1, 0),\n    (\n      post_listing\n        .post_actions\n        .is_some_and(|t| t.vote_is_upvote == Some(false)),\n      post_listing\n        .person_actions\n        .as_ref()\n        .is_some_and(|t| t.upvotes == Some(1)),\n      post_listing\n        .person_actions\n        .as_ref()\n        .is_some_and(|t| t.downvotes == Some(1)),\n      post_listing.post.score,\n      post_listing.post.downvotes,\n      post_listing.creator.post_score,\n    )\n  );\n\n  let form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, None);\n  PostActions::like(pool, &form).await?;\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_read_only(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Mark the bot post, then the tags post as read\n  PostActions::mark_as_read(pool, data.tegan.person.id, &[data.bot_post.id]).await?;\n\n  PostActions::mark_as_read(pool, data.tegan.person.id, &[data.post_with_tags.id]).await?;\n\n  let read_read_post_listing =\n    PostView::list_read(pool, &data.tegan.person, None, None, None).await?;\n\n  // This should be ordered from most recently read\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT],\n    names(&read_read_post_listing)\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn creator_info(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n  let community_id = data.community.id;\n\n  let tegan_listings = PostQuery {\n    community_id: Some(community_id),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?\n  .into_iter()\n  .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod))\n  .collect::<Vec<_>>();\n\n  // Tegan is an admin, so can_mod should be always true\n  let expected_post_listing = vec![\n    (\"tegan\".to_owned(), false, true),\n    (\"mybot\".to_owned(), false, true),\n    (\"tegan\".to_owned(), false, true),\n  ];\n  assert_eq!(expected_post_listing, tegan_listings);\n\n  // Have john become a moderator, then the bot\n  let john_mod_form = CommunityModeratorForm::new(community_id, data.john.person.id);\n  CommunityActions::join(pool, &john_mod_form).await?;\n\n  let bot_mod_form = CommunityModeratorForm::new(community_id, data.bot.person.id);\n  CommunityActions::join(pool, &bot_mod_form).await?;\n\n  let john_listings = PostQuery {\n    sort: Some(PostSortType::New),\n    local_user: Some(&data.john.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?\n  .into_iter()\n  .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod))\n  .collect::<Vec<_>>();\n\n  // John is a mod, so he can_mod the bots (and his own) posts, but not tegans.\n  let expected_post_listing = vec![\n    (\"tegan\".to_owned(), false, false),\n    (\"mybot\".to_owned(), true, true),\n    (\"tegan\".to_owned(), false, false),\n    (\"john\".to_owned(), true, true),\n  ];\n  assert_eq!(expected_post_listing, john_listings);\n\n  // Bot is also a mod, but was added after john, so can't mod anything\n  let bot_listings = PostQuery {\n    sort: Some(PostSortType::New),\n    local_user: Some(&data.bot.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?\n  .into_iter()\n  .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod))\n  .collect::<Vec<_>>();\n\n  let expected_post_listing = vec![\n    (\"tegan\".to_owned(), false, false),\n    (\"mybot\".to_owned(), true, true),\n    (\"tegan\".to_owned(), false, false),\n    (\"john\".to_owned(), true, false),\n  ];\n  assert_eq!(expected_post_listing, bot_listings);\n\n  // Make the bot leave the mod team, and make sure it can_mod is false.\n  CommunityActions::leave(pool, &bot_mod_form).await?;\n\n  let bot_listings = PostQuery {\n    sort: Some(PostSortType::New),\n    local_user: Some(&data.bot.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?\n  .into_iter()\n  .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod))\n  .collect::<Vec<_>>();\n\n  let expected_post_listing = vec![\n    (\"tegan\".to_owned(), false, false),\n    (\"mybot\".to_owned(), false, false),\n    (\"tegan\".to_owned(), false, false),\n    (\"john\".to_owned(), true, false),\n  ];\n  assert_eq!(expected_post_listing, bot_listings);\n\n  // Have tegan the administrator become a moderator\n  let tegan_mod_form = CommunityModeratorForm::new(community_id, data.tegan.person.id);\n  CommunityActions::join(pool, &tegan_mod_form).await?;\n\n  let john_listings = PostQuery {\n    sort: Some(PostSortType::New),\n    local_user: Some(&data.john.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?\n  .into_iter()\n  .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod))\n  .collect::<Vec<_>>();\n\n  // John is a mod, so he still can_mod the bots (and his own) posts. Tegan is a lower mod and\n  // admin, john can't mod their posts.\n  let expected_post_listing = vec![\n    (\"tegan\".to_owned(), true, false),\n    (\"mybot\".to_owned(), false, true),\n    (\"tegan\".to_owned(), true, false),\n    (\"john\".to_owned(), true, true),\n  ];\n  assert_eq!(expected_post_listing, john_listings);\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_person_language(data: &mut Data) -> LemmyResult<()> {\n  const EL_POSTO: &str = \"el posto\";\n\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let spanish_id = Language::read_id_from_code(pool, \"es\").await?;\n\n  let french_id = Language::read_id_from_code(pool, \"fr\").await?;\n\n  let post_spanish = PostInsertForm {\n    language_id: Some(spanish_id),\n    ..PostInsertForm::new(\n      EL_POSTO.to_string(),\n      data.tegan.person.id,\n      data.community.id,\n    )\n  };\n  Post::create(pool, &post_spanish).await?;\n\n  let post_listings_all = data.default_post_query().list(&data.site, pool).await?;\n\n  // no language filters specified, all posts should be returned\n  assert_eq!(\n    vec![EL_POSTO, POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_all)\n  );\n\n  LocalUserLanguage::update(pool, vec![french_id], data.tegan.local_user.id).await?;\n\n  let post_listing_french = data.default_post_query().list(&data.site, pool).await?;\n\n  // only one post in french and one undetermined should be returned\n  assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listing_french));\n  assert_eq!(\n    Some(french_id),\n    post_listing_french.get(1).map(|p| p.post.language_id)\n  );\n\n  LocalUserLanguage::update(\n    pool,\n    vec![french_id, UNDETERMINED_ID],\n    data.tegan.local_user.id,\n  )\n  .await?;\n  let post_listings_french_und = data\n    .default_post_query()\n    .list(&data.site, pool)\n    .await?\n    .into_iter()\n    .map(|p| (p.post.name, p.post.language_id))\n    .collect::<Vec<_>>();\n  let expected_post_listings_french_und = vec![\n    (POST_WITH_TAGS.to_owned(), french_id),\n    (POST_BY_BOT.to_owned(), UNDETERMINED_ID),\n    (POST.to_owned(), french_id),\n  ];\n\n  // french post and undetermined language post should be returned\n  assert_eq!(expected_post_listings_french_und, post_listings_french_und);\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_removed(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Remove the post\n  Post::update(\n    pool,\n    data.bot_post.id,\n    &PostUpdateForm {\n      removed: Some(true),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // Make sure you don't see the removed post in the results\n  data.tegan.local_user.admin = false;\n  let post_listings_no_admin = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_no_admin));\n\n  // Removed bot post is shown to admins\n  data.tegan.local_user.admin = true;\n  let post_listings_is_admin = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_is_admin)\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_deleted(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Delete the post\n  Post::update(\n    pool,\n    data.post.id,\n    &PostUpdateForm {\n      deleted: Some(true),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // Deleted post is only shown to creator\n  for (local_user, expect_contains_deleted) in [\n    (None, false),\n    (Some(&data.john.local_user), false),\n    (Some(&data.tegan.local_user), true),\n  ] {\n    let contains_deleted = PostQuery {\n      local_user,\n      ..data.default_post_query()\n    }\n    .list(&data.site, pool)\n    .await?\n    .iter()\n    .any(|p| p.post.id == data.post.id);\n\n    assert_eq!(expect_contains_deleted, contains_deleted);\n  }\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_hidden_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  Community::update(\n    pool,\n    data.community.id,\n    &CommunityUpdateForm {\n      visibility: Some(CommunityVisibility::Unlisted),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  let posts = PostQuery::default().list(&data.site, pool).await?;\n  assert!(posts.is_empty());\n\n  let posts = data.default_post_query().list(&data.site, pool).await?;\n  assert!(posts.is_empty());\n\n  // Follow the community\n  let form = CommunityFollowerForm::new(\n    data.community.id,\n    data.tegan.person.id,\n    CommunityFollowerState::Accepted,\n  );\n  CommunityActions::follow(pool, &form).await?;\n\n  let posts = data.default_post_query().list(&data.site, pool).await?;\n  assert!(!posts.is_empty());\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_instance_block_communities(data: &mut Data) -> LemmyResult<()> {\n  const POST_FROM_BLOCKED_INSTANCE_COMMS: &str = \"post on blocked instance\";\n  const HOWARD_POST: &str = \"howard post\";\n  const POST_LISTING_WITH_BLOCKED: [&str; 5] = [\n    HOWARD_POST,\n    POST_FROM_BLOCKED_INSTANCE_COMMS,\n    POST_WITH_TAGS,\n    POST_BY_BOT,\n    POST,\n  ];\n\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let blocked_instance_comms = Instance::read_or_create(pool, \"another_domain.tld\").await?;\n\n  let community_form = CommunityInsertForm::new(\n    blocked_instance_comms.id,\n    \"test_community_4\".to_string(),\n    \"none\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let inserted_community = Community::create(pool, &community_form).await?;\n\n  let post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(\n      POST_FROM_BLOCKED_INSTANCE_COMMS.to_string(),\n      data.bot.person.id,\n      inserted_community.id,\n    )\n  };\n  let post_from_blocked_instance = Post::create(pool, &post_form).await?;\n\n  // Create a person on that comm-blocked instance,\n  // have them create a post from a non-instance-comm blocked community.\n  // Make sure others can see it.\n  let howard_form = PersonInsertForm::test_form(blocked_instance_comms.id, \"howard\");\n  let howard = Person::create(pool, &howard_form).await?;\n  let howard_post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(HOWARD_POST.to_string(), howard.id, data.community.id)\n  };\n  let _post_from_blocked_instance_user = Post::create(pool, &howard_post_form).await?;\n\n  // no instance block, should return all posts\n  let post_listings_all = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_all));\n\n  // block the instance communities\n  let block_form =\n    InstanceCommunitiesBlockForm::new(data.tegan.person.id, blocked_instance_comms.id);\n  InstanceActions::block_communities(pool, &block_form).await?;\n\n  // now posts from communities on that instance should be hidden\n  let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(\n    vec![HOWARD_POST, POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_blocked)\n  );\n  assert!(\n    post_listings_blocked\n      .iter()\n      .all(|p| p.post.id != post_from_blocked_instance.id)\n  );\n\n  // Follow community from the blocked instance to see posts anyway\n  let follow_form = CommunityFollowerForm::new(\n    inserted_community.id,\n    data.tegan.person.id,\n    CommunityFollowerState::Accepted,\n  );\n  CommunityActions::follow(pool, &follow_form).await?;\n  let post_listings_bypass = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_bypass));\n  CommunityActions::unfollow(pool, data.tegan.person.id, inserted_community.id).await?;\n\n  // after unblocking it should return all posts again\n  InstanceActions::unblock_communities(pool, &block_form).await?;\n  let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_blocked));\n\n  Instance::delete(pool, blocked_instance_comms.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_instance_block_persons(data: &mut Data) -> LemmyResult<()> {\n  const POST_FROM_BLOCKED_INSTANCE_USERS: &str = \"post from blocked instance user\";\n  const POST_TO_UNBLOCKED_COMM: &str = \"post to unblocked comm\";\n  const POST_LISTING_WITH_BLOCKED: [&str; 5] = [\n    POST_TO_UNBLOCKED_COMM,\n    POST_FROM_BLOCKED_INSTANCE_USERS,\n    POST_WITH_TAGS,\n    POST_BY_BOT,\n    POST,\n  ];\n\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let blocked_instance_persons = Instance::read_or_create(pool, \"another_domain.tld\").await?;\n\n  let howard_form = PersonInsertForm::test_form(blocked_instance_persons.id, \"howard\");\n  let howard = Person::create(pool, &howard_form).await?;\n\n  let community_form = CommunityInsertForm::new(\n    blocked_instance_persons.id,\n    \"test_community_8\".to_string(),\n    \"none\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let inserted_community = Community::create(pool, &community_form).await?;\n\n  // Create a post from the blocked user on a safe community\n  let blocked_post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(\n      POST_FROM_BLOCKED_INSTANCE_USERS.to_string(),\n      howard.id,\n      data.community.id,\n    )\n  };\n  let post_from_blocked_instance = Post::create(pool, &blocked_post_form).await?;\n\n  // Also create a post from an unblocked user\n  let unblocked_post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(\n      POST_TO_UNBLOCKED_COMM.to_string(),\n      data.bot.person.id,\n      inserted_community.id,\n    )\n  };\n  let _post_to_unblocked_comm = Post::create(pool, &unblocked_post_form).await?;\n\n  // no instance block, should return all posts\n  let post_listings_all = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_all));\n\n  // block the instance communities\n  let block_form = InstancePersonsBlockForm::new(data.tegan.person.id, blocked_instance_persons.id);\n  InstanceActions::block_persons(pool, &block_form).await?;\n\n  // now posts from users on that instance should be hidden\n  let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(\n    vec![POST_TO_UNBLOCKED_COMM, POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_blocked)\n  );\n  assert!(\n    post_listings_blocked\n      .iter()\n      .all(|p| p.post.id != post_from_blocked_instance.id)\n  );\n\n  // after unblocking it should return all posts again\n  InstanceActions::unblock_persons(pool, &block_form).await?;\n  let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_blocked));\n\n  Instance::delete(pool, blocked_instance_persons.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn pagination_includes_each_post_once(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let community_form = CommunityInsertForm::new(\n    data.instance.id,\n    \"yes\".to_string(),\n    \"yes\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let inserted_community = Community::create(pool, &community_form).await?;\n\n  let mut inserted_post_ids = HashSet::new();\n\n  // Create 150 posts with varying non-correlating values for publish date, number of comments,\n  // and featured\n  for i in 0..45 {\n    let post_form = PostInsertForm {\n      featured_local: Some((i % 2) == 0),\n      featured_community: Some((i % 2) == 0),\n      published_at: Some(Utc::now() - Duration::from_secs(i)),\n      ..PostInsertForm::new(\n        \"keep Christ in Christmas\".to_owned(),\n        data.tegan.person.id,\n        inserted_community.id,\n      )\n    };\n    let inserted_post = Post::create(pool, &post_form).await?;\n    inserted_post_ids.insert(inserted_post.id);\n  }\n\n  let options = PostQuery {\n    community_id: Some(inserted_community.id),\n    sort: Some(PostSortType::Hot),\n    limit: Some(3),\n    ..Default::default()\n  };\n\n  let mut listed_post_ids_forward = vec![];\n  let mut page_cursor = None;\n  let mut page_cursor_back = None;\n  loop {\n    let post_listings = PostQuery {\n      page_cursor,\n      ..options.clone()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    listed_post_ids_forward.extend(post_listings.iter().map(|p| p.post.id));\n\n    if post_listings.next_page.is_none() {\n      break;\n    }\n    page_cursor = post_listings.next_page;\n    page_cursor_back = post_listings.prev_page;\n  }\n\n  // unsorted comparison with hashset\n  assert_eq!(\n    inserted_post_ids,\n    listed_post_ids_forward.iter().cloned().collect()\n  );\n\n  // By going backwards from the last page we dont see the last page again, so remove those items\n  listed_post_ids_forward.truncate(listed_post_ids_forward.len() - 3);\n\n  // Check that backward pagination matches forward pagination\n  loop {\n    let post_listings = PostQuery {\n      page_cursor: page_cursor_back,\n      ..options.clone()\n    }\n    .list(&data.site, pool)\n    .await?;\n\n    let listed_post_ids = post_listings.iter().map(|p| p.post.id).collect::<Vec<_>>();\n\n    let index = listed_post_ids_forward.len() - listed_post_ids.len();\n\n    assert_eq!(\n      listed_post_ids_forward.get(index..),\n      listed_post_ids.get(..)\n    );\n    listed_post_ids_forward.truncate(index);\n\n    if let Some(cursor) = post_listings.prev_page {\n      page_cursor_back = Some(cursor);\n    } else {\n      break;\n    }\n  }\n\n  Community::delete(pool, inserted_community.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\n/// Test that last and first partial pages only have one cursor.\nasync fn pagination_hidden_cursors(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let community_form = CommunityInsertForm::new(\n    data.instance.id,\n    \"yes\".to_string(),\n    \"yes\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let inserted_community = Community::create(pool, &community_form).await?;\n\n  let page_size: usize = 5;\n\n  // Create 2 pages with 5 and 4 posts respectively\n  for i in 0..9 {\n    let post_form = PostInsertForm {\n      featured_local: Some((i % 2) == 0),\n      featured_community: Some((i % 2) == 0),\n      published_at: Some(Utc::now() - Duration::from_secs(i)),\n      ..PostInsertForm::new(\n        \"keep Christ in Christmas\".to_owned(),\n        data.tegan.person.id,\n        inserted_community.id,\n      )\n    };\n    Post::create(pool, &post_form).await?;\n  }\n\n  let options = PostQuery {\n    community_id: Some(inserted_community.id),\n    sort: Some(PostSortType::Hot),\n    limit: Some(page_size.try_into()?),\n    ..Default::default()\n  };\n\n  let mut get_page = async |cursor: &Option<PaginationCursor>| {\n    PostQuery {\n      page_cursor: cursor.clone(),\n      ..options.clone()\n    }\n    .list(&data.site, pool)\n    .await\n  };\n\n  let first_page = get_page(&None).await?;\n  assert_eq!(first_page.items.len(), page_size);\n  assert!(first_page.prev_page.is_none()); // without request cursor, no back cursor\n  assert!(first_page.next_page.is_some());\n\n  let last_page = get_page(&first_page.next_page).await?;\n  assert_eq!(last_page.items.len(), page_size - 1);\n  assert!(last_page.prev_page.is_some());\n  assert!(last_page.next_page.is_none());\n\n  // Get first page with both cursors\n  let first_page2 = get_page(&last_page.prev_page).await?;\n  assert_eq!(first_page2.items.len(), page_size);\n  assert!(first_page2.prev_page.is_some());\n  assert_eq!(first_page2.next_page, first_page.next_page);\n\n  let pool = &data.pool;\n  let pool = &mut pool.into();\n\n  // Mark first post as deleted\n  let first_post_view = first_page.items.first().expect(\"first post\");\n  let post_update_form = PostUpdateForm {\n    deleted: Some(true),\n    ..Default::default()\n  };\n  Post::update(pool, first_post_view.post.id, &post_update_form).await?;\n\n  let partial_first_page = get_page(&last_page.prev_page).await?;\n  assert_eq!(partial_first_page.items.len(), page_size - 1);\n  assert!(partial_first_page.prev_page.is_none());\n  assert!(partial_first_page.next_page.is_some());\n\n  // Cursor works for item marked as deleted\n  let removed_item_page = get_page(&first_page2.prev_page).await?;\n  assert_eq!(removed_item_page.items.len(), 0);\n  assert!(removed_item_page.prev_page.is_none());\n  assert!(removed_item_page.next_page.is_some()); // recovery cursor\n\n  let recovered_page = get_page(&removed_item_page.next_page).await?;\n  assert_eq!(recovered_page.items.len(), page_size);\n  assert!(recovered_page.prev_page.is_some());\n  assert!(recovered_page.next_page.is_some());\n\n  // Delete first post from the database\n  Post::delete(pool, first_post_view.post.id).await?;\n\n  let partial_first_page = get_page(&last_page.prev_page).await?;\n  assert_eq!(partial_first_page.items.len(), page_size - 1);\n  assert!(partial_first_page.prev_page.is_none());\n  assert!(partial_first_page.next_page.is_some());\n\n  // Cursor doesn't work for item that no longer exists\n  let removed_item_page = get_page(&first_page2.prev_page).await;\n  if let Err(LemmyError {\n    error_type,\n    cause: _,\n    caller: _,\n  }) = removed_item_page\n  {\n    assert_eq!(error_type, LemmyErrorType::NotFound);\n  } else {\n    unreachable!();\n  }\n\n  Community::delete(pool, inserted_community.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\n/// Test paging past the last and first page.\nasync fn pagination_recovery_cursors(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let community_form = CommunityInsertForm::new(\n    data.instance.id,\n    \"yes\".to_string(),\n    \"yes\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let inserted_community = Community::create(pool, &community_form).await?;\n\n  let page_size: usize = 5;\n\n  // Create 2 pages with 5 posts each\n  for i in 0..10 {\n    let post_form = PostInsertForm {\n      featured_local: Some((i % 2) == 0),\n      featured_community: Some((i % 2) == 0),\n      published_at: Some(Utc::now() - Duration::from_secs(i)),\n      ..PostInsertForm::new(\n        \"keep Christ in Christmas\".to_owned(),\n        data.tegan.person.id,\n        inserted_community.id,\n      )\n    };\n    Post::create(pool, &post_form).await?;\n  }\n\n  let options = PostQuery {\n    community_id: Some(inserted_community.id),\n    sort: Some(PostSortType::Hot),\n    limit: Some(page_size.try_into()?),\n    ..Default::default()\n  };\n\n  let mut get_page = async |cursor: &Option<PaginationCursor>| {\n    PostQuery {\n      page_cursor: cursor.clone(),\n      ..options.clone()\n    }\n    .list(&data.site, pool)\n    .await\n  };\n\n  let first_page = get_page(&None).await?;\n  assert_eq!(first_page.items.len(), page_size);\n  assert!(first_page.prev_page.is_none()); // without request cursor, no back cursor\n  assert!(first_page.next_page.is_some());\n\n  let last_page = get_page(&first_page.next_page).await?;\n  assert_eq!(last_page.items.len(), page_size);\n  assert!(last_page.prev_page.is_some());\n  assert!(last_page.next_page.is_some()); // full page, has cursor\n\n  // Get the first page with both cursors\n  let first_page2 = get_page(&last_page.prev_page).await?;\n  assert_eq!(first_page.items.len(), page_size);\n  assert!(first_page2.prev_page.is_some()); // full page, has cursor\n  assert!(first_page2.next_page.is_some());\n  assert_eq!(first_page2.next_page, first_page.next_page);\n  assert_eq!(\n    first_page2\n      .items\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>(),\n    first_page\n      .items\n      .clone()\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>()\n  );\n\n  let beyond_first_page = get_page(&first_page2.prev_page).await?;\n  assert_eq!(beyond_first_page.items.len(), 0);\n  assert!(beyond_first_page.prev_page.is_none());\n  assert!(beyond_first_page.next_page.is_some());\n\n  let recovered_first_page = get_page(&beyond_first_page.next_page).await?;\n  assert_eq!(recovered_first_page.items.len(), page_size);\n  assert!(recovered_first_page.prev_page.is_some()); // full page, has cursor\n  assert!(recovered_first_page.next_page.is_some());\n  assert_eq!(recovered_first_page.next_page, first_page2.next_page);\n  assert_eq!(recovered_first_page.prev_page, first_page2.prev_page);\n  assert_eq!(\n    recovered_first_page\n      .items\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>(),\n    first_page\n      .items\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>()\n  );\n\n  let beyond_last_page = get_page(&last_page.next_page).await?;\n  assert_eq!(beyond_last_page.items.len(), 0);\n  assert!(beyond_last_page.prev_page.is_some());\n  assert!(beyond_last_page.next_page.is_none());\n\n  let recovered_last_page = get_page(&beyond_last_page.prev_page).await?;\n  assert_eq!(recovered_last_page.items.len(), page_size);\n  assert!(recovered_last_page.prev_page.is_some());\n  assert!(recovered_last_page.next_page.is_some()); // full page, has cursor\n  assert_eq!(recovered_last_page.next_page, last_page.next_page);\n  assert_eq!(recovered_last_page.prev_page, last_page.prev_page);\n  assert_eq!(\n    recovered_last_page\n      .items\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>(),\n    last_page\n      .items\n      .into_iter()\n      .map(|pv| pv.post.id)\n      .collect::<Vec<PostId>>()\n  );\n\n  Community::delete(pool, inserted_community.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_hide_read(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Make sure local user hides read posts\n  let local_user_form = LocalUserUpdateForm {\n    show_read_posts: Some(false),\n    ..Default::default()\n  };\n  LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?;\n  data.tegan.local_user.show_read_posts = false;\n\n  // Mark a post as read\n  PostActions::mark_as_read(pool, data.tegan.person.id, &[data.bot_post.id]).await?;\n\n  // Make sure you don't see the read post in the results\n  let post_listings_hide_read = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_hide_read));\n\n  // Test with the show_read override as true\n  let post_listings_show_read_true = PostQuery {\n    show_read: Some(true),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_show_read_true)\n  );\n\n  // Test with the show_read override as false\n  let post_listings_show_read_false = PostQuery {\n    show_read: Some(false),\n    ..data.default_post_query()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST],\n    names(&post_listings_show_read_false)\n  );\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_hide_hidden(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Mark a post as hidden\n  let hide_form = PostHideForm::new(data.bot_post.id, data.tegan.person.id);\n  PostActions::hide(pool, &hide_form).await?;\n\n  // Make sure you don't see the hidden post in the results\n  let post_listings_hide_hidden = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST],\n    names(&post_listings_hide_hidden)\n  );\n\n  // Make sure it does come back with the show_hidden option\n  let post_listings_show_hidden = PostQuery {\n    sort: Some(PostSortType::New),\n    local_user: Some(&data.tegan.local_user),\n    show_hidden: Some(true),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_show_hidden)\n  );\n\n  // Make sure that hidden field is true.\n  assert!(&post_listings_show_hidden.get(1).is_some_and(|p| {\n    p.post_actions\n      .as_ref()\n      .is_some_and(|a| a.hidden_at.is_some())\n  }));\n\n  // Make sure only that one comes back for list_hidden\n  let list_hidden = PostView::list_hidden(pool, &data.tegan.person, None, None, None).await?;\n  assert_eq!(vec![POST_BY_BOT], names(&list_hidden));\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_hide_nsfw(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Mark a post as nsfw\n  let update_form = PostUpdateForm {\n    nsfw: Some(true),\n    ..Default::default()\n  };\n\n  Post::update(pool, data.post_with_tags.id, &update_form).await?;\n\n  // Make sure you don't see the nsfw post in the regular results\n  let post_listings_hide_nsfw = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_hide_nsfw));\n\n  // Make sure it does come back with the show_nsfw option\n  let post_listings_show_nsfw = PostQuery {\n    sort: Some(PostSortType::New),\n    show_nsfw: Some(true),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT, POST],\n    names(&post_listings_show_nsfw)\n  );\n\n  // Make sure that nsfw field is true.\n  assert!(\n    &post_listings_show_nsfw\n      .first()\n      .ok_or(LemmyErrorType::NotFound)?\n      .post\n      .nsfw\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn local_only_instance(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  Community::update(\n    pool,\n    data.community.id,\n    &CommunityUpdateForm {\n      visibility: Some(CommunityVisibility::LocalOnlyPrivate),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  let unauthenticated_query = PostQuery {\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(0, unauthenticated_query.len());\n\n  let authenticated_query = PostQuery {\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(3, authenticated_query.len());\n\n  let unauthenticated_post =\n    PostView::read(pool, data.post.id, None, data.instance.id, false).await;\n  assert!(unauthenticated_post.is_err());\n\n  let authenticated_post = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await;\n  assert!(authenticated_post.is_ok());\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_local_user_banned_from_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Test that post view shows if local user is blocked from community\n  let banned_from_comm_person = PersonInsertForm::test_form(data.instance.id, \"jill\");\n\n  let inserted_banned_from_comm_person = Person::create(pool, &banned_from_comm_person).await?;\n\n  let inserted_banned_from_comm_local_user = LocalUser::create(\n    pool,\n    &LocalUserInsertForm::test_form(inserted_banned_from_comm_person.id),\n    vec![],\n  )\n  .await?;\n\n  CommunityActions::ban(\n    pool,\n    &CommunityPersonBanForm::new(data.community.id, inserted_banned_from_comm_person.id),\n  )\n  .await?;\n\n  let post_view = PostView::read(\n    pool,\n    data.post.id,\n    Some(&inserted_banned_from_comm_local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert!(\n    post_view\n      .community_actions\n      .is_some_and(|x| x.received_ban_at.is_some())\n  );\n\n  Person::delete(pool, inserted_banned_from_comm_person.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_local_user_not_banned_from_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let post_view = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert!(post_view.community_actions.is_none());\n\n  Ok(())\n}\n\n/// Use microseconds for date checks\n///\n/// Necessary because postgres uses micros, but rust uses nanos\nfn micros(dt: DateTime<Utc>) -> i64 {\n  dt.timestamp_micros()\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_creator_banned(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let banned_person_form = PersonInsertForm::test_form(data.instance.id, \"jill\");\n\n  let banned_person = Person::create(pool, &banned_person_form).await?;\n\n  let post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(\n      \"banned person post\".to_string(),\n      banned_person.id,\n      data.community.id,\n    )\n  };\n  let banned_post = Post::create(pool, &post_form).await?;\n\n  let expires_at = Utc::now().checked_add_days(Days::new(1));\n\n  InstanceActions::ban(\n    pool,\n    &InstanceBanForm::new(banned_person.id, data.instance.id, expires_at),\n  )\n  .await?;\n\n  // Let john read their post\n  let post_view = PostView::read(\n    pool,\n    banned_post.id,\n    Some(&data.john.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert!(post_view.creator_banned);\n\n  // Make sure the expires at is correct\n  assert_eq!(\n    expires_at.map(micros),\n    post_view.creator_ban_expires_at.map(micros)\n  );\n\n  Person::delete(pool, banned_person.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_creator_community_banned(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let banned_person_form = PersonInsertForm::test_form(data.instance.id, \"jarvis\");\n\n  let banned_person = Person::create(pool, &banned_person_form).await?;\n\n  let post_form = PostInsertForm {\n    language_id: Some(LanguageId(1)),\n    ..PostInsertForm::new(\n      \"banned jarvis post\".to_string(),\n      banned_person.id,\n      data.community.id,\n    )\n  };\n  let banned_post = Post::create(pool, &post_form).await?;\n\n  let expires_at = Utc::now().checked_add_days(Days::new(1));\n\n  CommunityActions::ban(\n    pool,\n    &CommunityPersonBanForm {\n      ban_expires_at: Some(expires_at),\n      ..CommunityPersonBanForm::new(data.community.id, banned_person.id)\n    },\n  )\n  .await?;\n\n  // Let john read their post\n  let post_view = PostView::read(\n    pool,\n    banned_post.id,\n    Some(&data.john.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert!(post_view.creator_banned_from_community);\n  assert!(!post_view.creator_banned);\n\n  // Make sure the expires at is correct\n  assert_eq!(\n    expires_at.map(micros),\n    post_view.creator_community_ban_expires_at.map(micros)\n  );\n\n  Person::delete(pool, banned_person.id).await?;\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn speed_check(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Make sure the post_view query is less than this time\n  let duration_max = Duration::from_millis(120);\n\n  // Create some dummy posts\n  let num_posts = 1000;\n  for x in 1..num_posts {\n    let name = format!(\"post_{x}\");\n    let url = Some(Url::parse(&format!(\"https://google.com/{name}\"))?.into());\n\n    let post_form = PostInsertForm {\n      url,\n      ..PostInsertForm::new(name, data.tegan.person.id, data.community.id)\n    };\n    Post::create(pool, &post_form).await?;\n  }\n\n  // Manually trigger and wait for a statistics update to ensure consistent and high amount of\n  // accuracy in the statistics used for query planning\n  println!(\"🧮 updating database statistics\");\n  let conn = &mut get_conn(pool).await?;\n  conn.batch_execute(\"ANALYZE;\").await?;\n\n  // Time how fast the query took\n  let now = Instant::now();\n  PostQuery {\n    sort: Some(PostSortType::Active),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n\n  let elapsed = now.elapsed();\n  println!(\"Elapsed: {:.0?}\", elapsed);\n\n  assert!(\n    elapsed.lt(&duration_max),\n    \"Query took {:.0?}, longer than the max of {:.0?}\",\n    elapsed,\n    duration_max\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_no_comments_only(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Create a comment for a post\n  let comment_form =\n    CommentInsertForm::new(data.tegan.person.id, data.post.id, \"a comment\".to_owned());\n  Comment::create(pool, &comment_form, None).await?;\n\n  // Make sure it doesnt come back with the no_comments option\n  let post_listings_no_comments = PostQuery {\n    sort: Some(PostSortType::New),\n    no_comments_only: Some(true),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n\n  assert_eq!(\n    vec![POST_WITH_TAGS, POST_BY_BOT],\n    names(&post_listings_no_comments)\n  );\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_private_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Mark community as private\n  Community::update(\n    pool,\n    data.community.id,\n    &CommunityUpdateForm {\n      visibility: Some(CommunityVisibility::Private),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // No posts returned without auth\n  let read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(0, read_post_listing.len());\n  let post_view = PostView::read(pool, data.post.id, None, data.instance.id, false).await;\n  assert!(post_view.is_err());\n\n  // No posts returned for non-follower who is not admin\n  data.tegan.local_user.admin = false;\n  let read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(0, read_post_listing.len());\n  let post_view = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await;\n  assert!(post_view.is_err());\n\n  // Admin can view content without following\n  data.tegan.local_user.admin = true;\n  let read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(3, read_post_listing.len());\n  let post_view = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    true,\n  )\n  .await;\n  assert!(post_view.is_ok());\n  data.tegan.local_user.admin = false;\n\n  // User can view after following\n  let follow_form = CommunityFollowerForm::new(\n    data.community.id,\n    data.tegan.person.id,\n    CommunityFollowerState::Accepted,\n  );\n  CommunityActions::follow(pool, &follow_form).await?;\n\n  let read_post_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(3, read_post_listing.len());\n  let post_view = PostView::read(\n    pool,\n    data.post.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    true,\n  )\n  .await;\n  assert!(post_view.is_ok());\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listings_hide_media(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // Make one post an image post\n  Post::update(\n    pool,\n    data.bot_post.id,\n    &PostUpdateForm {\n      url_content_type: Some(Some(String::from(\"image/png\"))),\n      ..Default::default()\n    },\n  )\n  .await?;\n\n  // Make sure all the posts are returned when `hide_media` is unset\n  let hide_media_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(3, hide_media_listing.len());\n\n  // Ensure the `hide_media` user setting is set\n  let local_user_form = LocalUserUpdateForm {\n    hide_media: Some(true),\n    ..Default::default()\n  };\n  LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?;\n  data.tegan.local_user.hide_media = true;\n\n  // Ensure you don't see the image post\n  let hide_media_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(2, hide_media_listing.len());\n\n  // Make sure the `hide_media` override works\n  let hide_media_listing = PostQuery {\n    community_id: Some(data.community.id),\n    local_user: Some(&data.tegan.local_user),\n    hide_media: Some(false),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(3, hide_media_listing.len());\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_with_blocked_keywords(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let name_blocked = format!(\"post_{POST_KEYWORD_BLOCKED}\");\n  let name_blocked2 = format!(\"post2_{POST_KEYWORD_BLOCKED}2\");\n  let url = Some(Url::parse(&format!(\"https://google.com/{POST_KEYWORD_BLOCKED}\"))?.into());\n  let body = format!(\"post body with {POST_KEYWORD_BLOCKED}\");\n  let name_not_blocked = \"post_with_name_not_blocked\".to_string();\n  let name_not_blocked2 = \"post_with_name_not_blocked2\".to_string();\n\n  let post_name_blocked = PostInsertForm::new(\n    name_blocked.clone(),\n    data.tegan.person.id,\n    data.community.id,\n  );\n\n  let post_body_blocked = PostInsertForm {\n    body: Some(body),\n    ..PostInsertForm::new(\n      name_not_blocked.clone(),\n      data.tegan.person.id,\n      data.community.id,\n    )\n  };\n\n  let post_url_blocked = PostInsertForm {\n    url,\n    ..PostInsertForm::new(\n      name_not_blocked2.clone(),\n      data.tegan.person.id,\n      data.community.id,\n    )\n  };\n\n  let post_name_blocked_but_not_body_and_url = PostInsertForm {\n    body: Some(\"Some body\".to_string()),\n    url: Some(Url::parse(\"https://google.com\")?.into()),\n    ..PostInsertForm::new(\n      name_blocked2.clone(),\n      data.tegan.person.id,\n      data.community.id,\n    )\n  };\n  Post::create(pool, &post_name_blocked).await?;\n  Post::create(pool, &post_body_blocked).await?;\n  Post::create(pool, &post_url_blocked).await?;\n  Post::create(pool, &post_name_blocked_but_not_body_and_url).await?;\n\n  let keyword_blocks = Some(LocalUserKeywordBlock::read(pool, data.tegan.local_user.id).await?);\n\n  let post_listings = PostQuery {\n    local_user: Some(&data.tegan.local_user),\n    keyword_blocks,\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n\n  // Should not have any of the posts\n  assert!(!names(&post_listings).contains(&name_blocked.as_str()));\n  assert!(!names(&post_listings).contains(&name_blocked2.as_str()));\n  assert!(!names(&post_listings).contains(&name_not_blocked.as_str()));\n  assert!(!names(&post_listings).contains(&name_not_blocked2.as_str()));\n\n  // Should contain not blocked posts\n  assert!(names(&post_listings).contains(&POST_BY_BOT));\n  assert!(names(&post_listings).contains(&POST));\n  Ok(())\n}\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_tags_present(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  let post_view = PostView::read(\n    pool,\n    data.post_with_tags.id,\n    Some(&data.tegan.local_user),\n    data.instance.id,\n    false,\n  )\n  .await?;\n\n  assert_eq!(2, post_view.tags.0.len());\n  assert_eq!(data.tag_1.name, post_view.tags.0[0].name);\n  assert_eq!(data.tag_2.name, post_view.tags.0[1].name);\n  assert_eq!(data.tag_1.color, post_view.tags.0[0].color);\n  assert_eq!(data.tag_2.color, post_view.tags.0[1].color);\n\n  let all_posts = data.default_post_query().list(&data.site, pool).await?;\n  assert_eq!(2, all_posts[0].tags.0.len()); // post with tags\n  assert_eq!(0, all_posts[1].tags.0.len()); // bot post\n  assert_eq!(0, all_posts[2].tags.0.len()); // normal post\n\n  Ok(())\n}\n\n#[test_context(Data)]\n#[tokio::test]\n#[serial]\nasync fn post_listing_multi_community(data: &mut Data) -> LemmyResult<()> {\n  let pool = &data.pool();\n  let pool = &mut pool.into();\n\n  // create two more communities with one post each\n  let form = CommunityInsertForm::new(\n    data.instance.id,\n    \"test_community_4\".to_string(),\n    \"nada\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let community_1 = Community::create(pool, &form).await?;\n\n  let form = PostInsertForm::new(POST.to_string(), data.tegan.person.id, community_1.id);\n  let post_1 = Post::create(pool, &form).await?;\n\n  let form = CommunityInsertForm::new(\n    data.instance.id,\n    \"test_community_5\".to_string(),\n    \"nada\".to_owned(),\n    \"pubkey\".to_string(),\n  );\n  let community_2 = Community::create(pool, &form).await?;\n\n  let form = PostInsertForm::new(POST.to_string(), data.tegan.person.id, community_2.id);\n  let post_2 = Post::create(pool, &form).await?;\n\n  let form = MultiCommunityInsertForm::new(\n    data.tegan.person.id,\n    data.tegan.person.instance_id,\n    \"test multi\".to_string(),\n    String::new(),\n  );\n  let multi = MultiCommunity::create(pool, &form).await?;\n  MultiCommunity::update_entries(pool, multi.id, &vec![community_1.id, community_2.id]).await?;\n\n  let listing = PostQuery {\n    multi_community_id: Some(multi.id),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n\n  let listing_communities = listing\n    .iter()\n    .map(|l| l.community.id)\n    .collect::<HashSet<_>>();\n  assert_eq!(\n    HashSet::from([community_1.id, community_2.id]),\n    listing_communities\n  );\n\n  let listing_posts = listing.iter().map(|l| l.post.id).collect::<HashSet<_>>();\n  assert_eq!(HashSet::from([post_1.id, post_2.id]), listing_posts);\n\n  let suggested = PostQuery {\n    listing_type: Some(ListingType::Suggested),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert!(suggested.is_empty());\n\n  let form = LocalSiteUpdateForm {\n    suggested_multi_community_id: Some(Some(multi.id)),\n    ..Default::default()\n  };\n  LocalSite::update(pool, &form).await?;\n\n  let suggested = PostQuery {\n    listing_type: Some(ListingType::Suggested),\n    ..Default::default()\n  }\n  .list(&data.site, pool)\n  .await?;\n  assert_eq!(listing.items, suggested.items);\n\n  Ok(())\n}\n"
  },
  {
    "path": "crates/db_views/post_comment_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_post_comment_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"diesel\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_views_post/full\",\n  \"lemmy_db_views_comment/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_views_post/ts-rs\",\n  \"lemmy_db_views_comment/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_views_post = { workspace = true }\nlemmy_db_views_comment = { workspace = true }\nlemmy_db_schema = { workspace = true }\ndiesel = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\nchrono = { workspace = true }\n\n[dev-dependencies]\n"
  },
  {
    "path": "crates/db_views/post_comment_combined/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::{\n  comment::{Comment, CommentActions},\n  community::{Community, CommunityActions},\n  community_tag::CommunityTagsView,\n  images::ImageDetails,\n  person::{Person, PersonActions},\n  post::{Post, PostActions},\n};\nuse lemmy_db_views_comment::CommentView;\nuse lemmy_db_views_post::PostView;\nuse serde::{Deserialize, Serialize};\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::traits::InternalToCombinedView,\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeCommunityBanExpiresType,\n    creator_ban_expires_from_community,\n    creator_banned_from_community,\n    creator_is_admin,\n    creator_is_moderator,\n    creator_local_home_community_ban_expires,\n    creator_local_home_community_banned,\n    local_user_can_mod,\n    post_community_tags_fragment,\n  },\n};\n\n#[cfg(feature = \"full\")]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)]\n#[diesel(check_for_backend(diesel::pg::Pg))]\n/// A combined person_saved view\npub struct PostCommentCombinedViewInternal {\n  #[diesel(embed)]\n  pub comment: Option<Comment>,\n  #[diesel(embed)]\n  pub post: Post,\n  #[diesel(embed)]\n  pub item_creator: Person,\n  #[diesel(embed)]\n  pub community: Community,\n  #[diesel(embed)]\n  pub community_actions: Option<CommunityActions>,\n  #[diesel(embed)]\n  pub post_actions: Option<PostActions>,\n  #[diesel(embed)]\n  pub person_actions: Option<PersonActions>,\n  #[diesel(embed)]\n  pub comment_actions: Option<CommentActions>,\n  #[diesel(embed)]\n  pub image_details: Option<ImageDetails>,\n  #[diesel(select_expression = creator_is_admin())]\n  pub item_creator_is_admin: bool,\n  #[diesel(select_expression = post_community_tags_fragment())]\n  pub tags: CommunityTagsView,\n  #[diesel(select_expression = local_user_can_mod())]\n  pub can_mod: bool,\n  #[diesel(select_expression = creator_local_home_community_banned())]\n  pub creator_banned: bool,\n  #[diesel(\n    select_expression_type = CreatorLocalHomeCommunityBanExpiresType,\n    select_expression = creator_local_home_community_ban_expires()\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[diesel(select_expression = creator_is_moderator())]\n  pub creator_is_moderator: bool,\n  #[diesel(select_expression = creator_banned_from_community())]\n  pub creator_banned_from_community: bool,\n  #[diesel(select_expression = creator_ban_expires_from_community())]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"type_\", rename_all = \"snake_case\")]\npub enum PostCommentCombinedView {\n  Post(PostView),\n  Comment(CommentView),\n}\n\n#[cfg(feature = \"full\")]\nimpl InternalToCombinedView for PostCommentCombinedViewInternal {\n  type CombinedView = PostCommentCombinedView;\n\n  fn map_to_enum(self) -> Option<Self::CombinedView> {\n    // Use for a short alias\n    let v = self;\n\n    if let Some(comment) = v.comment {\n      Some(PostCommentCombinedView::Comment(CommentView {\n        comment,\n        post: v.post,\n        community: v.community,\n        creator: v.item_creator,\n        community_actions: v.community_actions,\n        comment_actions: v.comment_actions,\n        person_actions: v.person_actions,\n        creator_is_admin: v.item_creator_is_admin,\n        tags: v.tags,\n        can_mod: v.can_mod,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else {\n      Some(PostCommentCombinedView::Post(PostView {\n        post: v.post,\n        community: v.community,\n        creator: v.item_creator,\n        image_details: v.image_details,\n        community_actions: v.community_actions,\n        post_actions: v.post_actions,\n        person_actions: v.person_actions,\n        creator_is_admin: v.item_creator_is_admin,\n        tags: v.tags,\n        can_mod: v.can_mod,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    }\n  }\n}\n\nimpl PostCommentCombinedView {\n  /// Useful in combination with filter_map\n  pub fn to_post_view(&self) -> Option<&PostView> {\n    if let Self::Post(v) = self {\n      Some(v)\n    } else {\n      None\n    }\n  }\n}\n"
  },
  {
    "path": "crates/db_views/private_message/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_private_message\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\n"
  },
  {
    "path": "crates/db_views/private_message/src/api.rs",
    "content": "use crate::PrivateMessageView;\nuse lemmy_db_schema::newtypes::PrivateMessageId;\nuse lemmy_db_schema_file::PersonId;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a private message.\npub struct CreatePrivateMessage {\n  pub content: String,\n  pub recipient_id: PersonId,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete a private message.\npub struct DeletePrivateMessage {\n  pub private_message_id: PrivateMessageId,\n  pub deleted: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit a private message.\npub struct EditPrivateMessage {\n  pub private_message_id: PrivateMessageId,\n  pub content: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A single private message response.\npub struct PrivateMessageResponse {\n  pub private_message_view: PrivateMessageView,\n}\n"
  },
  {
    "path": "crates/db_views/private_message/src/impls.rs",
    "content": "use crate::PrivateMessageView;\nuse diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse lemmy_db_schema::{newtypes::PrivateMessageId, source::person::Person};\nuse lemmy_db_schema_file::{\n  aliases,\n  schema::{instance_actions, person, person_actions, private_message},\n};\nuse lemmy_diesel_utils::connection::{DbPool, get_conn};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl PrivateMessageView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    let recipient_id = aliases::person1.field(person::id);\n\n    let creator_join = person::table.on(private_message::creator_id.eq(person::id));\n    let recipient_join = aliases::person1.on(private_message::recipient_id.eq(recipient_id));\n\n    let person_actions_join = person_actions::table.on(\n      person_actions::target_id\n        .eq(private_message::creator_id)\n        .and(person_actions::person_id.eq(recipient_id)),\n    );\n\n    let instance_actions_join = instance_actions::table.on(\n      instance_actions::instance_id\n        .eq(person::instance_id)\n        .and(instance_actions::person_id.eq(recipient_id)),\n    );\n\n    private_message::table\n      .inner_join(creator_join)\n      .inner_join(recipient_join)\n      .left_join(person_actions_join)\n      .left_join(instance_actions_join)\n  }\n\n  pub async fn read(\n    pool: &mut DbPool<'_>,\n    private_message_id: PrivateMessageId,\n    my_person: Option<&Person>,\n  ) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    let mut pm = Self::joins()\n      .filter(private_message::id.eq(private_message_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n\n    pm.private_message.clear_deleted_by_recipient(my_person);\n    Ok(pm)\n  }\n}\n"
  },
  {
    "path": "crates/db_views/private_message/src/lib.rs",
    "content": "use lemmy_db_schema::source::{person::Person, private_message::PrivateMessage};\nuse serde::{Deserialize, Serialize};\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::Person1AliasAllColumnsTuple,\n  lemmy_db_schema::utils::queries::selects::person1_select,\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A private message view.\npub struct PrivateMessageView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub private_message: PrivateMessage,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = Person1AliasAllColumnsTuple,\n      select_expression = person1_select()\n    )\n  )]\n  pub recipient: Person,\n}\n"
  },
  {
    "path": "crates/db_views/registration_applications/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_registration_applications\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"extism\",\n  \"extism-convert\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nextism = { workspace = true, optional = true }\nextism-convert = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/registration_applications/src/api.rs",
    "content": "use crate::RegistrationApplicationView;\nuse lemmy_db_schema::newtypes::RegistrationApplicationId;\nuse lemmy_db_schema_file::PersonId;\nuse lemmy_diesel_utils::{pagination::PaginationCursor, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {extism::ToBytes, extism_convert::Json};\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Approves a registration application.\npub struct ApproveRegistrationApplication {\n  pub id: RegistrationApplicationId,\n  pub approve: bool,\n  pub deny_reason: Option<String>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Gets a registration application for a person\npub struct GetRegistrationApplication {\n  pub person_id: PersonId,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches a list of registration applications.\npub struct ListRegistrationApplications {\n  /// Only shows the unread applications (IE those without an admin actor)\n  pub unread_only: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Register / Sign up to lemmy.\npub struct Register {\n  pub username: String,\n  pub password: SensitiveString,\n  pub password_verify: SensitiveString,\n  pub show_nsfw: Option<bool>,\n  /// email is mandatory if email verification is enabled on the server\n  pub email: Option<SensitiveString>,\n  /// The UUID of the captcha item.\n  pub captcha_uuid: Option<String>,\n  /// Your captcha answer.\n  pub captcha_answer: Option<String>,\n  /// A form field to trick signup bots. Should be None.\n  pub honeypot: Option<String>,\n  /// An answer is mandatory if require application is enabled on the server\n  pub answer: Option<String>,\n  /// If this is true the login is valid forever, otherwise it expires after one week.\n  pub stay_logged_in: Option<bool>,\n}\n\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(ToBytes,))]\n#[cfg_attr(feature = \"full\", encoding(Json))]\npub struct CaptchaAnswer {\n  pub answer: String,\n  pub uuid: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The response of an action done to a registration application.\npub struct RegistrationApplicationResponse {\n  pub registration_application: RegistrationApplicationView,\n}\n"
  },
  {
    "path": "crates/db_views/registration_applications/src/impls.rs",
    "content": "use crate::RegistrationApplicationView;\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  dsl::count,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  newtypes::RegistrationApplicationId,\n  source::registration_application::{\n    RegistrationApplication,\n    registration_application_keys as key,\n  },\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  PersonId,\n  aliases,\n  schema::{local_user, person, registration_application},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  traits::Crud,\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl PaginationCursorConversion for RegistrationApplicationView {\n  type PaginatedType = RegistrationApplication;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.registration_application.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    RegistrationApplication::read(pool, RegistrationApplicationId(cursor.id()?)).await\n  }\n}\n\nimpl RegistrationApplicationView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    let local_user_join =\n      local_user::table.on(registration_application::local_user_id.eq(local_user::id));\n\n    let creator_join = person::table.on(local_user::person_id.eq(person::id));\n    let admin_join = aliases::person1\n      .on(registration_application::admin_id.eq(aliases::person1.field(person::id).nullable()));\n\n    registration_application::table\n      .inner_join(local_user_join)\n      .inner_join(creator_join)\n      .left_join(admin_join)\n  }\n\n  pub async fn read(pool: &mut DbPool<'_>, id: RegistrationApplicationId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(registration_application::id.eq(id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  pub async fn read_by_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(person::id.eq(person_id))\n      .select(Self::as_select())\n      .first(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n\n  /// Returns the current unread registration_application count\n  pub async fn get_unread_count(\n    pool: &mut DbPool<'_>,\n    verified_email_only: bool,\n  ) -> LemmyResult<i64> {\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = Self::joins()\n      .filter(RegistrationApplication::is_unread())\n      .select(count(registration_application::id))\n      .into_boxed();\n\n    if verified_email_only {\n      query = query.filter(local_user::email_verified.eq(true))\n    }\n\n    query\n      .first::<i64>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\n#[derive(Default)]\npub struct RegistrationApplicationQuery {\n  pub unread_only: Option<bool>,\n  pub verified_email_only: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\nimpl RegistrationApplicationQuery {\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<PagedResponse<RegistrationApplicationView>> {\n    let limit = limit_fetch(self.limit, None)?;\n    let o = self;\n\n    let mut query = RegistrationApplicationView::joins()\n      .select(RegistrationApplicationView::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if o.unread_only.unwrap_or_default() {\n      query = query\n        .filter(RegistrationApplication::is_unread())\n        .order_by(registration_application::published_at.asc());\n    } else {\n      query = query.order_by(registration_application::published_at.desc());\n    }\n\n    if o.verified_email_only.unwrap_or_default() {\n      query = query.filter(local_user::email_verified.eq(true))\n    }\n\n    // Sorting by published\n    let paginated_query =\n      RegistrationApplicationView::paginate(query, &o.page_cursor, SortDirection::Desc, pool, None)\n        .await?\n        .then_order_by(key::published_at);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<RegistrationApplicationView>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, o.page_cursor)\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::{RegistrationApplicationView, impls::RegistrationApplicationQuery};\n  use lemmy_db_schema::source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},\n    person::{Person, PersonInsertForm},\n    registration_application::{\n      RegistrationApplication,\n      RegistrationApplicationInsertForm,\n      RegistrationApplicationUpdateForm,\n    },\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_crud() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_person_form = PersonInsertForm::test_form(instance.id, \"timmy_rav\");\n\n    let timmy_person = Person::create(pool, &timmy_person_form).await?;\n\n    let timmy_local_user_form = LocalUserInsertForm::test_form_admin(timmy_person.id);\n\n    let _inserted_timmy_local_user =\n      LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n\n    let sara_person_form = PersonInsertForm::test_form(instance.id, \"sara_rav\");\n\n    let sara_person = Person::create(pool, &sara_person_form).await?;\n\n    let sara_local_user_form = LocalUserInsertForm::test_form(sara_person.id);\n\n    let sara_local_user = LocalUser::create(pool, &sara_local_user_form, vec![]).await?;\n\n    // Sara creates an application\n    let sara_app_form = RegistrationApplicationInsertForm {\n      local_user_id: sara_local_user.id,\n      answer: \"LET ME IIIIINN\".to_string(),\n    };\n\n    let sara_app = RegistrationApplication::create(pool, &sara_app_form).await?;\n\n    let read_sara_app_view = RegistrationApplicationView::read(pool, sara_app.id).await?;\n\n    let jess_person_form = PersonInsertForm::test_form(instance.id, \"jess_rav\");\n\n    let inserted_jess_person = Person::create(pool, &jess_person_form).await?;\n\n    let jess_local_user_form = LocalUserInsertForm::test_form(inserted_jess_person.id);\n\n    let jess_local_user = LocalUser::create(pool, &jess_local_user_form, vec![]).await?;\n\n    // Sara creates an application\n    let jess_app_form = RegistrationApplicationInsertForm {\n      local_user_id: jess_local_user.id,\n      answer: \"LET ME IIIIINN\".to_string(),\n    };\n\n    let jess_app = RegistrationApplication::create(pool, &jess_app_form).await?;\n\n    let read_jess_app_view = RegistrationApplicationView::read(pool, jess_app.id).await?;\n\n    let mut expected_sara_app_view = RegistrationApplicationView {\n      registration_application: sara_app.clone(),\n      creator_local_user: LocalUser {\n        id: sara_local_user.id,\n        person_id: sara_local_user.person_id,\n        email: sara_local_user.email,\n        show_nsfw: sara_local_user.show_nsfw,\n        blur_nsfw: sara_local_user.blur_nsfw,\n        theme: sara_local_user.theme,\n        default_post_sort_type: sara_local_user.default_post_sort_type,\n        default_comment_sort_type: sara_local_user.default_comment_sort_type,\n        default_listing_type: sara_local_user.default_listing_type,\n        default_items_per_page: sara_local_user.default_items_per_page,\n        interface_language: sara_local_user.interface_language,\n        show_avatars: sara_local_user.show_avatars,\n        send_notifications_to_email: sara_local_user.send_notifications_to_email,\n        show_bot_accounts: sara_local_user.show_bot_accounts,\n        show_read_posts: sara_local_user.show_read_posts,\n        email_verified: sara_local_user.email_verified,\n        accepted_application: sara_local_user.accepted_application,\n        totp_2fa_secret: sara_local_user.totp_2fa_secret,\n        password_encrypted: sara_local_user.password_encrypted,\n        open_links_in_new_tab: sara_local_user.open_links_in_new_tab,\n        infinite_scroll_enabled: sara_local_user.infinite_scroll_enabled,\n        post_listing_mode: sara_local_user.post_listing_mode,\n        totp_2fa_enabled: sara_local_user.totp_2fa_enabled,\n        enable_animated_images: sara_local_user.enable_animated_images,\n        enable_private_messages: sara_local_user.enable_private_messages,\n        collapse_bot_comments: sara_local_user.collapse_bot_comments,\n        last_donation_notification_at: sara_local_user.last_donation_notification_at,\n        show_upvotes: sara_local_user.show_upvotes,\n        show_downvotes: sara_local_user.show_downvotes,\n        admin: sara_local_user.admin,\n        auto_mark_fetched_posts_as_read: sara_local_user.auto_mark_fetched_posts_as_read,\n        hide_media: sara_local_user.hide_media,\n        default_post_time_range_seconds: sara_local_user.default_post_time_range_seconds,\n        show_score: sara_local_user.show_score,\n        show_upvote_percentage: sara_local_user.show_upvote_percentage,\n        show_person_votes: sara_local_user.show_person_votes,\n      },\n      creator: Person {\n        id: sara_person.id,\n        name: sara_person.name.clone(),\n        display_name: None,\n        published_at: sara_person.published_at,\n        avatar: None,\n        ap_id: sara_person.ap_id.clone(),\n        local: true,\n        deleted: false,\n        bot_account: false,\n        bio: None,\n        banner: None,\n        updated_at: None,\n        inbox_url: sara_person.inbox_url.clone(),\n        matrix_user_id: None,\n        instance_id: instance.id,\n        private_key: sara_person.private_key,\n        public_key: sara_person.public_key,\n        last_refreshed_at: sara_person.last_refreshed_at,\n        post_count: 0,\n        post_score: 0,\n        comment_count: 0,\n        comment_score: 0,\n      },\n      admin: None,\n    };\n\n    assert_eq!(read_sara_app_view, expected_sara_app_view);\n\n    // Do a batch read of the applications\n    let apps = RegistrationApplicationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n\n    assert_eq!(\n      apps,\n      [expected_sara_app_view.clone(), read_jess_app_view.clone()]\n    );\n\n    // Make sure the counts are correct\n    let unread_count = RegistrationApplicationView::get_unread_count(pool, false).await?;\n    assert_eq!(unread_count, 2);\n\n    // Approve the application\n    let approve_form = RegistrationApplicationUpdateForm {\n      admin_id: Some(Some(timmy_person.id)),\n      deny_reason: None,\n      // Normally this would be Utc::now()\n      updated_at: None,\n    };\n\n    RegistrationApplication::update(pool, sara_app.id, &approve_form).await?;\n\n    // Update the local_user row\n    let approve_local_user_form = LocalUserUpdateForm {\n      accepted_application: Some(true),\n      ..Default::default()\n    };\n\n    LocalUser::update(pool, sara_local_user.id, &approve_local_user_form).await?;\n\n    let read_sara_app_view_after_approve =\n      RegistrationApplicationView::read(pool, sara_app.id).await?;\n\n    // Make sure the columns changed\n    expected_sara_app_view\n      .creator_local_user\n      .accepted_application = true;\n    expected_sara_app_view.registration_application.admin_id = Some(timmy_person.id);\n\n    expected_sara_app_view.admin = Some(Person {\n      id: timmy_person.id,\n      name: timmy_person.name.clone(),\n      display_name: None,\n      published_at: timmy_person.published_at,\n      avatar: None,\n      ap_id: timmy_person.ap_id.clone(),\n      local: true,\n      deleted: false,\n      bot_account: false,\n      bio: None,\n      banner: None,\n      updated_at: None,\n      inbox_url: timmy_person.inbox_url.clone(),\n      matrix_user_id: None,\n      instance_id: instance.id,\n      private_key: timmy_person.private_key,\n      public_key: timmy_person.public_key,\n      last_refreshed_at: timmy_person.last_refreshed_at,\n      post_count: 0,\n      post_score: 0,\n      comment_count: 0,\n      comment_score: 0,\n    });\n    assert_eq!(read_sara_app_view_after_approve, expected_sara_app_view);\n\n    // Do a batch read of apps again\n    // It should show only jessicas which is unresolved\n    let apps_after_resolve = RegistrationApplicationQuery {\n      unread_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool)\n    .await?\n    .items;\n    assert_eq!(apps_after_resolve, vec![read_jess_app_view]);\n\n    // Make sure the counts are correct\n    let unread_count_after_approve =\n      RegistrationApplicationView::get_unread_count(pool, false).await?;\n    assert_eq!(unread_count_after_approve, 1);\n\n    // Make sure the not undenied_only has all the apps\n    let all_apps = RegistrationApplicationQuery::default().list(pool).await?;\n    assert_eq!(all_apps.len(), 2);\n\n    Person::delete(pool, timmy_person.id).await?;\n    Person::delete(pool, sara_person.id).await?;\n    Person::delete(pool, inserted_jess_person.id).await?;\n    Instance::delete(pool, instance.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/registration_applications/src/lib.rs",
    "content": "use lemmy_db_schema::source::{\n  local_user::LocalUser,\n  person::Person,\n  registration_application::RegistrationApplication,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{NullableExpressionMethods, Queryable, Selectable, helper_types::Nullable},\n  lemmy_db_schema::{Person1AliasAllColumnsTuple, utils::queries::selects::person1_select},\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A registration application view.\npub struct RegistrationApplicationView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub registration_application: RegistrationApplication,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator_local_user: LocalUser,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\",\n    diesel(\n      select_expression_type = Nullable<Person1AliasAllColumnsTuple>,\n      select_expression = person1_select().nullable()\n    )\n  )]\n  pub admin: Option<Person>,\n}\n"
  },
  {
    "path": "crates/db_views/report_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_report_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\npublish = false\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_report_combined_sql\",\n  \"lemmy_diesel_utils/full\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_views_report_combined_sql = { workspace = true, optional = true }\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nchrono = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/report_combined/src/api.rs",
    "content": "use crate::{CommentReportView, CommunityReportView, PostReportView, PrivateMessageReportView};\nuse lemmy_db_schema::{\n  ReportType,\n  newtypes::{\n    CommentId,\n    CommentReportId,\n    CommunityId,\n    CommunityReportId,\n    PostId,\n    PostReportId,\n    PrivateMessageId,\n    PrivateMessageReportId,\n  },\n};\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// List reports.\npub struct ListReports {\n  /// Only shows the unresolved reports\n  pub unresolved_only: Option<bool>,\n  /// Filter the type of report.\n  pub type_: Option<ReportType>,\n  /// Filter by the post id. Can return either comment or post reports.\n  pub post_id: Option<PostId>,\n  /// if no community is given, it returns reports for all communities moderated by the auth user\n  pub community_id: Option<CommunityId>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n  /// Only for admins: also show reports with `violates_instance_rules=false`\n  pub show_community_rule_violations: Option<bool>,\n  /// If true, view all your created reports. Works for non-admins/mods also.\n  pub my_reports_only: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The comment report response.\npub struct CommentReportResponse {\n  pub comment_report_view: CommentReportView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community report response.\npub struct CommunityReportResponse {\n  pub community_report_view: CommunityReportView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Report a comment.\npub struct CreateCommentReport {\n  pub comment_id: CommentId,\n  pub reason: String,\n  /// The comment violates rules of the local instance. This report will only be shown to local\n  /// admins, not to community mods and will not be federated.\n  pub violates_instance_rules: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a report for a community.\npub struct CreateCommunityReport {\n  pub community_id: CommunityId,\n  pub reason: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a post report.\npub struct CreatePostReport {\n  pub post_id: PostId,\n  pub reason: String,\n  /// The post violates rules of the local instance. This report will only be shown to local\n  /// admins, not to community mods and will not be federated.\n  pub violates_instance_rules: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Resolve a comment report (only doable by mods).\npub struct ResolveCommentReport {\n  pub report_id: CommentReportId,\n  pub resolved: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Resolve a community report.\npub struct ResolveCommunityReport {\n  pub report_id: CommunityReportId,\n  pub resolved: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Resolve a post report (mods only).\npub struct ResolvePostReport {\n  pub report_id: PostReportId,\n  pub resolved: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Resolve a private message report.\npub struct ResolvePrivateMessageReport {\n  pub report_id: PrivateMessageReportId,\n  pub resolved: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a report for a private message.\npub struct CreatePrivateMessageReport {\n  pub private_message_id: PrivateMessageId,\n  pub reason: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A private message report response.\npub struct PrivateMessageReportResponse {\n  pub private_message_report_view: PrivateMessageReportView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The post report response.\npub struct PostReportResponse {\n  pub post_report_view: PostReportView,\n}\n"
  },
  {
    "path": "crates/db_views/report_combined/src/impls.rs",
    "content": "use crate::{\n  CommentReportView,\n  CommunityReportView,\n  LocalUserView,\n  PostReportView,\n  PrivateMessageReportView,\n  ReportCombinedView,\n  ReportCombinedViewInternal,\n};\nuse chrono::{DateTime, Days, Utc};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  PgExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  dsl::not,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::asc_if;\nuse lemmy_db_schema::{\n  ReportType,\n  newtypes::{\n    CommentReportId,\n    CommunityId,\n    CommunityReportId,\n    PostId,\n    PostReportId,\n    PrivateMessageReportId,\n  },\n  source::{\n    combined::report::{ReportCombined, report_combined_keys as key},\n    person::Person,\n  },\n  traits::InternalToCombinedView,\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  aliases,\n  schema::{\n    comment_report,\n    community,\n    community_actions,\n    person,\n    post,\n    post_report,\n    report_combined,\n  },\n};\nuse lemmy_db_views_report_combined_sql::report_combined_joins;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\n\nimpl ReportCombinedViewInternal {\n  pub async fn read_comment_report(\n    pool: &mut DbPool<'_>,\n    report_id: CommentReportId,\n    my_person: &Person,\n  ) -> LemmyResult<CommentReportView> {\n    let conn = &mut get_conn(pool).await?;\n    let res = report_combined_joins(my_person.id, my_person.instance_id)\n      .filter(report_combined::comment_report_id.eq(report_id))\n      .select(ReportCombinedViewInternal::as_select())\n      .first(conn)\n      .await?;\n\n    let res = InternalToCombinedView::map_to_enum(res);\n    let Some(ReportCombinedView::Comment(c)) = res else {\n      return Err(LemmyErrorType::NotFound.into());\n    };\n    Ok(c)\n  }\n\n  pub async fn read_post_report(\n    pool: &mut DbPool<'_>,\n    report_id: PostReportId,\n    my_person: &Person,\n  ) -> LemmyResult<PostReportView> {\n    let conn = &mut get_conn(pool).await?;\n    let res = report_combined_joins(my_person.id, my_person.instance_id)\n      .filter(report_combined::post_report_id.eq(report_id))\n      .select(ReportCombinedViewInternal::as_select())\n      .first(conn)\n      .await?;\n\n    let res = InternalToCombinedView::map_to_enum(res);\n    let Some(ReportCombinedView::Post(p)) = res else {\n      return Err(LemmyErrorType::NotFound.into());\n    };\n    Ok(p)\n  }\n\n  pub async fn read_community_report(\n    pool: &mut DbPool<'_>,\n    report_id: CommunityReportId,\n    my_person: &Person,\n  ) -> LemmyResult<CommunityReportView> {\n    let conn = &mut get_conn(pool).await?;\n    let res = report_combined_joins(my_person.id, my_person.instance_id)\n      .filter(report_combined::community_report_id.eq(report_id))\n      .select(ReportCombinedViewInternal::as_select())\n      .first(conn)\n      .await?;\n\n    let res = InternalToCombinedView::map_to_enum(res);\n    let Some(ReportCombinedView::Community(c)) = res else {\n      return Err(LemmyErrorType::NotFound.into());\n    };\n    Ok(c)\n  }\n\n  pub async fn read_private_message_report(\n    pool: &mut DbPool<'_>,\n    report_id: PrivateMessageReportId,\n    my_person: &Person,\n  ) -> LemmyResult<PrivateMessageReportView> {\n    let conn = &mut get_conn(pool).await?;\n    let res = report_combined_joins(my_person.id, my_person.instance_id)\n      .filter(report_combined::private_message_report_id.eq(report_id))\n      .select(ReportCombinedViewInternal::as_select())\n      .first(conn)\n      .await?;\n\n    let res = InternalToCombinedView::map_to_enum(res);\n    let Some(ReportCombinedView::PrivateMessage(pm)) = res else {\n      return Err(LemmyErrorType::NotFound.into());\n    };\n    Ok(pm)\n  }\n\n  /// returns the current unresolved report count for the communities you mod\n  pub async fn get_report_count(pool: &mut DbPool<'_>, user: &LocalUserView) -> LemmyResult<i64> {\n    use diesel::dsl::count;\n\n    let conn = &mut get_conn(pool).await?;\n\n    let mut query = report_combined_joins(user.person.id, user.person.instance_id)\n      .filter(not(report_combined::resolved))\n      .select(count(report_combined::id))\n      .into_boxed();\n\n    if user.local_user.admin {\n      query = query.filter(filter_admin_reports(Utc::now() - Days::new(3)));\n    } else {\n      query = query.filter(filter_mod_reports());\n    }\n\n    query\n      .first::<i64>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl PaginationCursorConversion for ReportCombinedView {\n  type PaginatedType = ReportCombined;\n\n  fn to_cursor(&self) -> CursorData {\n    let (prefix, id) = match &self {\n      ReportCombinedView::Comment(v) => ('C', v.comment_report.id.0),\n      ReportCombinedView::Post(v) => ('P', v.post_report.id.0),\n      ReportCombinedView::PrivateMessage(v) => ('M', v.private_message_report.id.0),\n      ReportCombinedView::Community(v) => ('Y', v.community_report.id.0),\n    };\n    CursorData::new_with_prefix(prefix, id)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let (prefix, id) = cursor.id_and_prefix()?;\n\n    let mut query = report_combined::table\n      .select(Self::PaginatedType::as_select())\n      .into_boxed();\n\n    query = match prefix {\n      'C' => query.filter(report_combined::comment_report_id.eq(id)),\n      'P' => query.filter(report_combined::post_report_id.eq(id)),\n      'M' => query.filter(report_combined::private_message_report_id.eq(id)),\n      'Y' => query.filter(report_combined::community_report_id.eq(id)),\n      _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()),\n    };\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\n#[derive(Default)]\npub struct ReportCombinedQuery {\n  pub type_: Option<ReportType>,\n  pub post_id: Option<PostId>,\n  pub community_id: Option<CommunityId>,\n  pub unresolved_only: Option<bool>,\n  /// For admins, also show reports with `violates_instance_rules=false`\n  pub show_community_rule_violations: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub my_reports_only: Option<bool>,\n  pub limit: Option<i64>,\n}\n\nimpl ReportCombinedQuery {\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    user: &LocalUserView,\n  ) -> LemmyResult<PagedResponse<ReportCombinedView>> {\n    let limit = limit_fetch(self.limit, None)?;\n\n    let report_creator = aliases::person1.field(person::id);\n\n    let mut query = report_combined_joins(user.person.id, user.person.instance_id)\n      .select(ReportCombinedViewInternal::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(community_id) = self.community_id {\n      query = query.filter(\n        community::id\n          .eq(community_id)\n          .and(report_combined::community_report_id.is_null()),\n      );\n    }\n\n    if user.local_user.admin {\n      let show_community_rule_violations = self.show_community_rule_violations.unwrap_or_default();\n      if !show_community_rule_violations {\n        query = query.filter(filter_admin_reports(Utc::now() - Days::new(3)));\n      }\n    } else {\n      query = query.filter(filter_mod_reports());\n    }\n\n    if let Some(post_id) = self.post_id {\n      query = query.filter(post::id.eq(post_id));\n    }\n\n    if self.my_reports_only.unwrap_or_default() {\n      query = query.filter(report_creator.eq(user.person.id));\n    }\n\n    if let Some(type_) = self.type_ {\n      query = match type_ {\n        ReportType::All => query,\n        ReportType::Posts => query.filter(report_combined::post_report_id.is_not_null()),\n        ReportType::Comments => query.filter(report_combined::comment_report_id.is_not_null()),\n        ReportType::PrivateMessages => {\n          query.filter(report_combined::private_message_report_id.is_not_null())\n        }\n        ReportType::Communities => query.filter(report_combined::community_report_id.is_not_null()),\n      }\n    }\n\n    // If viewing all reports, order by newest, but if viewing unresolved only, show the oldest\n    // first (FIFO)\n    let unresolved_only = self.unresolved_only.unwrap_or_default();\n    let sort_direction = asc_if(unresolved_only);\n\n    if unresolved_only {\n      query = query.filter(not(report_combined::resolved));\n    };\n\n    // Sorting by published\n    let paginated_query =\n      ReportCombinedView::paginate(query, &self.page_cursor, sort_direction, pool, None)\n        .await?\n        .then_order_by(key::published_at)\n        // Tie breaker\n        .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<ReportCombinedViewInternal>(conn)\n      .await?;\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .filter_map(InternalToCombinedView::map_to_enum)\n      .collect();\n\n    paginate_response(out, limit, self.page_cursor)\n  }\n}\n\n/// Mods can only see reports for posts/comments inside of communities where they are moderator,\n/// and which have `violates_instance_rules == false`.\n#[diesel::dsl::auto_type]\nfn filter_mod_reports() -> _ {\n  community_actions::became_moderator_at\n    .is_not_null()\n    // Reporting a community or private message must go to admins\n    .and(report_combined::community_report_id.is_null())\n    .and(report_combined::private_message_report_id.is_null())\n    .and(filter_violates_instance_rules().is_distinct_from(true))\n}\n\n/// Admins can see reports intended for them, or mod reports older than 3 days. Also reports\n/// on communities, person and private messages.\n#[diesel::dsl::auto_type]\nfn filter_admin_reports(interval: DateTime<Utc>) -> _ {\n  filter_violates_instance_rules()\n    .or(report_combined::published_at.lt(interval))\n    // Also show community reports where the admin is a community mod\n    .or(community_actions::became_moderator_at.is_not_null())\n}\n\n/// Filter reports which are only for admins (either post/comment report with\n/// `violates_instance_rules=true`, or report on a community/person/private message.\n#[diesel::dsl::auto_type]\nfn filter_violates_instance_rules() -> _ {\n  post_report::violates_instance_rules\n    .or(comment_report::violates_instance_rules)\n    .or(report_combined::community_report_id.is_not_null())\n    .or(report_combined::private_message_report_id.is_not_null())\n}\n\nimpl InternalToCombinedView for ReportCombinedViewInternal {\n  type CombinedView = ReportCombinedView;\n\n  fn map_to_enum(self) -> Option<Self::CombinedView> {\n    // Use for a short alias\n    let v = self;\n\n    if let (Some(post_report), Some(post), Some(community), Some(post_creator)) = (\n      v.post_report,\n      v.post.clone(),\n      v.community.clone(),\n      v.creator.clone(),\n    ) {\n      Some(ReportCombinedView::Post(PostReportView {\n        post_report,\n        post,\n        community,\n        post_creator,\n        creator: v.report_creator,\n        resolver: v.resolver,\n        community_actions: v.community_actions,\n        post_actions: v.post_actions,\n        person_actions: v.person_actions,\n        creator_is_admin: v.creator_is_admin,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else if let (\n      Some(comment_report),\n      Some(comment),\n      Some(post),\n      Some(community),\n      Some(comment_creator),\n    ) = (\n      v.comment_report,\n      v.comment,\n      v.post,\n      v.community.clone(),\n      v.creator.clone(),\n    ) {\n      Some(ReportCombinedView::Comment(CommentReportView {\n        comment_report,\n        comment,\n        post,\n        community,\n        creator: v.report_creator,\n        comment_creator,\n        resolver: v.resolver,\n        community_actions: v.community_actions,\n        comment_actions: v.comment_actions,\n        person_actions: v.person_actions,\n        creator_is_admin: v.creator_is_admin,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else if let (\n      Some(private_message_report),\n      Some(private_message),\n      Some(private_message_creator),\n    ) = (v.private_message_report, v.private_message, v.creator)\n    {\n      Some(ReportCombinedView::PrivateMessage(\n        PrivateMessageReportView {\n          private_message_report,\n          private_message,\n          creator: v.report_creator,\n          private_message_creator,\n          resolver: v.resolver,\n          creator_is_admin: v.creator_is_admin,\n          creator_banned: v.creator_banned,\n          creator_ban_expires_at: v.creator_ban_expires_at,\n        },\n      ))\n    } else if let (Some(community), Some(community_report)) = (v.community, v.community_report) {\n      Some(ReportCombinedView::Community(CommunityReportView {\n        community_report,\n        community,\n        creator: v.report_creator,\n        resolver: v.resolver,\n        creator_is_admin: v.creator_is_admin,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else {\n      None\n    }\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n\n  use crate::{\n    LocalUserView,\n    ReportCombinedView,\n    ReportCombinedViewInternal,\n    impls::ReportCombinedQuery,\n  };\n  use chrono::{Days, Utc};\n  use diesel::{ExpressionMethods, QueryDsl, update};\n  use diesel_async::RunQueryDsl;\n  use lemmy_db_schema::{\n    ReportType,\n    assert_length,\n    source::{\n      comment::{Comment, CommentInsertForm},\n      comment_report::{CommentReport, CommentReportForm},\n      community::{Community, CommunityActions, CommunityInsertForm, CommunityModeratorForm},\n      community_report::{CommunityReport, CommunityReportForm},\n      instance::{Instance, InstanceActions, InstanceBanForm},\n      local_user::{LocalUser, LocalUserInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostInsertForm},\n      post_report::{PostReport, PostReportForm},\n      private_message::{PrivateMessage, PrivateMessageInsertForm},\n      private_message_report::{PrivateMessageReport, PrivateMessageReportForm},\n    },\n    traits::{Bannable, Reportable},\n  };\n  use lemmy_db_schema_file::schema::report_combined;\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests, get_conn},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  struct Data {\n    instance: Instance,\n    timmy: Person,\n    sara: Person,\n    jessica: Person,\n    timmy_view: LocalUserView,\n    admin_view: LocalUserView,\n    community: Community,\n    post: Post,\n    post_2: Post,\n    comment: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let timmy_form = PersonInsertForm::test_form(inserted_instance.id, \"timmy_rcv\");\n    let inserted_timmy = Person::create(pool, &timmy_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form(inserted_timmy.id);\n    let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n    let timmy_view = LocalUserView {\n      local_user: timmy_local_user,\n      person: inserted_timmy.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    // Make an admin, to be able to see private message reports.\n    let admin_form = PersonInsertForm::test_form(inserted_instance.id, \"admin_rcv\");\n    let inserted_admin = Person::create(pool, &admin_form).await?;\n    let admin_local_user_form = LocalUserInsertForm::test_form_admin(inserted_admin.id);\n    let admin_local_user = LocalUser::create(pool, &admin_local_user_form, vec![]).await?;\n    let admin_view = LocalUserView {\n      local_user: admin_local_user,\n      person: inserted_admin.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let sara_form = PersonInsertForm::test_form(inserted_instance.id, \"sara_rcv\");\n    let inserted_sara = Person::create(pool, &sara_form).await?;\n\n    let jessica_form = PersonInsertForm::test_form(inserted_instance.id, \"jessica_mrv\");\n    let inserted_jessica = Person::create(pool, &jessica_form).await?;\n\n    let community_form = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community crv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &community_form).await?;\n\n    // Make timmy a mod\n    let timmy_moderator_form =\n      CommunityModeratorForm::new(inserted_community.id, inserted_timmy.id);\n    CommunityActions::join(pool, &timmy_moderator_form).await?;\n\n    let post_form = PostInsertForm::new(\n      \"A test post crv\".into(),\n      inserted_timmy.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &post_form).await?;\n\n    let new_post_2 = PostInsertForm::new(\n      \"A test post crv 2\".into(),\n      inserted_timmy.id,\n      inserted_community.id,\n    );\n    let inserted_post_2 = Post::create(pool, &new_post_2).await?;\n\n    // Timmy creates a comment\n    let comment_form = CommentInsertForm::new(\n      inserted_timmy.id,\n      inserted_post.id,\n      \"A test comment rv\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    Ok(Data {\n      instance: inserted_instance,\n      timmy: inserted_timmy,\n      sara: inserted_sara,\n      jessica: inserted_jessica,\n      admin_view,\n      timmy_view,\n      community: inserted_community,\n      post: inserted_post,\n      post_2: inserted_post_2,\n      comment: inserted_comment,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn combined() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Sara reports the community\n    let sara_report_community_form = CommunityReportForm {\n      creator_id: data.sara.id,\n      community_id: data.community.id,\n      original_community_name: data.community.name.clone(),\n      original_community_title: data.community.title.clone(),\n      original_community_banner: None,\n      original_community_summary: None,\n      original_community_sidebar: None,\n      original_community_icon: None,\n      reason: \"from sara\".into(),\n    };\n    CommunityReport::report(pool, &sara_report_community_form).await?;\n\n    // sara reports the post\n    let sara_report_post_form = PostReportForm {\n      creator_id: data.sara.id,\n      post_id: data.post.id,\n      original_post_name: \"Orig post\".into(),\n      original_post_url: None,\n      original_post_body: None,\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n    let inserted_post_report = PostReport::report(pool, &sara_report_post_form).await?;\n\n    // Sara reports the comment\n    let sara_report_comment_form = CommentReportForm {\n      creator_id: data.sara.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"A test comment rv\".into(),\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n    CommentReport::report(pool, &sara_report_comment_form).await?;\n\n    // Timmy creates a private message\n    let pm_form = PrivateMessageInsertForm::new(\n      data.timmy.id,\n      data.sara.id,\n      \"something offensive crv\".to_string(),\n    );\n    let inserted_pm = PrivateMessage::create(pool, &pm_form).await?;\n\n    // sara reports private message\n    let pm_report_form = PrivateMessageReportForm {\n      creator_id: data.sara.id,\n      original_pm_text: inserted_pm.content.clone(),\n      private_message_id: inserted_pm.id,\n      reason: \"its offensive\".to_string(),\n    };\n    PrivateMessageReport::report(pool, &pm_report_form).await?;\n\n    // Do a batch read of admins reports\n    let reports = ReportCombinedQuery {\n      show_community_rule_violations: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.admin_view)\n    .await?;\n    assert_length!(4, reports);\n\n    // Make sure the report types are correct\n    if let ReportCombinedView::Community(v) = &reports[3] {\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::Post(v) = &reports[2] {\n      assert_eq!(data.post.id, v.post.id);\n      assert_eq!(data.sara.id, v.creator.id);\n      assert_eq!(data.timmy.id, v.post_creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::Comment(v) = &reports[1] {\n      assert_eq!(data.comment.id, v.comment.id);\n      assert_eq!(data.post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.comment_creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::PrivateMessage(v) = &reports[0] {\n      assert_eq!(inserted_pm.id, v.private_message.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    let report_count_mod =\n      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(2, report_count_mod);\n    let report_count_admin =\n      ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?;\n    assert_eq!(2, report_count_admin);\n\n    // Make sure the type_ filter is working\n    let reports_by_type = ReportCombinedQuery {\n      type_: Some(ReportType::Posts),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n    assert_length!(1, reports_by_type);\n\n    // Filter by the post id\n    // Should be 2, for the post, and the comment on that post\n    let reports_by_post_id = ReportCombinedQuery {\n      post_id: Some(data.post.id),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n    assert_length!(2, reports_by_post_id);\n\n    // Timmy should only see 2 reports, since they're not an admin,\n    // but they do mod the community\n    let timmys_reports = ReportCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_length!(2, timmys_reports);\n\n    // Make sure the report types are correct\n    if let ReportCombinedView::Post(v) = &timmys_reports[1] {\n      assert_eq!(data.post.id, v.post.id);\n      assert_eq!(data.sara.id, v.creator.id);\n      assert_eq!(data.timmy.id, v.post_creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::Comment(v) = &timmys_reports[0] {\n      assert_eq!(data.comment.id, v.comment.id);\n      assert_eq!(data.post.id, v.post.id);\n      assert_eq!(data.timmy.id, v.comment_creator.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    let report_count_timmy =\n      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(2, report_count_timmy);\n\n    // Resolve the post report\n    PostReport::update_resolved(pool, inserted_post_report.id, data.timmy.id, true).await?;\n\n    // Do a batch read of timmys reports\n    // It should only show saras, which is unresolved\n    let reports_after_resolve = ReportCombinedQuery {\n      unresolved_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n    assert_length!(1, reports_after_resolve);\n\n    // Make sure the counts are correct\n    let report_count_after_resolved =\n      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(1, report_count_after_resolved);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn private_message_reports() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // timmy sends private message to jessica\n    let pm_form = PrivateMessageInsertForm::new(\n      data.timmy.id,\n      data.jessica.id,\n      \"something offensive\".to_string(),\n    );\n    let pm = PrivateMessage::create(pool, &pm_form).await?;\n\n    // jessica reports private message\n    let pm_report_form = PrivateMessageReportForm {\n      creator_id: data.jessica.id,\n      original_pm_text: pm.content.clone(),\n      private_message_id: pm.id,\n      reason: \"its offensive\".to_string(),\n    };\n    let pm_report = PrivateMessageReport::report(pool, &pm_report_form).await?;\n\n    let reports = ReportCombinedQuery {\n      show_community_rule_violations: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.admin_view)\n    .await?;\n    assert_length!(1, reports);\n    if let ReportCombinedView::PrivateMessage(v) = &reports[0] {\n      assert!(!v.private_message_report.resolved);\n      assert_eq!(data.timmy.name, v.private_message_creator.name);\n      assert_eq!(data.jessica.name, v.creator.name);\n      assert_eq!(pm_report.reason, v.private_message_report.reason);\n      assert_eq!(pm.content, v.private_message.content);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // admin resolves the report (after taking appropriate action)\n    PrivateMessageReport::update_resolved(pool, pm_report.id, data.admin_view.person.id, true)\n      .await?;\n\n    let reports = ReportCombinedQuery::default()\n      .list(pool, &data.admin_view)\n      .await?;\n    assert_length!(1, reports);\n    if let ReportCombinedView::PrivateMessage(v) = &reports[0] {\n      assert!(v.private_message_report.resolved);\n      assert!(v.resolver.is_some());\n      assert_eq!(\n        Some(&data.admin_view.person.name),\n        v.resolver.as_ref().map(|r| &r.name)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn post_reports() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // sara reports\n    let sara_report_form = PostReportForm {\n      creator_id: data.sara.id,\n      post_id: data.post.id,\n      original_post_name: \"Orig post\".into(),\n      original_post_url: None,\n      original_post_body: None,\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n\n    PostReport::report(pool, &sara_report_form).await?;\n\n    // jessica reports\n    let jessica_report_form = PostReportForm {\n      creator_id: data.jessica.id,\n      post_id: data.post_2.id,\n      original_post_name: \"Orig post\".into(),\n      original_post_url: None,\n      original_post_body: None,\n      reason: \"from jessica\".into(),\n      violates_instance_rules: false,\n    };\n\n    let inserted_jessica_report = PostReport::report(pool, &jessica_report_form).await?;\n\n    let read_jessica_report_view =\n      ReportCombinedViewInternal::read_post_report(pool, inserted_jessica_report.id, &data.timmy)\n        .await?;\n\n    // Make sure the triggers are reading the aggregates correctly.\n    let agg_1 = Post::read(pool, data.post.id).await?;\n    let agg_2 = Post::read(pool, data.post_2.id).await?;\n\n    assert_eq!(\n      read_jessica_report_view.post_report,\n      inserted_jessica_report\n    );\n    assert_eq!(read_jessica_report_view.post.id, data.post_2.id);\n    assert_eq!(read_jessica_report_view.community.id, data.community.id);\n    assert_eq!(read_jessica_report_view.creator.id, data.jessica.id);\n    assert_eq!(read_jessica_report_view.post_creator.id, data.timmy.id);\n    assert_eq!(read_jessica_report_view.resolver, None);\n    assert_eq!(agg_1.report_count, 1);\n    assert_eq!(agg_1.unresolved_report_count, 1);\n    assert_eq!(agg_2.report_count, 1);\n    assert_eq!(agg_2.unresolved_report_count, 1);\n\n    // Do a batch read of timmys reports\n    let reports = ReportCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n\n    if let ReportCombinedView::Post(v) = &reports[1] {\n      assert_eq!(v.creator.id, data.sara.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::Post(v) = &reports[0] {\n      assert_eq!(v.creator.id, data.jessica.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Make sure the counts are correct\n    let report_count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(2, report_count);\n\n    // Pretend the post was removed, and resolve all reports for that object.\n    // This is called manually in the API for post removals\n    PostReport::resolve_all_for_object(pool, inserted_jessica_report.post_id, data.timmy.id)\n      .await?;\n\n    let read_jessica_report_view_after_resolve =\n      ReportCombinedViewInternal::read_post_report(pool, inserted_jessica_report.id, &data.timmy)\n        .await?;\n    assert!(read_jessica_report_view_after_resolve.post_report.resolved);\n    assert_eq!(\n      read_jessica_report_view_after_resolve\n        .post_report\n        .resolver_id,\n      Some(data.timmy.id)\n    );\n    assert_eq!(\n      read_jessica_report_view_after_resolve\n        .resolver\n        .map(|r| r.id),\n      Some(data.timmy.id)\n    );\n\n    // Make sure the unresolved_post report got decremented in the trigger\n    let agg_2 = Post::read(pool, data.post_2.id).await?;\n    assert_eq!(agg_2.report_count, 1);\n    assert_eq!(agg_2.unresolved_report_count, 0);\n\n    // Make sure the other unresolved report isn't changed\n    let agg_1 = Post::read(pool, data.post.id).await?;\n    assert_eq!(agg_1.report_count, 1);\n    assert_eq!(agg_1.unresolved_report_count, 1);\n\n    // Do a batch read of timmys reports\n    // It should only show saras, which is unresolved\n    let reports_after_resolve = ReportCombinedQuery {\n      unresolved_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n\n    if let ReportCombinedView::Post(v) = &reports_after_resolve[0] {\n      assert_length!(1, reports_after_resolve);\n      assert_eq!(v.creator.id, data.sara.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Make sure the counts are correct\n    let report_count_after_resolved =\n      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(1, report_count_after_resolved);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment_reports() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // sara reports\n    let sara_report_form = CommentReportForm {\n      creator_id: data.sara.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"this was it at time of creation\".into(),\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n\n    CommentReport::report(pool, &sara_report_form).await?;\n\n    // jessica reports\n    let jessica_report_form = CommentReportForm {\n      creator_id: data.jessica.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"this was it at time of creation\".into(),\n      reason: \"from jessica\".into(),\n      violates_instance_rules: false,\n    };\n\n    let inserted_jessica_report = CommentReport::report(pool, &jessica_report_form).await?;\n\n    let comment = Comment::read(pool, data.comment.id).await?;\n    assert_eq!(comment.report_count, 2);\n\n    let read_jessica_report_view = ReportCombinedViewInternal::read_comment_report(\n      pool,\n      inserted_jessica_report.id,\n      &data.timmy,\n    )\n    .await?;\n    assert_eq!(read_jessica_report_view.comment.unresolved_report_count, 2);\n\n    // Do a batch read of timmys reports\n    let reports = ReportCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n\n    if let ReportCombinedView::Comment(v) = &reports[0] {\n      assert_eq!(v.creator.id, data.jessica.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let ReportCombinedView::Comment(v) = &reports[1] {\n      assert_eq!(v.creator.id, data.sara.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Make sure the counts are correct\n    let report_count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(2, report_count);\n\n    // Resolve the report\n    CommentReport::update_resolved(pool, inserted_jessica_report.id, data.timmy.id, true).await?;\n    let read_jessica_report_view_after_resolve = ReportCombinedViewInternal::read_comment_report(\n      pool,\n      inserted_jessica_report.id,\n      &data.timmy,\n    )\n    .await?;\n\n    assert!(\n      read_jessica_report_view_after_resolve\n        .comment_report\n        .resolved\n    );\n    assert_eq!(\n      read_jessica_report_view_after_resolve\n        .comment_report\n        .resolver_id,\n      Some(data.timmy.id)\n    );\n    assert_eq!(\n      read_jessica_report_view_after_resolve\n        .resolver\n        .map(|r| r.id),\n      Some(data.timmy.id)\n    );\n\n    // Do a batch read of timmys reports\n    // It should only show saras, which is unresolved\n    let reports_after_resolve = ReportCombinedQuery {\n      unresolved_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n\n    if let ReportCombinedView::Comment(v) = &reports_after_resolve[0] {\n      assert_length!(1, reports_after_resolve);\n      assert_eq!(v.creator.id, data.sara.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Make sure the counts are correct\n    let report_count_after_resolved =\n      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(1, report_count_after_resolved);\n\n    // Filter by post id, which should still include the comments.\n    let reports_post_id_filter = ReportCombinedQuery {\n      post_id: Some(data.post.id),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n\n    assert_length!(2, reports_post_id_filter);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn community_reports() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // jessica reports community\n    let community_report_form = CommunityReportForm {\n      creator_id: data.jessica.id,\n      community_id: data.community.id,\n      original_community_name: data.community.name.clone(),\n      original_community_title: data.community.title.clone(),\n      original_community_banner: None,\n      original_community_summary: None,\n      original_community_sidebar: None,\n      original_community_icon: None,\n      reason: \"the ice cream incident\".into(),\n    };\n    let community_report = CommunityReport::report(pool, &community_report_form).await?;\n\n    let reports = ReportCombinedQuery {\n      show_community_rule_violations: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.admin_view)\n    .await?;\n    assert_length!(1, reports);\n    if let ReportCombinedView::Community(v) = &reports[0] {\n      assert!(!v.community_report.resolved);\n      assert_eq!(data.jessica.name, v.creator.name);\n      assert_eq!(community_report.reason, v.community_report.reason);\n      assert_eq!(data.community.name, v.community.name);\n      assert_eq!(data.community.title, v.community.title);\n      let read_report = ReportCombinedViewInternal::read_community_report(\n        pool,\n        community_report.id,\n        &data.admin_view.person,\n      )\n      .await?;\n      assert_eq!(&read_report, v);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // admin resolves the report (after taking appropriate action)\n    CommunityReport::update_resolved(pool, community_report.id, data.admin_view.person.id, true)\n      .await?;\n\n    let reports = ReportCombinedQuery {\n      show_community_rule_violations: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.admin_view)\n    .await?;\n    assert_length!(1, reports);\n    if let ReportCombinedView::Community(v) = &reports[0] {\n      assert!(v.community_report.resolved);\n      assert!(v.resolver.is_some());\n      assert_eq!(\n        Some(&data.admin_view.person.name),\n        v.resolver.as_ref().map(|r| &r.name)\n      );\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn violates_instance_rules() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // create report to admins\n    let report_form = PostReportForm {\n      creator_id: data.sara.id,\n      post_id: data.post_2.id,\n      original_post_name: \"Orig post\".into(),\n      original_post_url: None,\n      original_post_body: None,\n      reason: \"from sara\".into(),\n      violates_instance_rules: true,\n    };\n    PostReport::report(pool, &report_form).await?;\n\n    // timmy is a mod and cannot see the report\n    let mod_reports = ReportCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_length!(0, mod_reports);\n    let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(0, count);\n\n    // only admin can see the report\n    let admin_reports = ReportCombinedQuery::default()\n      .list(pool, &data.admin_view)\n      .await?;\n    assert_length!(1, admin_reports);\n    let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?;\n    assert_eq!(1, count);\n\n    // cleanup the report for easier checks below\n    Post::delete(pool, data.post_2.id).await?;\n\n    // now create a mod report\n    let report_form = CommentReportForm {\n      creator_id: data.sara.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"this was it at time of creation\".into(),\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n    let comment_report = CommentReport::report(pool, &report_form).await?;\n\n    // this time the mod can see it\n    let mod_reports = ReportCombinedQuery::default()\n      .list(pool, &data.timmy_view)\n      .await?;\n    assert_length!(1, mod_reports);\n    let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?;\n    assert_eq!(1, count);\n\n    // but not the admin\n    let admin_reports = ReportCombinedQuery::default()\n      .list(pool, &data.admin_view)\n      .await?;\n    assert_length!(0, admin_reports);\n    let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?;\n    assert_eq!(0, count);\n\n    // admin can see the report with `view_mod_reports` set\n    let admin_reports = ReportCombinedQuery {\n      show_community_rule_violations: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n    assert_length!(1, admin_reports);\n\n    // change a comment to be 3 days old, now admin can also see it by default\n    update(\n      report_combined::table.filter(report_combined::dsl::comment_report_id.eq(comment_report.id)),\n    )\n    .set(report_combined::published_at.eq(Utc::now() - Days::new(3)))\n    .execute(&mut get_conn(pool).await?)\n    .await?;\n    let admin_reports = ReportCombinedQuery::default()\n      .list(pool, &data.admin_view)\n      .await?;\n    assert_length!(1, admin_reports);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn my_reports_only() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // sara reports\n    let sara_report_form = CommentReportForm {\n      creator_id: data.sara.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"this was it at time of creation\".into(),\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n    CommentReport::report(pool, &sara_report_form).await?;\n\n    // timmy reports\n    let timmy_report_form = CommentReportForm {\n      creator_id: data.timmy.id,\n      comment_id: data.comment.id,\n      original_comment_text: \"this was it at time of creation\".into(),\n      reason: \"from timmy\".into(),\n      violates_instance_rules: false,\n    };\n    CommentReport::report(pool, &timmy_report_form).await?;\n\n    let agg = Comment::read(pool, data.comment.id).await?;\n    assert_eq!(agg.report_count, 2);\n\n    // Do a batch read of timmys reports, it should only show his own\n    let reports = ReportCombinedQuery {\n      my_reports_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &data.timmy_view)\n    .await?;\n\n    assert_length!(1, reports);\n\n    if let ReportCombinedView::Comment(v) = &reports[0] {\n      assert_eq!(v.creator.id, data.timmy.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn ensure_creator_data_is_correct() -> LemmyResult<()> {\n    // The creator_banned and other creator_data should be the content creator, not the report\n    // creator.\n\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // sara reports timmys post\n    let sara_report_form = PostReportForm {\n      creator_id: data.sara.id,\n      post_id: data.post.id,\n      original_post_name: \"Orig post\".into(),\n      original_post_url: None,\n      original_post_body: None,\n      reason: \"from sara\".into(),\n      violates_instance_rules: false,\n    };\n    let inserted_sara_report = PostReport::report(pool, &sara_report_form).await?;\n\n    // Admin ban timmy (the post creator)\n    let ban_timmy_form = InstanceBanForm::new(data.timmy.id, data.instance.id, None);\n    InstanceActions::ban(pool, &ban_timmy_form).await?;\n\n    let read_sara_report_view =\n      ReportCombinedViewInternal::read_post_report(pool, inserted_sara_report.id, &data.timmy)\n        .await?;\n\n    // Make sure timmy is seen as banned.\n    assert_eq!(read_sara_report_view.creator_banned, true);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/report_combined/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::source::{\n  combined::report::ReportCombined,\n  comment::{Comment, CommentActions},\n  comment_report::CommentReport,\n  community::{Community, CommunityActions},\n  community_report::CommunityReport,\n  person::{Person, PersonActions},\n  post::{Post, PostActions},\n  post_report::PostReport,\n  private_message::PrivateMessage,\n  private_message_report::PrivateMessageReport,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{NullableExpressionMethods, Queryable, Selectable, dsl::Nullable},\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeCommunityBanExpiresType,\n    creator_ban_expires_from_community,\n    creator_banned_from_community,\n    creator_is_moderator,\n    creator_local_home_community_ban_expires,\n    creator_local_home_community_banned,\n    local_user_is_admin,\n    person1_select,\n    person2_select,\n  },\n  lemmy_db_schema::{Person1AliasAllColumnsTuple, Person2AliasAllColumnsTuple},\n  lemmy_db_views_local_user::LocalUserView,\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[cfg(feature = \"full\")]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)]\n#[diesel(check_for_backend(diesel::pg::Pg))]\n/// A combined report view\npub struct ReportCombinedViewInternal {\n  #[diesel(embed)]\n  pub report_combined: ReportCombined,\n  #[diesel(embed)]\n  pub post_report: Option<PostReport>,\n  #[diesel(embed)]\n  pub comment_report: Option<CommentReport>,\n  #[diesel(embed)]\n  pub private_message_report: Option<PrivateMessageReport>,\n  #[diesel(embed)]\n  pub community_report: Option<CommunityReport>,\n  #[diesel(\n    select_expression_type = Person1AliasAllColumnsTuple,\n    select_expression = person1_select()\n  )]\n  pub report_creator: Person,\n  #[diesel(embed)]\n  pub comment: Option<Comment>,\n  #[diesel(embed)]\n  pub private_message: Option<PrivateMessage>,\n  #[diesel(embed)]\n  pub post: Option<Post>,\n  #[diesel(embed)]\n  pub creator: Option<Person>,\n  #[diesel(\n    select_expression_type = Nullable<Person2AliasAllColumnsTuple>,\n    select_expression = person2_select().nullable()\n  )]\n  pub resolver: Option<Person>,\n  #[diesel(select_expression = local_user_is_admin())]\n  pub creator_is_admin: bool,\n  #[diesel(select_expression = creator_is_moderator())]\n  pub creator_is_moderator: bool,\n  #[diesel(select_expression = creator_local_home_community_banned())]\n  pub creator_banned: bool,\n  #[diesel(\n    select_expression_type = CreatorLocalHomeCommunityBanExpiresType,\n    select_expression = creator_local_home_community_ban_expires()\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[diesel(select_expression = creator_banned_from_community())]\n  pub creator_banned_from_community: bool,\n  #[diesel(select_expression = creator_ban_expires_from_community())]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n  #[diesel(embed)]\n  pub community: Option<Community>,\n  #[diesel(embed)]\n  pub community_actions: Option<CommunityActions>,\n  #[diesel(embed)]\n  pub post_actions: Option<PostActions>,\n  #[diesel(embed)]\n  pub person_actions: Option<PersonActions>,\n  #[diesel(embed)]\n  pub comment_actions: Option<CommentActions>,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"type_\", rename_all = \"snake_case\")]\npub enum ReportCombinedView {\n  Post(PostReportView),\n  Comment(CommentReportView),\n  PrivateMessage(PrivateMessageReportView),\n  Community(CommunityReportView),\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A private message report view.\npub struct PrivateMessageReportView {\n  pub private_message_report: PrivateMessageReport,\n  pub private_message: PrivateMessage,\n  pub creator: Person,\n  pub private_message_creator: Person,\n  pub resolver: Option<Person>,\n  pub creator_is_admin: bool,\n  pub creator_banned: bool,\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A comment report view.\npub struct CommentReportView {\n  pub comment_report: CommentReport,\n  pub comment: Comment,\n  pub post: Post,\n  pub community: Community,\n  pub creator: Person,\n  pub comment_creator: Person,\n  pub comment_actions: Option<CommentActions>,\n  pub resolver: Option<Person>,\n  pub person_actions: Option<PersonActions>,\n  pub community_actions: Option<CommunityActions>,\n  pub creator_is_admin: bool,\n  pub creator_is_moderator: bool,\n  pub creator_banned: bool,\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  pub creator_banned_from_community: bool,\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A community report view.\npub struct CommunityReportView {\n  pub community_report: CommunityReport,\n  pub community: Community,\n  pub creator: Person,\n  pub resolver: Option<Person>,\n  pub creator_is_admin: bool,\n  pub creator_is_moderator: bool,\n  pub creator_banned: bool,\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  pub creator_banned_from_community: bool,\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A post report view.\npub struct PostReportView {\n  pub post_report: PostReport,\n  pub post: Post,\n  pub community: Community,\n  pub creator: Person,\n  pub post_creator: Person,\n  pub community_actions: Option<CommunityActions>,\n  pub post_actions: Option<PostActions>,\n  pub person_actions: Option<PersonActions>,\n  pub resolver: Option<Person>,\n  pub creator_is_admin: bool,\n  pub creator_is_moderator: bool,\n  pub creator_banned: bool,\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  pub creator_banned_from_community: bool,\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n"
  },
  {
    "path": "crates/db_views/report_combined_sql/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_report_combined_sql\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[dependencies]\nlemmy_db_schema_file = { workspace = true, features = [\"full\"] }\ndiesel = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/report_combined_sql/src/lib.rs",
    "content": "use diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  QueryDsl,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  aliases,\n  aliases::creator_community_actions,\n  joins::{\n    creator_community_instance_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n  },\n  schema::{\n    comment,\n    comment_actions,\n    comment_report,\n    community,\n    community_actions,\n    community_report,\n    local_user,\n    person,\n    person_actions,\n    post,\n    post_actions,\n    post_report,\n    private_message,\n    private_message_report,\n    report_combined,\n  },\n};\n\n#[diesel::dsl::auto_type(no_type_alias)]\npub fn report_combined_joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ {\n  // The item creator needs to be person::id, otherwise all the creator actions like\n  // creator_banned will be wrong.\n  let item_creator = person::id;\n  let report_creator = aliases::person1.field(person::id);\n\n  let resolver = aliases::person2.field(person::id).nullable();\n\n  let comment_join = comment::table.on(comment_report::comment_id.eq(comment::id));\n  let private_message_join =\n    private_message::table.on(private_message_report::private_message_id.eq(private_message::id));\n\n  let post_join = post::table.on(\n    post_report::post_id\n      .eq(post::id)\n      .or(comment::post_id.eq(post::id)),\n  );\n\n  let community_actions_join = community_actions::table.on(\n    community_actions::community_id\n      .eq(community::id)\n      .and(community_actions::person_id.eq(my_person_id)),\n  );\n\n  let report_creator_join = aliases::person1.on(\n    post_report::creator_id\n      .eq(report_creator)\n      .or(comment_report::creator_id.eq(report_creator))\n      .or(private_message_report::creator_id.eq(report_creator))\n      .or(community_report::creator_id.eq(report_creator)),\n  );\n\n  let item_creator_join = person::table.on(\n    post::creator_id\n      .eq(item_creator)\n      .or(comment::creator_id.eq(item_creator))\n      .or(private_message::creator_id.eq(item_creator)),\n  );\n\n  let resolver_join = aliases::person2.on(\n    private_message_report::resolver_id\n      .eq(resolver)\n      .or(post_report::resolver_id.eq(resolver))\n      .or(comment_report::resolver_id.eq(resolver))\n      .or(community_report::resolver_id.eq(resolver)),\n  );\n\n  let community_join = community::table.on(\n    community_report::community_id\n      .eq(community::id)\n      .or(post::community_id.eq(community::id)),\n  );\n\n  let local_user_join = local_user::table.on(\n    item_creator\n      .eq(local_user::person_id)\n      .and(local_user::admin.eq(true)),\n  );\n\n  let creator_community_actions_join = creator_community_actions.on(\n    creator_community_actions\n      .field(community_actions::community_id)\n      .eq(post::community_id)\n      .and(\n        creator_community_actions\n          .field(community_actions::person_id)\n          .eq(item_creator),\n      ),\n  );\n  let creator_local_instance_actions_join: creator_local_instance_actions_join =\n    creator_local_instance_actions_join(local_instance_id);\n\n  let post_actions_join = post_actions::table.on(\n    post_actions::post_id\n      .eq(post::id)\n      .and(post_actions::person_id.eq(my_person_id)),\n  );\n\n  let person_actions_join = person_actions::table.on(\n    person_actions::target_id\n      .eq(item_creator)\n      .and(person_actions::person_id.eq(my_person_id)),\n  );\n\n  let comment_actions_join = comment_actions::table.on(\n    comment_actions::comment_id\n      .eq(comment::id)\n      .and(comment_actions::person_id.eq(my_person_id)),\n  );\n\n  report_combined::table\n    .left_join(post_report::table)\n    .left_join(comment_report::table)\n    .left_join(private_message_report::table)\n    .left_join(community_report::table)\n    .inner_join(report_creator_join)\n    .left_join(comment_join)\n    .left_join(private_message_join)\n    .left_join(post_join)\n    .left_join(item_creator_join)\n    .left_join(resolver_join)\n    .left_join(community_join)\n    .left_join(creator_community_actions_join)\n    .left_join(creator_home_instance_actions_join())\n    .left_join(creator_local_instance_actions_join)\n    .left_join(creator_community_instance_actions_join())\n    .left_join(local_user_join)\n    .left_join(community_actions_join)\n    .left_join(post_actions_join)\n    .left_join(person_actions_join)\n    .left_join(comment_actions_join)\n}\n"
  },
  {
    "path": "crates/db_views/search_combined/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_search_combined\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"i-love-jesus\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_post/full\",\n  \"lemmy_db_views_comment/full\",\n  \"lemmy_db_views_community/full\",\n  \"lemmy_db_views_person/full\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_schema_file/ts-rs\",\n  \"lemmy_db_views_comment/ts-rs\",\n  \"lemmy_db_views_community/ts-rs\",\n  \"lemmy_db_views_person/ts-rs\",\n  \"lemmy_db_views_post/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_views_post = { workspace = true }\nlemmy_db_views_comment = { workspace = true }\nlemmy_db_views_community = { workspace = true }\nlemmy_db_views_person = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nserde_with = { workspace = true }\nchrono = { workspace = true }\nurl = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/search_combined/src/api.rs",
    "content": "use lemmy_db_schema::newtypes::{CommentId, PostId};\nuse lemmy_db_views_community::CommunityView;\nuse lemmy_db_views_post::PostView;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n// TODO this should be made into a tagged enum\n/// Get a post. Needs either the post id, or comment_id.\npub struct GetPost {\n  pub id: Option<PostId>,\n  pub comment_id: Option<CommentId>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The post response.\npub struct GetPostResponse {\n  pub post_view: PostView,\n  pub community_view: CommunityView,\n  /// A list of cross-posts, or other times / communities this link has been posted to.\n  pub cross_posts: Vec<PostView>,\n}\n"
  },
  {
    "path": "crates/db_views/search_combined/src/impls.rs",
    "content": "use crate::{\n  CommentView,\n  CommunityView,\n  LocalUserView,\n  PersonView,\n  PostView,\n  SearchCombinedView,\n  SearchCombinedViewInternal,\n};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  JoinOnDsl,\n  NullableExpressionMethods,\n  PgTextExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n  dsl::not,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::asc_if;\nuse lemmy_db_schema::{\n  SearchSortType::{self, *},\n  SearchType,\n  impls::local_user::LocalUserOptionHelper,\n  newtypes::CommunityId,\n  source::{\n    combined::search::{SearchCombined, search_combined_keys as key},\n    site::Site,\n  },\n  traits::InternalToCombinedView,\n  utils::{\n    limit_fetch,\n    queries::filters::{\n      filter_is_subscribed,\n      filter_not_unlisted_or_is_subscribed,\n      filter_suggested_communities,\n    },\n  },\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  enums::{CommunityFollowerState, CommunityVisibility, ListingType},\n  joins::{\n    creator_community_actions_join,\n    creator_home_instance_actions_join,\n    creator_local_instance_actions_join,\n    creator_local_user_admin_join,\n    image_details_join,\n    my_comment_actions_join,\n    my_community_actions_join,\n    my_local_user_admin_join,\n    my_person_actions_join,\n    my_post_actions_join,\n  },\n  schema::{\n    comment,\n    comment_actions,\n    community,\n    community_actions,\n    multi_community,\n    person,\n    post,\n    post_actions,\n    search_combined,\n  },\n};\nuse lemmy_db_views_community::MultiCommunityView;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n  utils::{fuzzy_search, now, seconds_to_pg_interval},\n};\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  utils::validation::clean_url,\n};\nuse url::Url;\n\nimpl SearchCombinedViewInternal {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {\n    let item_creator = person::id;\n\n    let item_creator_join = person::table.on(\n      search_combined::person_id\n        .eq(item_creator.nullable())\n        .or(\n          search_combined::comment_id\n            .is_not_null()\n            .and(comment::creator_id.eq(item_creator)),\n        )\n        .or(\n          search_combined::post_id\n            .is_not_null()\n            .and(post::creator_id.eq(item_creator)),\n        )\n        .or(\n          search_combined::multi_community_id\n            .is_not_null()\n            .and(multi_community::creator_id.eq(item_creator)),\n        )\n        .and(not(person::deleted)),\n    );\n\n    let comment_join = comment::table.on(\n      search_combined::comment_id\n        .eq(comment::id.nullable())\n        .and(not(comment::removed))\n        .and(not(comment::deleted)),\n    );\n\n    let post_join = post::table.on(\n      search_combined::post_id\n        .eq(post::id.nullable())\n        .or(comment::post_id.eq(post::id))\n        .and(not(post::removed))\n        .and(not(post::deleted)),\n    );\n\n    let community_join = community::table.on(\n      search_combined::community_id\n        .eq(community::id.nullable())\n        .or(post::community_id.eq(community::id))\n        .and(not(community::removed))\n        .and(not(community::local_removed))\n        .and(not(community::deleted)),\n    );\n\n    let multi_community_join = multi_community::table.on(\n      search_combined::multi_community_id\n        .eq(multi_community::id.nullable())\n        .and(not(multi_community::deleted)),\n    );\n\n    let my_community_actions_join: my_community_actions_join =\n      my_community_actions_join(my_person_id);\n    let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id);\n    let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id);\n    let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id);\n    let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    search_combined::table\n      .left_join(comment_join)\n      .left_join(post_join)\n      .left_join(multi_community_join)\n      .left_join(item_creator_join)\n      .left_join(community_join)\n      .left_join(image_details_join())\n      .left_join(creator_community_actions_join())\n      .left_join(creator_local_user_admin_join())\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .left_join(my_local_user_admin_join)\n      .left_join(my_community_actions_join)\n      .left_join(my_post_actions_join)\n      .left_join(my_person_actions_join)\n      .left_join(my_comment_actions_join)\n  }\n}\n\nimpl SearchCombinedView {\n  /// Useful in combination with filter_map\n  pub fn to_post_view(&self) -> Option<&PostView> {\n    if let Self::Post(v) = self {\n      Some(v)\n    } else {\n      None\n    }\n  }\n}\n\nimpl PaginationCursorConversion for SearchCombinedView {\n  type PaginatedType = SearchCombined;\n\n  fn to_cursor(&self) -> CursorData {\n    let (prefix, id) = match &self {\n      SearchCombinedView::Post(v) => ('P', v.post.id.0),\n      SearchCombinedView::Comment(v) => ('C', v.comment.id.0),\n      SearchCombinedView::Community(v) => ('O', v.community.id.0),\n      SearchCombinedView::Person(v) => ('E', v.person.id.0),\n      SearchCombinedView::MultiCommunity(v) => ('M', v.multi.id.0),\n    };\n    CursorData::new_with_prefix(prefix, id)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let conn = &mut get_conn(pool).await?;\n    let (prefix, id) = cursor.id_and_prefix()?;\n\n    let mut query = search_combined::table\n      .select(Self::PaginatedType::as_select())\n      .into_boxed();\n\n    query = match prefix {\n      'P' => query.filter(search_combined::post_id.eq(id)),\n      'C' => query.filter(search_combined::comment_id.eq(id)),\n      'O' => query.filter(search_combined::community_id.eq(id)),\n      'E' => query.filter(search_combined::person_id.eq(id)),\n      'M' => query.filter(search_combined::multi_community_id.eq(id)),\n      _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()),\n    };\n    let token = query.first(conn).await?;\n\n    Ok(token)\n  }\n}\n\n#[derive(Default)]\npub struct SearchCombinedQuery {\n  pub search_term: Option<String>,\n  pub community_id: Option<CommunityId>,\n  pub creator_id: Option<PersonId>,\n  pub type_: Option<SearchType>,\n  pub sort: Option<SearchSortType>,\n  pub time_range_seconds: Option<i32>,\n  pub listing_type: Option<ListingType>,\n  pub title_only: Option<bool>,\n  pub post_url_only: Option<bool>,\n  pub liked_only: Option<bool>,\n  pub disliked_only: Option<bool>,\n  pub show_nsfw: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\nimpl SearchCombinedQuery {\n  pub async fn list(\n    self,\n    pool: &mut DbPool<'_>,\n    user: &Option<LocalUserView>,\n    site_local: &Site,\n  ) -> LemmyResult<PagedResponse<SearchCombinedView>> {\n    let my_local_user = user.as_ref().map(|u| &u.local_user);\n    let my_person_id = my_local_user.person_id();\n    let item_creator = person::id;\n\n    let limit = limit_fetch(self.limit, None)?;\n\n    let mut query = SearchCombinedViewInternal::joins(my_person_id, site_local.instance_id)\n      .select(SearchCombinedViewInternal::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    // The filters\n\n    // Some helpers\n    let is_post = search_combined::post_id.is_not_null();\n    let is_comment = search_combined::comment_id.is_not_null();\n    let is_community = search_combined::community_id.is_not_null();\n    let is_person = search_combined::person_id.is_not_null();\n    let is_multi_community = search_combined::multi_community_id.is_not_null();\n\n    // The search term\n    if let Some(search_term) = self.search_term {\n      if self.post_url_only.unwrap_or_default() {\n        // Parse and normalize the url, removing tracking parameters (same logic which is used\n        // when creating a new post).\n        let normalized_url = Url::parse(&search_term).map(|u| clean_url(&u).to_string());\n        // If any of the normalization steps above failed, use the search term directly\n        // (this can happen when searching part of an url).\n        let url_searcher = fuzzy_search(&normalized_url.unwrap_or(search_term));\n        query = query.filter(is_post.and(post::url.ilike(url_searcher)));\n      } else {\n        let searcher = fuzzy_search(&search_term);\n\n        // These need to also filter by the type, otherwise they may return children\n        let name_or_title_filter = is_post\n          .and(post::name.ilike(searcher.clone()))\n          .or(is_comment.and(comment::content.ilike(searcher.clone())))\n          .or(is_community.and(community::name.ilike(searcher.clone())))\n          .or(is_community.and(community::title.ilike(searcher.clone())))\n          .or(is_person.and(person::name.ilike(searcher.clone())))\n          .or(is_person.and(person::display_name.ilike(searcher.clone())))\n          .or(is_multi_community.and(multi_community::title.ilike(searcher.clone())))\n          .or(is_multi_community.and(multi_community::name.ilike(searcher.clone())));\n\n        query = if self.title_only.unwrap_or_default() {\n          query.filter(name_or_title_filter)\n        } else {\n          let body_or_description_filter = is_post\n            .and(post::body.ilike(searcher.clone()))\n            .or(is_community.and(community::summary.ilike(searcher.clone())))\n            .or(is_multi_community.and(multi_community::summary.ilike(searcher.clone())))\n            .or(is_person.and(person::bio.ilike(searcher.clone())));\n          query.filter(name_or_title_filter.or(body_or_description_filter))\n        }\n      }\n    }\n\n    // Community id\n    if let Some(community_id) = self.community_id {\n      query = query.filter(community::id.eq(community_id));\n    }\n\n    // Creator id\n    if let Some(creator_id) = self.creator_id {\n      query = query.filter(item_creator.eq(creator_id));\n    }\n\n    // Liked / disliked filter\n    if let Some(my_id) = my_person_id {\n      let not_creator_filter = item_creator.ne(my_id);\n      let liked_disliked_filter = |should_be_upvote: bool| {\n        is_post\n          .and(post_actions::vote_is_upvote.eq(should_be_upvote))\n          .or(is_comment.and(comment_actions::vote_is_upvote.eq(should_be_upvote)))\n      };\n\n      if self.liked_only.unwrap_or_default() {\n        query = query\n          .filter(not_creator_filter)\n          .filter(liked_disliked_filter(true));\n      } else if self.disliked_only.unwrap_or_default() {\n        query = query\n          .filter(not_creator_filter)\n          .filter(liked_disliked_filter(false));\n      }\n    };\n\n    // Type\n    query = match self.type_.unwrap_or_default() {\n      SearchType::All => query,\n      SearchType::Posts => query.filter(is_post),\n      SearchType::Comments => query.filter(is_comment),\n      SearchType::Communities => query.filter(is_community),\n      SearchType::Users => query.filter(is_person),\n      SearchType::MultiCommunities => query.filter(is_multi_community),\n    };\n\n    // Listing type\n    query = match self.listing_type.unwrap_or_default() {\n      ListingType::Subscribed => query.filter(filter_is_subscribed()),\n      ListingType::Local => query.filter(\n        community::local\n          .eq(true)\n          .and(filter_not_unlisted_or_is_subscribed())\n          .or(is_person.and(person::local))\n          .or(multi_community::local),\n      ),\n      ListingType::All => query.filter(\n        filter_not_unlisted_or_is_subscribed()\n          .or(is_person)\n          .or(is_multi_community),\n      ),\n      ListingType::ModeratorView => {\n        query.filter(community_actions::became_moderator_at.is_not_null())\n      }\n      ListingType::Suggested => query.filter(filter_suggested_communities()),\n    };\n\n    // Filter by the time range\n    if let Some(time_range_seconds) = self.time_range_seconds {\n      query = query.filter(\n        search_combined::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)),\n      );\n    }\n\n    // NSFW\n    let user_and_site_nsfw = my_local_user.show_nsfw(site_local);\n    if !self.show_nsfw.unwrap_or(user_and_site_nsfw) {\n      let safe_community = community::nsfw.eq(false);\n      let safe_post_and_community = post::nsfw.eq(false).and(safe_community);\n\n      query = query.filter(\n        is_community\n          .and(safe_community)\n          .or(is_post.and(safe_post_and_community))\n          .or(is_comment.and(safe_post_and_community))\n          .or(is_person)\n          .or(is_multi_community),\n      );\n    };\n\n    // Check permissions to view private community content.\n    // Specifically, if the community is private then only accepted followers may view its\n    // content, otherwise it is filtered out. Admins can view private community content\n    // without restriction.\n    if !my_local_user.is_admin() {\n      let view_private_community = community::visibility\n        .ne(CommunityVisibility::Private)\n        .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted));\n\n      // Only filter for communities, posts, and comments\n      query = query.filter(\n        is_community\n          .and(view_private_community.clone())\n          .or(is_post.and(view_private_community.clone()))\n          .or(is_comment.and(view_private_community.clone()))\n          .or(is_person)\n          .or(is_multi_community),\n      );\n    };\n\n    // Only sort by asc if old\n    let sort = self.sort.unwrap_or_default();\n    let sort_direction = asc_if(sort == Old);\n\n    let mut paginated_query =\n      SearchCombinedView::paginate(query, &self.page_cursor, sort_direction, pool, None).await?;\n\n    paginated_query = match sort {\n      New | Old => paginated_query.then_order_by(key::published_at),\n      Top => paginated_query.then_order_by(key::score),\n    }\n    // finally use unique id as tie breaker\n    .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = paginated_query\n      .load::<SearchCombinedViewInternal>(conn)\n      .await?;\n\n    // Map the query results to the enum\n    let out = res\n      .into_iter()\n      .filter_map(InternalToCombinedView::map_to_enum)\n      .collect();\n\n    paginate_response(out, limit, self.page_cursor)\n  }\n}\n\nimpl InternalToCombinedView for SearchCombinedViewInternal {\n  type CombinedView = SearchCombinedView;\n\n  fn map_to_enum(self) -> Option<Self::CombinedView> {\n    // Use for a short alias\n    let v = self;\n\n    if let (Some(comment), Some(creator), Some(post), Some(community)) = (\n      v.comment,\n      v.item_creator.clone(),\n      v.post.clone(),\n      v.community.clone(),\n    ) {\n      Some(SearchCombinedView::Comment(CommentView {\n        comment,\n        post,\n        community,\n        creator,\n        community_actions: v.community_actions,\n        person_actions: v.person_actions,\n        comment_actions: v.comment_actions,\n        creator_is_admin: v.item_creator_is_admin,\n        tags: v.tags,\n        can_mod: v.can_mod,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else if let (Some(post), Some(creator), Some(community)) =\n      (v.post, v.item_creator.clone(), v.community.clone())\n    {\n      Some(SearchCombinedView::Post(PostView {\n        post,\n        community,\n        creator,\n        creator_is_admin: v.item_creator_is_admin,\n        image_details: v.image_details,\n        community_actions: v.community_actions,\n        person_actions: v.person_actions,\n        post_actions: v.post_actions,\n        tags: v.tags,\n        can_mod: v.can_mod,\n        creator_banned: v.creator_banned,\n        creator_ban_expires_at: v.creator_ban_expires_at,\n        creator_is_moderator: v.creator_is_moderator,\n        creator_banned_from_community: v.creator_banned_from_community,\n        creator_community_ban_expires_at: v.creator_community_ban_expires_at,\n      }))\n    } else if let Some(community) = v.community {\n      Some(SearchCombinedView::Community(CommunityView {\n        community,\n        community_actions: v.community_actions,\n        can_mod: v.can_mod,\n        tags: v.tags,\n      }))\n    } else if let (Some(multi), Some(creator)) = (v.multi_community, &v.item_creator) {\n      Some(SearchCombinedView::MultiCommunity(MultiCommunityView {\n        multi,\n        owner: creator.clone(),\n        follow_state: None,\n      }))\n    } else if let Some(person) = v.item_creator {\n      Some(SearchCombinedView::Person(PersonView {\n        person,\n        is_admin: v.item_creator_is_admin,\n        person_actions: v.person_actions,\n        banned: v.creator_banned,\n        ban_expires_at: v.creator_ban_expires_at,\n      }))\n    } else {\n      None\n    }\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use crate::{LocalUserView, SearchCombinedView, impls::SearchCombinedQuery};\n  use lemmy_db_schema::{\n    SearchSortType,\n    SearchType,\n    assert_length,\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm},\n      community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm},\n      instance::Instance,\n      local_user::{LocalUser, LocalUserInsertForm},\n      multi_community::{MultiCommunity, MultiCommunityInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm, PostUpdateForm},\n      site::{Site, SiteInsertForm},\n    },\n    traits::{Followable, Likeable},\n  };\n  use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};\n  use lemmy_diesel_utils::{\n    connection::{DbPool, build_db_pool_for_tests},\n    traits::Crud,\n  };\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n  use url::Url;\n\n  struct Data {\n    instance: Instance,\n    site: Site,\n    timmy: Person,\n    timmy_view: LocalUserView,\n    sara: Person,\n    community: Community,\n    community_2: Community,\n    private_community: Community,\n    timmy_post: Post,\n    timmy_post_2: Post,\n    sara_post: Post,\n    nsfw_post: Post,\n    timmy_post_private_comm: Post,\n    timmy_comment: Comment,\n    sara_comment: Comment,\n    sara_comment_2: Comment,\n    comment_in_nsfw_post: Comment,\n    timmy_comment_private_comm: Comment,\n  }\n\n  async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult<Data> {\n    let instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n    let site_form = SiteInsertForm::new(\"test_site\".to_string(), instance.id);\n    let site = Site::create(pool, &site_form).await?;\n\n    let sara_form = PersonInsertForm::test_form(instance.id, \"sara_pcv\");\n    let sara = Person::create(pool, &sara_form).await?;\n\n    let timmy_form = PersonInsertForm::test_form(instance.id, \"timmy_pcv\");\n    let timmy = Person::create(pool, &timmy_form).await?;\n    let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id);\n    let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;\n    let timmy_view = LocalUserView {\n      local_user: timmy_local_user,\n      person: timmy.clone(),\n      banned: false,\n      ban_expires_at: None,\n    };\n\n    let community_form = CommunityInsertForm {\n      summary: Some(\"ask lemmy things\".into()),\n      ..CommunityInsertForm::new(\n        instance.id,\n        \"asklemmy\".to_string(),\n        \"Ask Lemmy\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let community = Community::create(pool, &community_form).await?;\n\n    let community_form_2 = CommunityInsertForm::new(\n      instance.id,\n      \"startrek_ds9\".to_string(),\n      \"Star Trek - Deep Space Nine\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let community_2 = Community::create(pool, &community_form_2).await?;\n\n    let private_community_form = CommunityInsertForm {\n      visibility: Some(CommunityVisibility::Private),\n      ..CommunityInsertForm::new(\n        instance.id,\n        \"private_comm\".to_string(),\n        \"This is a private comm\".to_owned(),\n        \"pubkey\".to_string(),\n      )\n    };\n    let private_community = Community::create(pool, &private_community_form).await?;\n\n    let timmy_post_form = PostInsertForm {\n      body: Some(\"postbody inside here\".into()),\n      url: Some(Url::parse(\"https://google.com\")?.into()),\n      ..PostInsertForm::new(\"timmy post prv\".into(), timmy.id, community.id)\n    };\n    let timmy_post = Post::create(pool, &timmy_post_form).await?;\n\n    let timmy_post_form_2 = PostInsertForm::new(\"timmy post prv 2\".into(), timmy.id, community.id);\n    let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?;\n\n    let sara_post_form = PostInsertForm::new(\"sara post prv\".into(), sara.id, community_2.id);\n    let sara_post = Post::create(pool, &sara_post_form).await?;\n\n    let nsfw_post_form = PostInsertForm {\n      body: Some(\"nsfw post inside here\".into()),\n      url: Some(Url::parse(\"https://google.com\")?.into()),\n      nsfw: Some(true),\n      ..PostInsertForm::new(\"nsfw post prv\".into(), timmy.id, community.id)\n    };\n    let nsfw_post = Post::create(pool, &nsfw_post_form).await?;\n\n    let timmy_post_private_comm_form = PostInsertForm::new(\n      \"timmy post private comm\".into(),\n      timmy.id,\n      private_community.id,\n    );\n    let timmy_post_private_comm = Post::create(pool, &timmy_post_private_comm_form).await?;\n\n    let timmy_comment_form =\n      CommentInsertForm::new(timmy.id, timmy_post.id, \"timmy comment prv gold\".into());\n    let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?;\n\n    let sara_comment_form =\n      CommentInsertForm::new(sara.id, sara_post.id, \"sara comment prv gold\".into());\n    let sara_comment = Comment::create(pool, &sara_comment_form, None).await?;\n\n    let sara_comment_form_2 =\n      CommentInsertForm::new(sara.id, timmy_post_2.id, \"sara comment prv 2\".into());\n    let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?;\n\n    let comment_in_nsfw_post_form = CommentInsertForm::new(\n      sara.id,\n      nsfw_post.id,\n      \"sara comment in nsfw post prv 2\".into(),\n    );\n    let comment_in_nsfw_post = Comment::create(pool, &comment_in_nsfw_post_form, None).await?;\n\n    let timmy_comment_private_comm_form = CommentInsertForm::new(\n      timmy.id,\n      timmy_post_private_comm.id,\n      \"timmy comment private comm\".into(),\n    );\n    let timmy_comment_private_comm =\n      Comment::create(pool, &timmy_comment_private_comm_form, None).await?;\n\n    // Timmy likes and dislikes a few things\n    let timmy_like_post_form = PostLikeForm::new(timmy_post.id, timmy.id, Some(true));\n    PostActions::like(pool, &timmy_like_post_form).await?;\n\n    let timmy_like_sara_post_form = PostLikeForm::new(sara_post.id, timmy.id, Some(true));\n    PostActions::like(pool, &timmy_like_sara_post_form).await?;\n\n    let timmy_dislike_post_form = PostLikeForm::new(timmy_post_2.id, timmy.id, Some(false));\n    PostActions::like(pool, &timmy_dislike_post_form).await?;\n\n    let timmy_like_comment_form = CommentLikeForm::new(timmy_comment.id, timmy.id, Some(true));\n    CommentActions::like(pool, &timmy_like_comment_form).await?;\n\n    let timmy_like_sara_comment_form = CommentLikeForm::new(sara_comment.id, timmy.id, Some(true));\n    CommentActions::like(pool, &timmy_like_sara_comment_form).await?;\n\n    let timmy_dislike_sara_comment_form =\n      CommentLikeForm::new(sara_comment_2.id, timmy.id, Some(false));\n    CommentActions::like(pool, &timmy_dislike_sara_comment_form).await?;\n\n    Ok(Data {\n      instance,\n      site,\n      timmy,\n      timmy_view,\n      sara,\n      community,\n      community_2,\n      private_community,\n      timmy_post,\n      timmy_post_2,\n      sara_post,\n      nsfw_post,\n      timmy_post_private_comm,\n      timmy_comment,\n      sara_comment,\n      sara_comment_2,\n      comment_in_nsfw_post,\n      timmy_comment_private_comm,\n    })\n  }\n\n  async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {\n    Instance::delete(pool, data.instance.id).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn combined() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // search\n    let search = SearchCombinedQuery::default()\n      .list(pool, &None, &data.site)\n      .await?;\n    assert_length!(10, search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::Comment(v) = &search[0] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Comment(v) = &search[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara_post.id, v.post.id);\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Comment(v) = &search[2] {\n      assert_eq!(data.timmy_comment.id, v.comment.id);\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Post(v) = &search[3] {\n      assert_eq!(data.sara_post.id, v.post.id);\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Post(v) = &search[4] {\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Post(v) = &search[5] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Community(v) = &search[6] {\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Community(v) = &search[7] {\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Person(v) = &search[8] {\n      assert_eq!(data.timmy.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Person(v) = &search[9] {\n      assert_eq!(data.sara.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Filtered by community id\n    let search_by_community = SearchCombinedQuery {\n      community_id: Some(data.community.id),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(5, search_by_community);\n\n    // Filtered by creator_id\n    let search_by_creator = SearchCombinedQuery {\n      creator_id: Some(data.timmy.id),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(4, search_by_creator);\n\n    // Using a term\n    let search_by_name = SearchCombinedQuery {\n      search_term: Some(\"gold\".into()),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(2, search_by_name);\n\n    // Liked / disliked only\n    let search_liked_only = SearchCombinedQuery {\n      liked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    assert_length!(2, search_liked_only);\n\n    let search_disliked_only = SearchCombinedQuery {\n      disliked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    assert_length!(1, search_disliked_only);\n\n    // Test sorts\n    // Test Old sort\n    let search_old_sort = SearchCombinedQuery {\n      sort: Some(SearchSortType::Old),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n    if let SearchCombinedView::Person(v) = &search_old_sort[0] {\n      assert_eq!(data.sara.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    assert_length!(10, search_old_sort);\n\n    // Remove a post and delete a comment\n    Post::update(\n      pool,\n      data.timmy_post_2.id,\n      &PostUpdateForm {\n        removed: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    Comment::update(\n      pool,\n      data.sara_comment.id,\n      &CommentUpdateForm {\n        deleted: Some(true),\n        ..Default::default()\n      },\n    )\n    .await?;\n\n    // 2 things got removed, but the post also has another comment which got removed\n    let search = SearchCombinedQuery::default()\n      .list(pool, &None, &data.site)\n      .await?;\n    assert_length!(7, search);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Community search\n    let community_search = SearchCombinedQuery {\n      type_: Some(SearchType::Communities),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(2, community_search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::Community(v) = &community_search[0] {\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Community(v) = &community_search[1] {\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Filtered by id\n    let community_search_by_id = SearchCombinedQuery {\n      community_id: Some(data.community.id),\n      type_: Some(SearchType::Communities),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(1, community_search_by_id);\n\n    // Using a term\n    let community_search_by_name = SearchCombinedQuery {\n      search_term: Some(\"things\".into()),\n      type_: Some(SearchType::Communities),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, community_search_by_name);\n    if let SearchCombinedView::Community(v) = &community_search_by_name[0] {\n      // The asklemmy community\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Test title only search to make sure 'ask lemmy things' doesn't get returned\n    // Using a term\n    let community_search_title_only = SearchCombinedQuery {\n      search_term: Some(\"things\".into()),\n      type_: Some(SearchType::Communities),\n      title_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert!(community_search_title_only.is_empty());\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn person() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Person search\n    let person_search = SearchCombinedQuery {\n      type_: Some(SearchType::Users),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(2, person_search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::Person(v) = &person_search[0] {\n      assert_eq!(data.timmy.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Person(v) = &person_search[1] {\n      assert_eq!(data.sara.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Filtered by creator_id\n    let person_search_by_id = SearchCombinedQuery {\n      creator_id: Some(data.sara.id),\n      type_: Some(SearchType::Users),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(1, person_search_by_id);\n    if let SearchCombinedView::Person(v) = &person_search_by_id[0] {\n      assert_eq!(data.sara.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Using a term\n    let person_search_by_name = SearchCombinedQuery {\n      search_term: Some(\"tim\".into()),\n      type_: Some(SearchType::Users),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, person_search_by_name);\n    if let SearchCombinedView::Person(v) = &person_search_by_name[0] {\n      assert_eq!(data.timmy.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Test Top sorting (uses post score)\n    let person_search_sort_top = SearchCombinedQuery {\n      type_: Some(SearchType::Users),\n      sort: Some(SearchSortType::Top),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(2, person_search_sort_top);\n\n    // Sara should be first, as she has a higher score\n    if let SearchCombinedView::Person(v) = &person_search_sort_top[0] {\n      assert_eq!(data.sara.id, v.person.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn post() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // post search\n    let post_search = SearchCombinedQuery {\n      type_: Some(SearchType::Posts),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(3, post_search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::Post(v) = &post_search[0] {\n      assert_eq!(data.sara_post.id, v.post.id);\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Post(v) = &post_search[1] {\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Post(v) = &post_search[2] {\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Filtered by id\n    let post_search_by_community = SearchCombinedQuery {\n      community_id: Some(data.community.id),\n      type_: Some(SearchType::Posts),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(2, post_search_by_community);\n\n    // Using a term\n    let post_search_by_name = SearchCombinedQuery {\n      search_term: Some(\"sara\".into()),\n      type_: Some(SearchType::Posts),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, post_search_by_name);\n\n    // Test title only search to make sure 'postbody' doesn't show up\n    // Using a term\n    let post_search_title_only = SearchCombinedQuery {\n      search_term: Some(\"postbody\".into()),\n      type_: Some(SearchType::Posts),\n      title_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert!(post_search_title_only.is_empty());\n\n    // Test title only search to make sure 'postbody' doesn't show up\n    // Using a term\n    let post_search_url_only = SearchCombinedQuery {\n      search_term: data.timmy_post.url.as_ref().map(ToString::to_string),\n      post_url_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(1, post_search_url_only);\n\n    let post_search_partial_url = SearchCombinedQuery {\n      search_term: Some(\"google.c\".to_string()),\n      post_url_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(1, post_search_partial_url);\n\n    // Liked / disliked only\n    let post_search_liked_only = SearchCombinedQuery {\n      type_: Some(SearchType::Posts),\n      liked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    // Should only be 1 not 2, because liked only ignores your own content\n    assert_length!(1, post_search_liked_only);\n\n    let post_search_disliked_only = SearchCombinedQuery {\n      type_: Some(SearchType::Posts),\n      disliked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    // Should be zero because you disliked your own post\n    assert_length!(0, post_search_disliked_only);\n\n    // Test top sort\n    let post_search_sort_top = SearchCombinedQuery {\n      type_: Some(SearchType::Posts),\n      sort: Some(SearchSortType::Top),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(3, post_search_sort_top);\n\n    // Timmy_post_2 has a dislike, so it should be last\n    if let SearchCombinedView::Post(v) = &post_search_sort_top[2] {\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  // Due to the joins which return children, double check to make sure the search term filters\n  // aren't returning child content. IE a search for post title my_post won't return any comments.\n  async fn no_children() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // Post searches should not return the child comments\n    let post_no_children = SearchCombinedQuery {\n      search_term: Some(\"timmy post prv 2\".into()),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, post_no_children);\n\n    // Community searches should not return posts or comments\n    let community_no_children = SearchCombinedQuery {\n      search_term: Some(\"asklemmy\".into()),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, community_no_children);\n\n    // Person searches should not return communities, posts, or comments\n    let person_no_children = SearchCombinedQuery {\n      search_term: Some(\"timmy_pcv\".into()),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, person_no_children);\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn nsfw_post() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let nsfw_post_search = SearchCombinedQuery {\n      type_: Some(SearchType::Posts),\n      show_nsfw: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(4, nsfw_post_search);\n\n    // Make sure the first is the nsfw\n    if let SearchCombinedView::Post(v) = &nsfw_post_search[0] {\n      assert_eq!(data.nsfw_post.id, v.post.id);\n      assert!(v.post.nsfw);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn nsfw_comment() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let nsfw_comment_search = SearchCombinedQuery {\n      type_: Some(SearchType::Comments),\n      show_nsfw: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(4, nsfw_comment_search);\n\n    // Make sure the first is the nsfw\n    if let SearchCombinedView::Comment(v) = &nsfw_comment_search[0] {\n      assert_eq!(data.comment_in_nsfw_post.id, v.comment.id);\n      assert_eq!(data.nsfw_post.id, v.post.id);\n      assert!(v.post.nsfw);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn private_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let unsubbed_private_search = SearchCombinedQuery {\n      community_id: Some(data.private_community.id),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    assert_length!(0, unsubbed_private_search);\n\n    // Approve timmy to the community\n    let follow_form = CommunityFollowerForm::new(\n      data.private_community.id,\n      data.timmy.id,\n      CommunityFollowerState::ApprovalRequired,\n    );\n\n    CommunityActions::follow(pool, &follow_form).await?;\n    CommunityActions::approve_private_community_follower(\n      pool,\n      data.private_community.id,\n      data.timmy.id,\n      data.sara.id,\n      CommunityFollowerState::Accepted,\n    )\n    .await?;\n\n    let subbed_private_search = SearchCombinedQuery {\n      community_id: Some(data.private_community.id),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    // Timmy subscribes to the comm and its accepted\n    // 1 community, 1 post, and 1 comment\n    assert_length!(3, subbed_private_search);\n\n    // Check the content\n    if let SearchCombinedView::Comment(v) = &subbed_private_search[0] {\n      assert_eq!(data.timmy_comment_private_comm.id, v.comment.id);\n      assert_eq!(data.timmy_post_private_comm.id, v.post.id);\n      assert_eq!(data.private_community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let SearchCombinedView::Post(v) = &subbed_private_search[1] {\n      assert_eq!(data.timmy_post_private_comm.id, v.post.id);\n      assert_eq!(data.private_community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n    if let SearchCombinedView::Community(v) = &subbed_private_search[2] {\n      assert_eq!(data.private_community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn comment() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    // comment search\n    let comment_search = SearchCombinedQuery {\n      type_: Some(SearchType::Comments),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(3, comment_search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::Comment(v) = &comment_search[0] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Comment(v) = &comment_search[1] {\n      assert_eq!(data.sara_comment.id, v.comment.id);\n      assert_eq!(data.sara_post.id, v.post.id);\n      assert_eq!(data.community_2.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    if let SearchCombinedView::Comment(v) = &comment_search[2] {\n      assert_eq!(data.timmy_comment.id, v.comment.id);\n      assert_eq!(data.timmy_post.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Filtered by id\n    let comment_search_by_community = SearchCombinedQuery {\n      community_id: Some(data.community.id),\n      type_: Some(SearchType::Comments),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(2, comment_search_by_community);\n\n    // Using a term\n    let comment_search_by_name = SearchCombinedQuery {\n      search_term: Some(\"gold\".into()),\n      type_: Some(SearchType::Comments),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(2, comment_search_by_name);\n\n    // Liked / disliked only\n    let comment_search_liked_only = SearchCombinedQuery {\n      type_: Some(SearchType::Comments),\n      liked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    assert_length!(1, comment_search_liked_only);\n\n    let comment_search_disliked_only = SearchCombinedQuery {\n      type_: Some(SearchType::Comments),\n      disliked_only: Some(true),\n      ..Default::default()\n    }\n    .list(pool, &Some(data.timmy_view.clone()), &data.site)\n    .await?;\n\n    assert_length!(1, comment_search_disliked_only);\n\n    // Test top sort\n    let comment_search_sort_top = SearchCombinedQuery {\n      type_: Some(SearchType::Comments),\n      sort: Some(SearchSortType::Top),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(3, comment_search_sort_top);\n\n    // Sara comment 2 is disliked, so should be last\n    if let SearchCombinedView::Comment(v) = &comment_search_sort_top[2] {\n      assert_eq!(data.sara_comment_2.id, v.comment.id);\n      assert_eq!(data.timmy_post_2.id, v.post.id);\n      assert_eq!(data.community.id, v.community.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn multi_community() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n    let data = init_data(pool).await?;\n\n    let form = MultiCommunityInsertForm::new(\n      data.timmy_view.person.id,\n      data.instance.id,\n      \"multi\".to_string(),\n      String::new(),\n    );\n    let multi = MultiCommunity::create(pool, &form).await?;\n\n    // Multi-community search\n    let search = SearchCombinedQuery {\n      type_: Some(SearchType::MultiCommunities),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n    assert_length!(1, search);\n\n    // Make sure the types are correct\n    if let SearchCombinedView::MultiCommunity(v) = &search[0] {\n      assert_eq!(multi.id, v.multi.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    // Using a term\n    let search_by_name = SearchCombinedQuery {\n      search_term: Some(\"multi\".into()),\n      type_: Some(SearchType::MultiCommunities),\n      ..Default::default()\n    }\n    .list(pool, &None, &data.site)\n    .await?;\n\n    assert_length!(1, search_by_name);\n    if let SearchCombinedView::MultiCommunity(v) = &search_by_name[0] {\n      assert_eq!(multi.id, v.multi.id);\n    } else {\n      panic!(\"wrong type\");\n    }\n\n    cleanup(data, pool).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/search_combined/src/lib.rs",
    "content": "use chrono::{DateTime, Utc};\nuse lemmy_db_schema::{\n  SearchSortType,\n  SearchType,\n  newtypes::CommunityId,\n  source::{\n    combined::search::SearchCombined,\n    comment::{Comment, CommentActions},\n    community::{Community, CommunityActions},\n    community_tag::CommunityTagsView,\n    images::ImageDetails,\n    multi_community::MultiCommunity,\n    person::{Person, PersonActions},\n    post::{Post, PostActions},\n  },\n};\nuse lemmy_db_schema_file::{PersonId, enums::ListingType};\nuse lemmy_db_views_comment::CommentView;\nuse lemmy_db_views_community::{CommunityView, MultiCommunityView};\nuse lemmy_db_views_person::PersonView;\nuse lemmy_db_views_post::PostView;\nuse lemmy_diesel_utils::pagination::PaginationCursor;\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::{\n    CreatorLocalHomeBanExpiresType,\n    community_tags_fragment,\n    creator_ban_expires_from_community,\n    creator_banned_from_community,\n    creator_is_admin,\n    creator_is_moderator,\n    creator_local_home_ban_expires,\n    creator_local_home_banned,\n    local_user_can_mod,\n    post_community_tags_fragment,\n  },\n  lemmy_db_views_local_user::LocalUserView,\n};\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[cfg(feature = \"full\")]\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)]\n#[diesel(check_for_backend(diesel::pg::Pg))]\n/// A combined search view\npub(crate) struct SearchCombinedViewInternal {\n  #[diesel(embed)]\n  pub search_combined: SearchCombined,\n  #[diesel(embed)]\n  pub comment: Option<Comment>,\n  #[diesel(embed)]\n  pub post: Option<Post>,\n  #[diesel(embed)]\n  pub item_creator: Option<Person>,\n  #[diesel(embed)]\n  pub community: Option<Community>,\n  #[diesel(embed)]\n  pub multi_community: Option<MultiCommunity>,\n  #[diesel(embed)]\n  pub community_actions: Option<CommunityActions>,\n  #[diesel(embed)]\n  pub post_actions: Option<PostActions>,\n  #[diesel(embed)]\n  pub person_actions: Option<PersonActions>,\n  #[diesel(embed)]\n  pub comment_actions: Option<CommentActions>,\n  #[diesel(embed)]\n  pub image_details: Option<ImageDetails>,\n  #[diesel(select_expression = creator_is_admin())]\n  pub item_creator_is_admin: bool,\n  #[diesel(select_expression = post_community_tags_fragment())]\n  /// tags for this post\n  pub tags: CommunityTagsView,\n  #[diesel(select_expression = community_tags_fragment())]\n  /// available tags in this community\n  pub community_tags: CommunityTagsView,\n  #[diesel(select_expression = local_user_can_mod())]\n  pub can_mod: bool,\n  #[diesel(select_expression = creator_local_home_banned())]\n  pub creator_banned: bool,\n  #[diesel(\n    select_expression_type = CreatorLocalHomeBanExpiresType,\n    select_expression = creator_local_home_ban_expires()\n  )]\n  pub creator_ban_expires_at: Option<DateTime<Utc>>,\n  #[diesel(select_expression = creator_is_moderator())]\n  pub creator_is_moderator: bool,\n  #[diesel(select_expression = creator_banned_from_community())]\n  pub creator_banned_from_community: bool,\n  #[diesel(select_expression = creator_ban_expires_from_community())]\n  pub creator_community_ban_expires_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"type_\", rename_all = \"snake_case\")]\npub enum SearchCombinedView {\n  Post(PostView),\n  Comment(CommentView),\n  Community(CommunityView),\n  Person(PersonView),\n  MultiCommunity(MultiCommunityView),\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Searches the site, given a search term, and some optional filters.\npub struct Search {\n  /// The search query. Can be a plain text, or an object ID which will be resolved\n  /// (eg `https://lemmy.world/comment/1` or `!fediverse@lemmy.ml`).\n  pub q: String,\n  pub community_id: Option<CommunityId>,\n  pub community_name: Option<String>,\n  pub creator_id: Option<PersonId>,\n  pub type_: Option<SearchType>,\n  pub sort: Option<SearchSortType>,\n  /// Filter to within a given time range, in seconds.\n  /// IE 60 would give results for the past minute.\n  pub time_range_seconds: Option<i32>,\n  pub listing_type: Option<ListingType>,\n  pub title_only: Option<bool>,\n  pub post_url_only: Option<bool>,\n  pub liked_only: Option<bool>,\n  pub disliked_only: Option<bool>,\n  /// If true, then show the nsfw posts (even if your user setting is to hide them)\n  pub show_nsfw: Option<bool>,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The search response, containing lists of the return type possibilities\npub struct SearchResponse {\n  /// If `Search.q` contains an ActivityPub ID (eg `https://lemmy.world/comment/1`) or an\n  /// identifier (eg `!fediverse@lemmy.ml`) then this field contains the resolved object.\n  /// It should always be shown above other search results.\n  pub resolve: Option<SearchCombinedView>,\n  /// Items which contain the search string in post body, comment text, community sidebar etc.\n  /// This is always empty when calling `/api/v4/resolve_object`\n  pub search: Vec<SearchCombinedView>,\n  /// the pagination cursor to use to fetch the next page\n  pub next_page: Option<PaginationCursor>,\n  pub prev_page: Option<PaginationCursor>,\n}\n"
  },
  {
    "path": "crates/db_views/site/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_site\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_db_views_person/full\",\n  \"lemmy_db_views_community/full\",\n  \"anyhow\",\n  \"extism\",\n  \"i-love-jesus\",\n]\nts-rs = [\n  \"dep:ts-rs\",\n  \"lemmy_db_schema/ts-rs\",\n  \"lemmy_db_schema_file/ts-rs\",\n  \"lemmy_db_views_community_follower/ts-rs\",\n  \"lemmy_db_views_community_moderator/ts-rs\",\n  \"lemmy_db_views_local_user/ts-rs\",\n  \"lemmy_db_views_person/ts-rs\",\n  \"lemmy_db_views_community/ts-rs\",\n]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_db_views_community_follower = { workspace = true }\nlemmy_db_views_community_moderator = { workspace = true }\nlemmy_db_views_local_user = { workspace = true }\nlemmy_db_views_person = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nlemmy_db_views_community = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\nurl = { workspace = true }\nextism = { workspace = true, optional = true }\nextism-convert = { workspace = true }\nanyhow = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/site/src/api.rs",
    "content": "use crate::SiteView;\n#[cfg(feature = \"full\")]\nuse extism::FromBytes;\nuse extism_convert::Json;\nuse lemmy_db_schema::{\n  newtypes::{LanguageId, MultiCommunityId, OAuthProviderId, TaglineId},\n  source::{\n    comment::Comment,\n    community::Community,\n    instance::Instance,\n    language::Language,\n    local_site_url_blocklist::LocalSiteUrlBlocklist,\n    local_user::LocalUser,\n    login_token::LoginToken,\n    oauth_provider::{AdminOAuthProvider, PublicOAuthProvider},\n    person::Person,\n    post::Post,\n    private_message::PrivateMessage,\n    tagline::Tagline,\n  },\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  enums::{\n    CommentSortType,\n    FederationMode,\n    ImageMode,\n    ListingType,\n    PostListingMode,\n    PostSortType,\n    RegistrationMode,\n    VoteShow,\n  },\n};\nuse lemmy_db_views_community::MultiCommunityView;\nuse lemmy_db_views_community_follower::CommunityFollowerView;\nuse lemmy_db_views_community_moderator::CommunityModeratorView;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_person::PersonView;\nuse lemmy_diesel_utils::{pagination::PaginationCursor, sensitive::SensitiveString};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse url::Url;\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct AdminAllowInstanceParams {\n  pub instance: String,\n  pub allow: bool,\n  pub reason: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct AdminBlockInstanceParams {\n  pub instance: String,\n  pub block: bool,\n  pub reason: String,\n  /// A time that the block will expire, in unix epoch seconds.\n  ///\n  /// An i64 unix timestamp is used for a simpler API client implementation.\n  pub expires_at: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Logging in with an OAuth 2.0 authorization\npub struct AuthenticateWithOauth {\n  pub code: String,\n  pub oauth_provider_id: OAuthProviderId,\n  pub redirect_uri: Url,\n  pub show_nsfw: Option<bool>,\n  /// Username is mandatory at registration time\n  pub username: Option<String>,\n  /// An answer is mandatory if require application is enabled on the server\n  pub answer: Option<String>,\n  pub pkce_code_verifier: Option<String>,\n  /// If this is true the login is valid forever, otherwise it expires after one week.\n  pub stay_logged_in: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create an external auth method.\npub struct CreateOAuthProvider {\n  pub display_name: String,\n  pub issuer: String,\n  pub authorization_endpoint: String,\n  pub token_endpoint: String,\n  pub userinfo_endpoint: String,\n  pub id_claim: String,\n  pub client_id: String,\n  pub client_secret: String,\n  pub scopes: String,\n  pub auto_verify_email: Option<bool>,\n  pub account_linking_enabled: Option<bool>,\n  pub use_pkce: Option<bool>,\n  pub enabled: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Creates a site. Should be done after first running lemmy.\npub struct CreateSite {\n  pub name: String,\n  pub sidebar: Option<String>,\n  pub summary: Option<String>,\n  pub community_creation_admin_only: Option<bool>,\n  pub require_email_verification: Option<bool>,\n  pub application_question: Option<String>,\n  pub private_instance: Option<bool>,\n  pub default_theme: Option<String>,\n  pub default_post_listing_type: Option<ListingType>,\n  pub default_post_listing_mode: Option<PostListingMode>,\n  pub default_post_sort_type: Option<PostSortType>,\n  pub default_post_time_range_seconds: Option<i32>,\n  pub default_items_per_page: Option<i32>,\n  pub default_comment_sort_type: Option<CommentSortType>,\n  pub legal_information: Option<String>,\n  pub application_email_admins: Option<bool>,\n  pub discussion_languages: Option<Vec<LanguageId>>,\n  pub slur_filter_regex: Option<String>,\n  pub rate_limit_message_max_requests: Option<i32>,\n  pub rate_limit_message_interval_seconds: Option<i32>,\n  pub rate_limit_post_max_requests: Option<i32>,\n  pub rate_limit_post_interval_seconds: Option<i32>,\n  pub rate_limit_register_max_requests: Option<i32>,\n  pub rate_limit_register_interval_seconds: Option<i32>,\n  pub rate_limit_image_max_requests: Option<i32>,\n  pub rate_limit_image_interval_seconds: Option<i32>,\n  pub rate_limit_comment_max_requests: Option<i32>,\n  pub rate_limit_comment_interval_seconds: Option<i32>,\n  pub rate_limit_search_max_requests: Option<i32>,\n  pub rate_limit_search_interval_seconds: Option<i32>,\n  pub rate_limit_import_user_settings_max_requests: Option<i32>,\n  pub rate_limit_import_user_settings_interval_seconds: Option<i32>,\n  pub federation_enabled: Option<bool>,\n  pub registration_mode: Option<RegistrationMode>,\n  pub oauth_registration: Option<bool>,\n  pub content_warning: Option<String>,\n  pub reports_email_admins: Option<bool>,\n  pub federation_signed_fetch: Option<bool>,\n  pub post_upvotes: Option<FederationMode>,\n  pub post_downvotes: Option<FederationMode>,\n  pub comment_upvotes: Option<FederationMode>,\n  pub comment_downvotes: Option<FederationMode>,\n  pub disallow_nsfw_content: Option<bool>,\n  pub disable_email_notifications: Option<bool>,\n  pub suggested_multi_community_id: Option<MultiCommunityId>,\n  pub image_mode: Option<ImageMode>,\n  pub image_proxy_bypass_domains: Option<String>,\n  pub image_upload_timeout_seconds: Option<i32>,\n  pub image_max_thumbnail_size: Option<i32>,\n  pub image_max_avatar_size: Option<i32>,\n  pub image_max_banner_size: Option<i32>,\n  pub image_max_upload_size: Option<i32>,\n  pub image_allow_video_uploads: Option<bool>,\n  pub image_upload_disabled: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete an external auth method.\npub struct DeleteOAuthProvider {\n  pub id: OAuthProviderId,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edit an external auth method.\npub struct EditOAuthProvider {\n  pub id: OAuthProviderId,\n  pub display_name: Option<String>,\n  pub authorization_endpoint: Option<String>,\n  pub token_endpoint: Option<String>,\n  pub userinfo_endpoint: Option<String>,\n  pub id_claim: Option<String>,\n  pub client_secret: Option<String>,\n  pub scopes: Option<String>,\n  pub auto_verify_email: Option<bool>,\n  pub account_linking_enabled: Option<bool>,\n  pub use_pkce: Option<bool>,\n  pub enabled: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Edits a site.\npub struct EditSite {\n  pub name: Option<String>,\n  /// A sidebar for the site, in markdown.\n  pub sidebar: Option<String>,\n  /// A shorter, one line description of your site.\n  pub summary: Option<String>,\n  /// Limits community creation to admins only.\n  pub community_creation_admin_only: Option<bool>,\n  /// Whether to require email verification.\n  pub require_email_verification: Option<bool>,\n  /// Your application question form. This is in markdown, and can be many questions.\n  pub application_question: Option<String>,\n  /// Whether your instance is public, or private.\n  pub private_instance: Option<bool>,\n  /// The default theme. Usually \"browser\"\n  pub default_theme: Option<String>,\n  /// The default post listing type, usually \"local\"\n  pub default_post_listing_type: Option<ListingType>,\n  /// Default value for listing mode, usually \"list\"\n  pub default_post_listing_mode: Option<PostListingMode>,\n  /// The default post sort, usually \"active\"\n  pub default_post_sort_type: Option<PostSortType>,\n  /// A default time range limit to apply to post sorts, in seconds. 0 means none.\n  pub default_post_time_range_seconds: Option<i32>,\n  /// A default fetch limit for number of items returned.\n  pub default_items_per_page: Option<i32>,\n  /// The default comment sort, usually \"hot\"\n  pub default_comment_sort_type: Option<CommentSortType>,\n  /// An optional page of legal information\n  pub legal_information: Option<String>,\n  /// Whether to email admins when receiving a new application.\n  pub application_email_admins: Option<bool>,\n  /// Whether to sign outgoing Activitypub fetches with private key of local instance. Some\n  /// Fediverse instances and platforms require this.\n  pub federation_signed_fetch: Option<bool>,\n  /// A list of allowed discussion languages.\n  pub discussion_languages: Option<Vec<LanguageId>>,\n  /// A regex string of items to filter.\n  pub slur_filter_regex: Option<String>,\n  /// The number of messages allowed in a given time frame.\n  pub rate_limit_message_max_requests: Option<i32>,\n  pub rate_limit_message_interval_seconds: Option<i32>,\n  /// The number of posts allowed in a given time frame.\n  pub rate_limit_post_max_requests: Option<i32>,\n  pub rate_limit_post_interval_seconds: Option<i32>,\n  /// The number of registrations allowed in a given time frame.\n  pub rate_limit_register_max_requests: Option<i32>,\n  pub rate_limit_register_interval_seconds: Option<i32>,\n  /// The number of image uploads allowed in a given time frame.\n  pub rate_limit_image_max_requests: Option<i32>,\n  pub rate_limit_image_interval_seconds: Option<i32>,\n  /// The number of comments allowed in a given time frame.\n  pub rate_limit_comment_max_requests: Option<i32>,\n  pub rate_limit_comment_interval_seconds: Option<i32>,\n  /// The number of searches allowed in a given time frame.\n  pub rate_limit_search_max_requests: Option<i32>,\n  pub rate_limit_search_interval_seconds: Option<i32>,\n  /// The number of settings imports or exports allowed in a given time frame.\n  pub rate_limit_import_user_settings_max_requests: Option<i32>,\n  pub rate_limit_import_user_settings_interval_seconds: Option<i32>,\n  /// Whether to enable federation.\n  pub federation_enabled: Option<bool>,\n  /// A list of blocked URLs\n  pub blocked_urls: Option<Vec<String>>,\n  pub registration_mode: Option<RegistrationMode>,\n  /// Whether to email admins for new reports.\n  pub reports_email_admins: Option<bool>,\n  /// If present, nsfw content is visible by default. Should be displayed by frontends/clients\n  /// when the site is first opened by a user.\n  pub content_warning: Option<String>,\n  /// Whether or not external auth methods can auto-register users.\n  pub oauth_registration: Option<bool>,\n  /// What kind of post upvotes your site allows.\n  pub post_upvotes: Option<FederationMode>,\n  /// What kind of post downvotes your site allows.\n  pub post_downvotes: Option<FederationMode>,\n  /// What kind of comment upvotes your site allows.\n  pub comment_upvotes: Option<FederationMode>,\n  /// What kind of comment downvotes your site allows.\n  pub comment_downvotes: Option<FederationMode>,\n  /// Block NSFW content being created\n  pub disallow_nsfw_content: Option<bool>,\n  /// Dont send email notifications to users for new replies, mentions etc\n  pub disable_email_notifications: Option<bool>,\n  /// A multicommunity with suggested communities which is shown on the homepage. Sending a zero\n  /// erases this field.\n  pub suggested_multi_community_id: Option<MultiCommunityId>,\n  /// A mode for setting how pictrs handles images.\n  pub image_mode: Option<ImageMode>,\n  /// Allows bypassing proxy for specific image hosts when using [[ImageMode.ProxyAllImages]]. Use\n  /// a comma-delimited string.\n  ///\n  /// Example: i.imgur.com,postimg.cc\n  pub image_proxy_bypass_domains: Option<String>,\n  pub image_upload_timeout_seconds: Option<i32>,\n  pub image_max_thumbnail_size: Option<i32>,\n  pub image_max_avatar_size: Option<i32>,\n  pub image_max_banner_size: Option<i32>,\n  pub image_max_upload_size: Option<i32>,\n  pub image_allow_video_uploads: Option<bool>,\n  pub image_upload_disabled: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[serde(rename_all = \"snake_case\")]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\npub enum GetFederatedInstancesKind {\n  #[default]\n  All,\n  Linked,\n  Allowed,\n  Blocked,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct GetFederatedInstances {\n  pub domain_filter: Option<String>,\n  pub kind: GetFederatedInstancesKind,\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// An expanded response for a site.\npub struct GetSiteResponse {\n  pub site_view: SiteView,\n  pub admins: Vec<PersonView>,\n  pub version: String,\n  pub all_languages: Vec<Language>,\n  pub discussion_languages: Vec<LanguageId>,\n  /// If the site has any taglines, a random one is included here for displaying\n  pub tagline: Option<Tagline>,\n  /// A list of external auth methods your site supports.\n  pub oauth_providers: Vec<PublicOAuthProvider>,\n  pub admin_oauth_providers: Vec<AdminOAuthProvider>,\n  pub blocked_urls: Vec<LocalSiteUrlBlocklist>,\n  pub active_plugins: Vec<PluginMetadata>,\n  /// The number of seconds between the last application published, and approved / denied time.\n  ///\n  /// Useful for estimating when your application will be approved.\n  pub last_application_duration_seconds: Option<i64>,\n  pub captcha_enabled: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// The response for a site.\npub struct SiteResponse {\n  pub site_view: SiteView,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[cfg_attr(feature = \"full\", derive(FromBytes))]\n#[cfg_attr(feature = \"full\", encoding(Json))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A captcha response.\npub struct CaptchaResponse {\n  /// A Base64 encoded png\n  pub png: String,\n  /// A Base64 encoded wav audio\n  pub wav: String,\n  /// The UUID for the captcha item.\n  pub uuid: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Changes your account password.\npub struct ChangePassword {\n  pub new_password: SensitiveString,\n  pub new_password_verify: SensitiveString,\n  pub old_password: SensitiveString,\n  /// If this is true the login is valid forever, otherwise it expires after one week.\n  pub stay_logged_in: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete your account.\npub struct DeleteAccount {\n  pub password: SensitiveString,\n  pub delete_content: bool,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A wrapper for the captcha response.\npub struct GetCaptchaResponse {\n  /// Will be None if captchas are disabled.\n  pub ok: Option<CaptchaResponse>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct GenerateTotpSecretResponse {\n  pub totp_secret_url: SensitiveString,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct ListLoginsResponse {\n  pub logins: Vec<LoginToken>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Logging into lemmy.\n///\n/// Note: Banned users can still log in, to be able to do certain things like delete\n/// their account.\npub struct Login {\n  pub username_or_email: SensitiveString,\n  pub password: SensitiveString,\n  /// May be required, if totp is enabled for their account.\n  pub totp_2fa_token: Option<String>,\n  /// If this is true the login is valid forever, otherwise it expires after one week.\n  pub stay_logged_in: Option<bool>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A response for your login.\npub struct LoginResponse {\n  /// This is None in response to `Register` if email verification is enabled, or the server\n  /// requires registration applications.\n  pub jwt: Option<SensitiveString>,\n  /// If registration applications are required, this will return true for a signup response.\n  pub registration_created: bool,\n  /// If email verifications are required, this will return true for a signup response.\n  pub verify_email_sent: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Your user info.\npub struct MyUserInfo {\n  pub local_user_view: LocalUserView,\n  pub follows: Vec<CommunityFollowerView>,\n  pub moderates: Vec<CommunityModeratorView>,\n  pub multi_community_follows: Vec<MultiCommunityView>,\n  pub community_blocks: Vec<Community>,\n  pub instance_communities_blocks: Vec<Instance>,\n  pub instance_persons_blocks: Vec<Instance>,\n  pub person_blocks: Vec<Person>,\n  pub keyword_blocks: Vec<String>,\n  pub discussion_languages: Vec<LanguageId>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Change your password after receiving a reset request.\npub struct PasswordChangeAfterReset {\n  pub token: SensitiveString,\n  pub password: SensitiveString,\n  pub password_verify: SensitiveString,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Reset your password via email.\npub struct PasswordReset {\n  pub email: SensitiveString,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Make a request to resend your verification email.\npub struct ResendVerificationEmail {\n  pub email: SensitiveString,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Saves settings for your user.\npub struct SaveUserSettings {\n  /// Show nsfw posts.\n  pub show_nsfw: Option<bool>,\n  /// Blur nsfw posts.\n  pub blur_nsfw: Option<bool>,\n  /// Your user's theme.\n  pub theme: Option<String>,\n  /// The default post listing type, usually \"local\"\n  pub default_listing_type: Option<ListingType>,\n  /// A post-view mode that changes how multiple post listings look.\n  pub post_listing_mode: Option<PostListingMode>,\n  /// The default post sort, usually \"active\"\n  pub default_post_sort_type: Option<PostSortType>,\n  /// A default time range limit to apply to post sorts, in seconds. 0 means none.\n  pub default_post_time_range_seconds: Option<i32>,\n  /// A default fetch limit for number of items returned.\n  pub default_items_per_page: Option<i32>,\n  /// The default comment sort, usually \"hot\"\n  pub default_comment_sort_type: Option<CommentSortType>,\n  /// The language of the lemmy interface\n  pub interface_language: Option<String>,\n  /// Your display name, which can contain strange characters, and does not need to be unique.\n  pub display_name: Option<String>,\n  /// Your email.\n  pub email: Option<SensitiveString>,\n  /// Your bio / info, in markdown.\n  pub bio: Option<String>,\n  /// Your matrix user id. Ex: @my_user:matrix.org\n  pub matrix_user_id: Option<String>,\n  /// Whether to show or hide avatars.\n  pub show_avatars: Option<bool>,\n  /// Sends notifications to your email.\n  pub send_notifications_to_email: Option<bool>,\n  /// Whether this account is a bot account. Users can hide these accounts easily if they wish.\n  pub bot_account: Option<bool>,\n  /// Whether to show bot accounts.\n  pub show_bot_accounts: Option<bool>,\n  /// Whether to show read posts.\n  pub show_read_posts: Option<bool>,\n  /// A list of languages you are able to see discussion in.\n  pub discussion_languages: Option<Vec<LanguageId>>,\n  // A list of keywords used for blocking posts having them in title,url or body.\n  pub blocking_keywords: Option<Vec<String>>,\n  /// Open links in a new tab\n  pub open_links_in_new_tab: Option<bool>,\n  /// Enable infinite scroll\n  pub infinite_scroll_enabled: Option<bool>,\n  /// Whether user avatars or inline images in the UI that are gifs should be allowed to play or\n  /// should be paused\n  pub enable_animated_images: Option<bool>,\n  /// Whether a user can send / receive private messages\n  pub enable_private_messages: Option<bool>,\n  /// Whether to auto-collapse bot comments.\n  pub collapse_bot_comments: Option<bool>,\n  /// Some vote display mode settings\n  pub show_score: Option<bool>,\n  pub show_upvotes: Option<bool>,\n  pub show_downvotes: Option<VoteShow>,\n  pub show_upvote_percentage: Option<bool>,\n  /// Whether to automatically mark fetched posts as read.\n  pub auto_mark_fetched_posts_as_read: Option<bool>,\n  /// Whether to hide posts containing images/videos.\n  pub hide_media: Option<bool>,\n  /// Whether to show vote totals given to others.\n  pub show_person_votes: Option<bool>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct EditTotp {\n  pub totp_token: String,\n  pub enabled: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct EditTotpResponse {\n  pub enabled: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Block an instance's persons.\npub struct UserBlockInstancePersonsParams {\n  pub instance_id: InstanceId,\n  pub block: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Block an instance's communities.\npub struct UserBlockInstanceCommunitiesParams {\n  pub instance_id: InstanceId,\n  pub block: bool,\n}\n\n#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Verify your email.\npub struct VerifyEmail {\n  pub token: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Create a tagline\npub struct CreateTagline {\n  pub content: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Delete a tagline\npub struct DeleteTagline {\n  pub id: TaglineId,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Fetches a list of taglines.\npub struct ListTaglines {\n  pub page_cursor: Option<PaginationCursor>,\n  pub limit: Option<i64>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct TaglineResponse {\n  pub tagline: Tagline,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Update a tagline\npub struct EditTagline {\n  pub id: TaglineId,\n  pub content: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[cfg_attr(feature = \"full\", derive(FromBytes))]\n#[cfg_attr(feature = \"full\", encoding(Json))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PluginMetadata {\n  pub name: String,\n  pub url: Option<Url>,\n  pub description: Option<String>,\n}\n\nimpl PluginMetadata {\n  pub fn new(name: &'static str, url: &'static str, description: &'static str) -> Self {\n    Self {\n      name: name.to_string(),\n      url: url.parse().ok(),\n      description: Some(description.to_string()),\n    }\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Does an apub fetch for an object.\npub struct ResolveObject {\n  /// Can be the full url, or a shortened version like: !fediverse@lemmy.ml\n  pub q: String,\n}\n\n#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"type_\", rename_all = \"snake_case\")]\npub enum PostOrCommentOrPrivateMessage {\n  Post(Post),\n  Comment(Comment),\n  PrivateMessage(PrivateMessage),\n}\n\n/// Backup of user data. This struct should never be changed so that the data can be used as a\n/// long-term backup in case the instance goes down unexpectedly. All fields are optional to allow\n/// importing partial backups.\n///\n/// This data should not be parsed by apps/clients, but directly downloaded as a file.\n///\n/// Be careful with any changes to this struct, to avoid breaking changes which could prevent\n/// importing older backups.\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\n#[serde(deny_unknown_fields)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct UserSettingsBackup {\n  pub display_name: Option<String>,\n  pub bio: Option<String>,\n  pub avatar: Option<Url>,\n  pub banner: Option<Url>,\n  pub matrix_id: Option<String>,\n  pub bot_account: Option<bool>,\n  // TODO: might be worth making a separate struct for settings backup, to avoid breakage in case\n  //       fields are renamed, and to avoid storing unnecessary fields like person_id or email\n  pub settings: Option<LocalUser>,\n  #[serde(default)]\n  pub followed_communities: Vec<Url>,\n  #[serde(default)]\n  pub saved_posts: Vec<Url>,\n  #[serde(default)]\n  pub saved_comments: Vec<Url>,\n  #[serde(default)]\n  pub blocked_communities: Vec<Url>,\n  #[serde(default)]\n  pub blocked_users: Vec<Url>,\n  #[serde(default)]\n  #[serde(alias = \"blocked_instances\")] // the name used by v0.19\n  pub blocked_instances_communities: Vec<String>,\n  #[serde(default)]\n  pub blocked_instances_persons: Vec<String>,\n  #[serde(default)]\n  pub blocking_keywords: Vec<String>,\n  #[serde(default)]\n  pub discussion_languages: Vec<String>,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// Your exported data.\npub struct ExportDataResponse {\n  pub notifications: Vec<PostOrCommentOrPrivateMessage>,\n  pub content: Vec<PostOrCommentOrPrivateMessage>,\n  pub read_posts: Vec<Url>,\n  pub liked: Vec<Url>,\n  pub moderates: Vec<Url>,\n  pub settings: UserSettingsBackup,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A response that completes successfully.\npub struct SuccessResponse {\n  pub success: bool,\n}\n\nimpl Default for SuccessResponse {\n  fn default() -> Self {\n    SuccessResponse { success: true }\n  }\n}\n\n/// Contains the amount of unread items of various types. For normal users this means the number of\n/// unread notifications, mods and admins get additional unread counts for reports, registration\n/// applications and pending follows to private communities.\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct UnreadCountsResponse {\n  pub notification_count: i64,\n  pub report_count: Option<i64>,\n  pub pending_follow_count: Option<i64>,\n  pub registration_application_count: Option<i64>,\n}\n"
  },
  {
    "path": "crates/db_views/site/src/impls.rs",
    "content": "use crate::{\n  FederatedInstanceView,\n  SiteView,\n  api::{GetFederatedInstances, GetFederatedInstancesKind, UserSettingsBackup},\n};\nuse diesel::{\n  ExpressionMethods,\n  JoinOnDsl,\n  OptionalExtension,\n  PgTextExpressionMethods,\n  QueryDsl,\n  SelectableHelper,\n};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  source::{\n    actor_language::LocalUserLanguage,\n    instance::{Instance, instance_keys as key},\n    keyword_block::LocalUserKeywordBlock,\n    language::Language,\n    local_user::LocalUser,\n    person::Person,\n  },\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  schema::{\n    federation_allowlist,\n    federation_blocklist,\n    federation_queue_state,\n    instance,\n    local_site,\n    local_site_rate_limit,\n    site,\n  },\n};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{CursorData, PagedResponse, PaginationCursorConversion, paginate_response},\n  traits::Crud,\n  utils::fuzzy_search,\n};\nuse lemmy_utils::{\n  CacheLock,\n  build_cache,\n  error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult},\n};\nuse std::{\n  collections::HashMap,\n  sync::{Arc, LazyLock},\n};\n\nimpl SiteView {\n  pub async fn read_local(pool: &mut DbPool<'_>) -> LemmyResult<Self> {\n    static CACHE: CacheLock<SiteView> = LazyLock::new(build_cache);\n    CACHE\n      .try_get_with((), async move {\n        let conn = &mut get_conn(pool).await?;\n        let local_site = site::table\n          .inner_join(local_site::table)\n          .inner_join(instance::table)\n          .inner_join(\n            local_site_rate_limit::table\n              .on(local_site::id.eq(local_site_rate_limit::local_site_id)),\n          )\n          .select(Self::as_select())\n          .first(conn)\n          .await\n          .optional()?\n          .ok_or(LemmyErrorType::LocalSiteNotSetup)?;\n        Ok(local_site)\n      })\n      .await\n      .map_err(|e: Arc<LemmyError>| anyhow::anyhow!(\"err getting local site: {e:?}\").into())\n  }\n\n  /// A special site bot user, solely made for following non-local communities for\n  /// multi-communities.\n  pub async fn read_system_account(pool: &mut DbPool<'_>) -> LemmyResult<Person> {\n    let site_view = SiteView::read_local(pool).await?;\n    Person::read(pool, site_view.local_site.system_account).await\n  }\n}\n\npub async fn user_backup_list_to_user_settings_backup(\n  local_user_view: LocalUserView,\n  pool: &mut DbPool<'_>,\n) -> LemmyResult<UserSettingsBackup> {\n  let lists = LocalUser::export_backup(pool, local_user_view.person.id).await?;\n  let blocking_keywords = LocalUserKeywordBlock::read(pool, local_user_view.local_user.id).await?;\n  let discussion_languages = LocalUserLanguage::read(pool, local_user_view.local_user.id).await?;\n\n  let all_languages: HashMap<_, _> = Language::read_all(pool)\n    .await?\n    .into_iter()\n    .map(|l| (l.id, l.code))\n    .collect();\n  let discussion_languages = discussion_languages\n    .iter()\n    .flat_map(|d| all_languages.get(d).cloned())\n    .collect();\n  let vec_into = |vec: Vec<_>| vec.into_iter().map(Into::into).collect();\n  Ok(UserSettingsBackup {\n    display_name: local_user_view.person.display_name,\n    bio: local_user_view.person.bio,\n    avatar: local_user_view.person.avatar.map(Into::into),\n    banner: local_user_view.person.banner.map(Into::into),\n    matrix_id: local_user_view.person.matrix_user_id,\n    bot_account: local_user_view.person.bot_account.into(),\n    settings: Some(local_user_view.local_user),\n    followed_communities: vec_into(lists.followed_communities),\n    blocked_communities: vec_into(lists.blocked_communities),\n    blocked_instances_communities: lists.blocked_instances_communities,\n    blocked_instances_persons: lists.blocked_instances_persons,\n    blocked_users: vec_into(lists.blocked_users),\n    saved_posts: vec_into(lists.saved_posts),\n    saved_comments: vec_into(lists.saved_comments),\n    blocking_keywords,\n    discussion_languages,\n  })\n}\n\nimpl FederatedInstanceView {\n  #[diesel::dsl::auto_type(no_type_alias)]\n  fn joins() -> _ {\n    instance::table\n      // omit instance representing the local site\n      .left_join(site::table.left_join(local_site::table))\n      .filter(local_site::id.is_null())\n      .left_join(federation_blocklist::table)\n      .left_join(federation_allowlist::table)\n      .left_join(federation_queue_state::table)\n  }\n\n  pub async fn list(\n    pool: &mut DbPool<'_>,\n    data: GetFederatedInstances,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    let limit = limit_fetch(data.limit, None)?;\n    let mut query = Self::joins()\n      .select(Self::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    if let Some(domain_filter) = &data.domain_filter {\n      query = query.filter(instance::domain.ilike(fuzzy_search(domain_filter)))\n    }\n\n    query = match data.kind {\n      GetFederatedInstancesKind::All => query,\n      GetFederatedInstancesKind::Linked => {\n        query.filter(federation_blocklist::instance_id.is_null())\n      }\n      GetFederatedInstancesKind::Allowed => {\n        query.filter(federation_allowlist::instance_id.is_not_null())\n      }\n      GetFederatedInstancesKind::Blocked => {\n        query.filter(federation_blocklist::instance_id.is_not_null())\n      }\n    };\n\n    let mut pq = Self::paginate(query, &data.page_cursor, SortDirection::Desc, pool, None).await?;\n\n    // Show recently updated instances and those with valid metadata first\n    pq = pq\n      .then_order_by(key::updated_at)\n      .then_order_by(key::software)\n      .then_order_by(key::id);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = pq\n      .get_results(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_response(res, limit, data.page_cursor)\n  }\n\n  pub async fn read(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult<Self> {\n    let conn = &mut get_conn(pool).await?;\n    Self::joins()\n      .filter(instance::id.eq(instance_id))\n      .select(Self::as_select())\n      .get_result(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)\n  }\n}\n\nimpl PaginationCursorConversion for FederatedInstanceView {\n  type PaginatedType = Instance;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_id(self.instance.id.0)\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    Instance::read(pool, InstanceId(cursor.id()?)).await\n  }\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod tests {\n  use crate::{\n    FederatedInstanceView,\n    api::{GetFederatedInstances, GetFederatedInstancesKind},\n  };\n  use lemmy_db_schema::{\n    assert_length,\n    source::{\n      federation_allowlist::{FederationAllowList, FederationAllowListForm},\n      federation_queue_state::FederationQueueState,\n      instance::Instance,\n      site::{Site, SiteInsertForm},\n    },\n  };\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_instance_list() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    // insert test data\n    let instance0 = Instance::read_or_create(pool, \"example0.com\").await?;\n    let instance1 = Instance::read_or_create(pool, \"example1.com\").await?;\n    let site_form = SiteInsertForm::new(\"Example\".to_string(), instance0.id);\n    let site = Site::create(pool, &site_form).await?;\n    let form = FederationAllowListForm::new(instance0.id);\n    let allow = FederationAllowList::allow(pool, &form).await?;\n    let queue_state = FederationQueueState {\n      instance_id: instance0.id,\n      fail_count: 5,\n      last_successful_id: None,\n      last_successful_published_time_at: None,\n      last_retry_at: None,\n    };\n    FederationQueueState::upsert(pool, &queue_state).await?;\n\n    // run the query\n    let data = GetFederatedInstances {\n      domain_filter: None,\n      kind: GetFederatedInstancesKind::Linked,\n      page_cursor: None,\n      limit: None,\n    };\n    let list = FederatedInstanceView::list(pool, data).await?;\n    assert_length!(2, list);\n\n    // compare first result\n    let list0 = &list[1];\n    assert_eq!(instance0.domain, list0.instance.domain);\n    assert_eq!(Some(site), list0.site.clone());\n    assert_eq!(\n      Some(queue_state.fail_count),\n      list0.queue_state.clone().map(|q| q.fail_count)\n    );\n    assert_eq!(Some(allow), list0.allowed);\n    assert!(list0.blocked.is_none());\n\n    // compare second result\n    let list1 = &list[0];\n    assert_eq!(instance1.domain, list1.instance.domain);\n    assert!(list1.site.is_none());\n    assert!(list1.queue_state.is_none());\n    assert!(list1.allowed.is_none());\n    assert!(list1.blocked.is_none());\n\n    Instance::delete_all(pool).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/site/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel::{Queryable, Selectable};\nuse lemmy_db_schema::source::{\n  federation_allowlist::FederationAllowList,\n  federation_blocklist::FederationBlockList,\n  federation_queue_state::FederationQueueState,\n  instance::Instance,\n  local_site::LocalSite,\n  local_site_rate_limit::LocalSiteRateLimit,\n  site::Site,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n\npub mod api;\n#[cfg(feature = \"full\")]\npub mod impls;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A site view.\npub struct SiteView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub site: Site,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub local_site: LocalSite,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub local_site_rate_limit: LocalSiteRateLimit,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub instance: Instance,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct FederatedInstanceView {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub instance: Instance,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub site: Option<Site>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub queue_state: Option<FederationQueueState>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub blocked: Option<FederationBlockList>,\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub allowed: Option<FederationAllowList>,\n}\n"
  },
  {
    "path": "crates/db_views/vote/Cargo.toml",
    "content": "[package]\nname = \"lemmy_db_views_vote\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"lemmy_utils\",\n  \"diesel\",\n  \"diesel-async\",\n  \"lemmy_db_schema/full\",\n  \"lemmy_db_schema_file/full\",\n  \"lemmy_diesel_utils/full\",\n  \"i-love-jesus\",\n]\nts-rs = [\"dep:ts-rs\", \"lemmy_db_schema/ts-rs\"]\n\n[dependencies]\nlemmy_db_schema = { workspace = true }\nlemmy_utils = { workspace = true, optional = true }\nlemmy_db_schema_file = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\ndiesel = { workspace = true, optional = true }\ndiesel-async = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_with = { workspace = true }\nts-rs = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntokio = { workspace = true }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/db_views/vote/src/impls.rs",
    "content": "use crate::{VoteView, VoteViewComment, VoteViewPost};\nuse diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper};\nuse diesel_async::RunQueryDsl;\nuse i_love_jesus::SortDirection;\nuse lemmy_db_schema::{\n  newtypes::{CommentId, PostId},\n  source::{comment::CommentActions, post::PostActions},\n  utils::limit_fetch,\n};\nuse lemmy_db_schema_file::{\n  InstanceId,\n  PersonId,\n  aliases::creator_community_actions,\n  joins::{creator_home_instance_actions_join, creator_local_instance_actions_join},\n  schema::{comment, comment_actions, community_actions, person, post, post_actions},\n};\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  pagination::{\n    CursorData,\n    PagedResponse,\n    PaginationCursor,\n    PaginationCursorConversion,\n    paginate_response,\n  },\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse serde::{Deserialize, Serialize};\n\nimpl VoteView {\n  pub async fn list_for_post(\n    pool: &mut DbPool<'_>,\n    post_id: PostId,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n    local_instance_id: InstanceId,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    use lemmy_db_schema::source::post::post_actions_keys as key;\n    let limit = limit_fetch(limit, None)?;\n\n    let creator_community_actions_join = creator_community_actions.on(\n      creator_community_actions\n        .field(community_actions::community_id)\n        .eq(post::community_id)\n        .and(\n          creator_community_actions\n            .field(community_actions::person_id)\n            .eq(post_actions::person_id),\n        ),\n    );\n\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    let query = post_actions::table\n      .inner_join(person::table)\n      .inner_join(post::table)\n      .left_join(creator_community_actions_join)\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .filter(post_actions::post_id.eq(post_id))\n      .filter(post_actions::vote_is_upvote.is_not_null())\n      .select(VoteViewPost::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    // Sorting by like score\n    let query = VoteViewPost::paginate(query, &page_cursor, SortDirection::Asc, pool, None)\n      .await?\n      .then_order_by(key::vote_is_upvote)\n      // Tie breaker\n      .then_order_by(key::voted_at);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = query\n      .load::<VoteViewPost>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_vote_response(res, limit, page_cursor)\n  }\n\n  pub async fn list_for_comment(\n    pool: &mut DbPool<'_>,\n    comment_id: CommentId,\n    page_cursor: Option<PaginationCursor>,\n    limit: Option<i64>,\n    local_instance_id: InstanceId,\n  ) -> LemmyResult<PagedResponse<Self>> {\n    use lemmy_db_schema::source::comment::comment_actions_keys as key;\n    let limit = limit_fetch(limit, None)?;\n\n    let creator_community_actions_join = creator_community_actions.on(\n      creator_community_actions\n        .field(community_actions::community_id)\n        .eq(post::community_id)\n        .and(\n          creator_community_actions\n            .field(community_actions::person_id)\n            .eq(comment_actions::person_id),\n        ),\n    );\n\n    let creator_local_instance_actions_join: creator_local_instance_actions_join =\n      creator_local_instance_actions_join(local_instance_id);\n\n    let query = comment_actions::table\n      .inner_join(person::table)\n      .inner_join(comment::table.inner_join(post::table))\n      .left_join(creator_community_actions_join)\n      .left_join(creator_home_instance_actions_join())\n      .left_join(creator_local_instance_actions_join)\n      .filter(comment_actions::comment_id.eq(comment_id))\n      .filter(comment_actions::vote_is_upvote.is_not_null())\n      .select(VoteViewComment::as_select())\n      .limit(limit)\n      .into_boxed();\n\n    // Sorting by like score\n    let query = VoteViewComment::paginate(query, &page_cursor, SortDirection::Asc, pool, None)\n      .await?\n      .then_order_by(key::vote_is_upvote)\n      // Tie breaker\n      .then_order_by(key::voted_at);\n\n    let conn = &mut get_conn(pool).await?;\n    let res = query\n      .load::<VoteViewComment>(conn)\n      .await\n      .with_lemmy_type(LemmyErrorType::NotFound)?;\n    paginate_vote_response(res, limit, page_cursor)\n  }\n}\n\n// https://github.com/rust-lang/rust/issues/115590\n#[expect(clippy::multiple_bound_locations)]\nfn paginate_vote_response<\n  #[cfg(feature = \"ts-rs\")] T: ts_rs::TS,\n  #[cfg(not(feature = \"ts-rs\"))] T,\n>(\n  data: Vec<T>,\n  limit: i64,\n  page_cursor: Option<PaginationCursor>,\n) -> LemmyResult<PagedResponse<VoteView>>\nwhere\n  T: PaginationCursorConversion + Serialize + for<'a> Deserialize<'a>,\n  VoteView: From<T>,\n{\n  let res = paginate_response(data, limit, page_cursor)?;\n  Ok(PagedResponse {\n    items: res.items.into_iter().map(Into::into).collect(),\n    next_page: res.next_page,\n    prev_page: res.prev_page,\n  })\n}\n\nimpl PaginationCursorConversion for VoteViewPost {\n  type PaginatedType = PostActions;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_multi([self.creator.id.0, self.post_id.0])\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let [creator_id, post_id] = cursor.multi()?;\n    PostActions::read(pool, PostId(post_id), PersonId(creator_id)).await\n  }\n}\n\nimpl PaginationCursorConversion for VoteViewComment {\n  type PaginatedType = CommentActions;\n  fn to_cursor(&self) -> CursorData {\n    CursorData::new_multi([self.creator.id.0, self.comment_id.0])\n  }\n\n  async fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> LemmyResult<Self::PaginatedType> {\n    let [creator_id, comment_id] = cursor.multi()?;\n    CommentActions::read(pool, CommentId(comment_id), PersonId(creator_id)).await\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::VoteView;\n  use lemmy_db_schema::{\n    source::{\n      comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm},\n      community::{Community, CommunityActions, CommunityInsertForm, CommunityPersonBanForm},\n      instance::Instance,\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    },\n    traits::{Bannable, Likeable},\n  };\n  use lemmy_db_schema_file::InstanceId;\n  use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud};\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn post_and_comment_vote_views() -> LemmyResult<()> {\n    let pool = &build_db_pool_for_tests();\n    let pool = &mut pool.into();\n\n    let inserted_instance = Instance::read_or_create(pool, \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"timmy_vv\");\n\n    let inserted_timmy = Person::create(pool, &new_person).await?;\n\n    let new_person_2 = PersonInsertForm::test_form(inserted_instance.id, \"sara_vv\");\n\n    let inserted_sara = Person::create(pool, &new_person_2).await?;\n\n    let new_community = CommunityInsertForm::new(\n      inserted_instance.id,\n      \"test community vv\".to_string(),\n      \"nada\".to_owned(),\n      \"pubkey\".to_string(),\n    );\n    let inserted_community = Community::create(pool, &new_community).await?;\n\n    let new_post = PostInsertForm::new(\n      \"A test post vv\".into(),\n      inserted_timmy.id,\n      inserted_community.id,\n    );\n    let inserted_post = Post::create(pool, &new_post).await?;\n\n    let comment_form = CommentInsertForm::new(\n      inserted_timmy.id,\n      inserted_post.id,\n      \"A test comment vv\".into(),\n    );\n    let inserted_comment = Comment::create(pool, &comment_form, None).await?;\n\n    // Timmy upvotes his own post\n    let timmy_post_vote_form = PostLikeForm::new(inserted_post.id, inserted_timmy.id, Some(true));\n    PostActions::like(pool, &timmy_post_vote_form).await?;\n\n    // Sara downvotes timmy's post\n    let sara_post_vote_form = PostLikeForm::new(inserted_post.id, inserted_sara.id, Some(false));\n    PostActions::like(pool, &sara_post_vote_form).await?;\n\n    let mut expected_post_vote_views = [\n      VoteView {\n        creator: inserted_sara.clone(),\n        creator_banned: false,\n        creator_banned_from_community: false,\n        is_upvote: false,\n      },\n      VoteView {\n        creator: inserted_timmy.clone(),\n        creator_banned: false,\n        creator_banned_from_community: false,\n        is_upvote: true,\n      },\n    ];\n    expected_post_vote_views[1].creator.post_count = 1;\n    expected_post_vote_views[1].creator.comment_count = 1;\n\n    let read_post_vote_views =\n      VoteView::list_for_post(pool, inserted_post.id, None, None, InstanceId(1)).await?;\n    assert_eq!(read_post_vote_views.items, expected_post_vote_views);\n\n    // Timothy votes down his own comment\n    let timmy_comment_vote_form =\n      CommentLikeForm::new(inserted_comment.id, inserted_timmy.id, Some(false));\n    CommentActions::like(pool, &timmy_comment_vote_form).await?;\n\n    // Sara upvotes timmy's comment\n    let sara_comment_vote_form =\n      CommentLikeForm::new(inserted_comment.id, inserted_sara.id, Some(true));\n    CommentActions::like(pool, &sara_comment_vote_form).await?;\n\n    let mut expected_comment_vote_views = [\n      VoteView {\n        creator: inserted_timmy.clone(),\n        creator_banned: false,\n        creator_banned_from_community: false,\n        is_upvote: false,\n      },\n      VoteView {\n        creator: inserted_sara.clone(),\n        creator_banned: false,\n        creator_banned_from_community: false,\n        is_upvote: true,\n      },\n    ];\n    expected_comment_vote_views[0].creator.post_count = 1;\n    expected_comment_vote_views[0].creator.comment_count = 1;\n\n    let read_comment_vote_views =\n      VoteView::list_for_comment(pool, inserted_comment.id, None, None, InstanceId(1)).await?;\n    assert_eq!(read_comment_vote_views.items, expected_comment_vote_views);\n\n    // Ban timmy from that community\n    let ban_timmy_form = CommunityPersonBanForm::new(inserted_community.id, inserted_timmy.id);\n    CommunityActions::ban(pool, &ban_timmy_form).await?;\n\n    // Make sure creator_banned_from_community is true\n    let read_comment_vote_views_after_ban =\n      VoteView::list_for_comment(pool, inserted_comment.id, None, None, InstanceId(1)).await?;\n\n    assert!(\n      read_comment_vote_views_after_ban\n        .first()\n        .is_some_and(|c| c.creator_banned_from_community)\n    );\n\n    let read_post_vote_views_after_ban =\n      VoteView::list_for_post(pool, inserted_post.id, None, None, InstanceId(1)).await?;\n\n    assert!(\n      read_post_vote_views_after_ban\n        .get(1)\n        .is_some_and(|p| p.creator_banned_from_community)\n    );\n\n    // Cleanup\n    Instance::delete(pool, inserted_instance.id).await?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/db_views/vote/src/lib.rs",
    "content": "use lemmy_db_schema::{\n  newtypes::{CommentId, PostId},\n  source::person::Person,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\n#[cfg(feature = \"full\")]\nuse {\n  diesel::{ExpressionMethods, NullableExpressionMethods, Queryable, Selectable},\n  lemmy_db_schema::utils::queries::selects::creator_local_home_banned,\n  lemmy_db_schema_file::{\n    aliases::creator_community_actions,\n    schema::{comment, comment_actions, community_actions, post, post_actions},\n  },\n};\n\n#[cfg(feature = \"full\")]\npub mod impls;\n\n/// Only used internally so no ts(export)\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\nstruct VoteViewPost {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = creator_local_home_banned()))]\n  pub creator_banned: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = creator_community_actions\n          .field(community_actions::received_ban_at)\n          .nullable()\n          .is_not_null()))]\n  pub creator_banned_from_community: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = post_actions::vote_is_upvote.assume_not_null()))]\n  pub is_upvote: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = post::id))]\n  post_id: PostId,\n}\n\n/// Only used internally so no ts(export)\n#[derive(Serialize, Deserialize)]\n#[cfg_attr(feature = \"full\", derive(Queryable, Selectable))]\n#[cfg_attr(feature = \"full\", diesel(check_for_backend(diesel::pg::Pg)))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\nstruct VoteViewComment {\n  #[cfg_attr(feature = \"full\", diesel(embed))]\n  pub creator: Person,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = creator_local_home_banned()))]\n  pub creator_banned: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = creator_community_actions\n          .field(community_actions::received_ban_at)\n          .nullable()\n          .is_not_null()))]\n  pub creator_banned_from_community: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = comment_actions::vote_is_upvote.assume_not_null()))]\n  pub is_upvote: bool,\n  #[cfg_attr(feature = \"full\", diesel(select_expression = comment::id))]\n  comment_id: CommentId,\n}\n\n#[skip_serializing_none]\n#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\n/// A vote view for checking a post or comments votes.\npub struct VoteView {\n  pub creator: Person,\n  pub creator_banned: bool,\n  pub creator_banned_from_community: bool,\n  /// True means Upvote, False means Downvote.\n  pub is_upvote: bool,\n}\n\nimpl From<VoteViewComment> for VoteView {\n  fn from(v: VoteViewComment) -> Self {\n    VoteView {\n      creator: v.creator,\n      creator_banned: v.creator_banned,\n      creator_banned_from_community: v.creator_banned_from_community,\n      is_upvote: v.is_upvote,\n    }\n  }\n}\n\nimpl From<VoteViewPost> for VoteView {\n  fn from(v: VoteViewPost) -> Self {\n    VoteView {\n      creator: v.creator,\n      creator_banned: v.creator_banned,\n      creator_banned_from_community: v.creator_banned_from_community,\n      is_upvote: v.is_upvote,\n    }\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/Cargo.toml",
    "content": "[package]\nname = \"lemmy_diesel_utils\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_diesel_utils\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\nts-rs = [\"dep:ts-rs\"]\nfull = [\n  \"diesel-async\",\n  \"chrono\",\n  \"diesel_migrations\",\n  \"anyhow\",\n  \"diesel_ltree\",\n  \"tracing\",\n  \"deadpool\",\n  \"futures-util\",\n  \"tokio\",\n  \"tokio-postgres\",\n  \"tokio-postgres-rustls\",\n  \"rustls\",\n  \"i-love-jesus\",\n  \"lemmy_utils/full\",\n  \"diesel\",\n  \"activitypub_federation\",\n  \"serde_urlencoded\",\n  \"base64\",\n  \"itertools\",\n]\n\n[dependencies]\ndiesel = { workspace = true, optional = true }\nchrono = { workspace = true, optional = true }\ndiesel_migrations = { workspace = true, optional = true }\nanyhow = { workspace = true, optional = true }\nserde = { workspace = true }\nurl = { workspace = true }\nactivitypub_federation = { workspace = true, optional = true }\ndiesel-derive-newtype = { workspace = true }\ndiesel-async = { workspace = true, features = [\n  \"deadpool\",\n  \"postgres\",\n], optional = true }\ndiesel_ltree = { workspace = true, optional = true }\ntracing = { workspace = true, optional = true }\ndeadpool = { version = \"0.12.3\", features = [\"rt_tokio_1\"], optional = true }\nts-rs = { workspace = true, optional = true }\nfutures-util = { workspace = true, optional = true }\ntokio = { workspace = true, optional = true }\ntokio-postgres = { workspace = true, optional = true }\ntokio-postgres-rustls = { workspace = true, optional = true }\nrustls = { workspace = true, optional = true }\ni-love-jesus = { workspace = true, optional = true }\nlemmy_utils = { workspace = true, features = [\"full\"], optional = true }\nserde_urlencoded = { version = \"0.7.1\", optional = true }\nbase64 = { workspace = true, optional = true }\nserde_with = { workspace = true }\nitertools = { workspace = true, optional = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ndiff = \"0.1.13\"\nitertools = { workspace = true }\npathfinding = \"4.14.0\"\nunified-diff = { workspace = true }\ndiesel_ltree = { workspace = true }\nlemmy_db_schema_file = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/diesel_utils/build.rs",
    "content": "use std::path::Path;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n  let migrations_dir = Path::new(\"../../migrations/\");\n  if !migrations_dir.exists() {\n    return Err(\"Migrations dir not found\".into());\n  }\n  println!(\"cargo:rerun-if-changed={}\", migrations_dir.display());\n  Ok(())\n}\n"
  },
  {
    "path": "crates/diesel_utils/replaceable_schema/triggers.sql",
    "content": "-- A trigger is associated with a table instead of a schema, so they can't be in the `r` schema. This is\n-- okay if the function specified after `EXECUTE FUNCTION` is in `r`, since dropping the function drops the trigger.\n--\n-- Triggers that update multiple tables should use this order: person_aggregates, comment_aggregates,\n-- post, community_aggregates, site_aggregates\n--   * The order matters because the updated rows are locked until the end of the transaction, and statements\n--     in a trigger don't use separate transactions. This means that updates closer to the beginning cause\n--     longer locks because the duration of each update extends the durations of the locks caused by previous\n--     updates. Long locks are worse on rows that have more concurrent transactions trying to update them. The\n--     listed order starts with tables that are less likely to have such rows.\n--     https://www.postgresql.org/docs/16/transaction-iso.html#XACT-READ-COMMITTED\n--   * Using the same order in every trigger matters because a deadlock is possible if multiple transactions\n--     update the same rows in a different order.\n--     https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS\n--\n--\n-- Create triggers for both post and comments\nCREATE PROCEDURE r.post_or_comment (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$\n        -- When a thing gets a vote, update its aggregates and its creator's aggregates\n        CALL r.create_triggers ('thing_actions', $$\n            BEGIN\n                WITH thing_diff AS ( UPDATE\n                        thing AS a\n                    SET\n                        score = a.score + diff.upvotes - diff.downvotes, upvotes = a.upvotes + diff.upvotes, downvotes = a.downvotes + diff.downvotes, controversy_rank = r.controversy_rank ((a.upvotes + diff.upvotes)::numeric, (a.downvotes + diff.downvotes)::numeric)\n                    FROM (\n                        SELECT\n                            (thing_actions).thing_id, coalesce(sum(count_diff) FILTER (WHERE (thing_actions).vote_is_upvote), 0) AS upvotes, coalesce(sum(count_diff) FILTER (WHERE NOT (thing_actions).vote_is_upvote), 0) AS downvotes FROM select_old_and_new_rows AS old_and_new_rows\n                WHERE (thing_actions).vote_is_upvote IS NOT NULL GROUP BY (thing_actions).thing_id) AS diff\n            WHERE\n                a.id = diff.thing_id\n                    AND (diff.upvotes, diff.downvotes) != (0, 0)\n                RETURNING\n                    a.creator_id AS creator_id, diff.upvotes - diff.downvotes AS score)\n                UPDATE\n                    person AS a\n                SET\n                    thing_score = a.thing_score + diff.score FROM (\n                        SELECT\n                            creator_id, sum(score) AS score FROM thing_diff GROUP BY creator_id) AS diff\n                    WHERE\n                        a.id = diff.creator_id\n                        AND diff.score != 0;\n                RETURN NULL;\n            END;\n    $$);\n    $b$,\n    'thing',\n    table_name);\nEND;\n$a$;\n\nCALL r.post_or_comment ('post');\n\nCALL r.post_or_comment ('comment');\n\n-- Create triggers that update counts in parent aggregates\nCREATE FUNCTION r.parent_comment_ids (path ltree)\n    RETURNS SETOF int\n    LANGUAGE sql\n    IMMUTABLE parallel safe\nBEGIN ATOMIC\n    SELECT\n        comment_id::int\n    FROM\n        string_to_table (ltree2text (path), '.') AS comment_id\n    -- Skip first and last\nLIMIT (nlevel (path) - 2)\n    OFFSET 1;\nEND;\nCALL r.create_triggers ('comment', $$\nBEGIN\n    -- Prevent infinite recursion\n    IF (\n        SELECT\n            count(*)\n    FROM select_old_and_new_rows AS old_and_new_rows) = 0 THEN\n        RETURN NULL;\nEND IF;\nUPDATE\n    person AS a\nSET\n    comment_count = a.comment_count + diff.comment_count\nFROM (\n    SELECT\n        (comment).creator_id,\n        coalesce(sum(count_diff), 0) AS comment_count\n    FROM\n        select_old_and_new_rows AS old_and_new_rows\n    WHERE\n        r.is_counted (comment)\n    GROUP BY\n        (comment).creator_id) AS diff\nWHERE\n    a.id = diff.creator_id\n    AND diff.comment_count != 0;\nUPDATE\n    comment AS a\nSET\n    child_count = a.child_count + diff.child_count\nFROM (\n    SELECT\n        parent_id,\n        coalesce(sum(count_diff), 0) AS child_count\n    FROM (\n        -- For each inserted or deleted comment, this outputs 1 row for each parent comment.\n        -- For example, this:\n        --\n        --  count_diff | (comment).path\n        -- ------------+----------------\n        --  1          | 0.5.6.7\n        --  1          | 0.5.6.7.8\n        --\n        -- becomes this:\n        --\n        --  count_diff | parent_id\n        -- ------------+-----------\n        --  1          | 5\n        --  1          | 6\n        --  1          | 5\n        --  1          | 6\n        --  1          | 7\n        SELECT\n            count_diff,\n            parent_id\n        FROM\n            select_old_and_new_rows AS old_and_new_rows,\n            LATERAL r.parent_comment_ids ((comment).path) AS parent_id) AS expanded_old_and_new_rows\n    GROUP BY\n        parent_id) AS diff\nWHERE\n    a.id = diff.parent_id\n    AND diff.child_count != 0;\nUPDATE\n    post AS a\nSET\n    comments = a.comments + diff.comments,\n    newest_comment_time_at = GREATEST (a.newest_comment_time_at, diff.newest_comment_time_at),\n    newest_comment_time_necro_at = GREATEST (a.newest_comment_time_necro_at, diff.newest_comment_time_necro_at)\nFROM (\n    SELECT\n        post.id AS post_id,\n        coalesce(sum(count_diff), 0) AS comments,\n        -- Old rows are excluded using `count_diff = 1`\n        max((comment).published_at) FILTER (WHERE count_diff = 1) AS newest_comment_time_at,\n        max((comment).published_at) FILTER (WHERE count_diff = 1\n            -- Ignore comments from the post's creator\n            AND post.creator_id != (comment).creator_id\n        -- Ignore comments on old posts\n        AND post.published_at > ((comment).published_at - '2 days'::interval)) AS newest_comment_time_necro_at\nFROM\n    select_old_and_new_rows AS old_and_new_rows\n    LEFT JOIN post ON post.id = (comment).post_id\nWHERE\n    r.is_counted (comment)\nGROUP BY\n    post.id) AS diff\nWHERE\n    a.id = diff.post_id\n    AND (diff.comments,\n        GREATEST (a.newest_comment_time_at, diff.newest_comment_time_at),\n        GREATEST (a.newest_comment_time_necro_at, diff.newest_comment_time_necro_at)) != (0,\n        a.newest_comment_time_at,\n        a.newest_comment_time_necro_at);\nUPDATE\n    local_site AS a\nSET\n    comments = a.comments + diff.comments\nFROM (\n    SELECT\n        coalesce(sum(count_diff), 0) AS comments\n    FROM\n        select_old_and_new_rows AS old_and_new_rows\n    WHERE\n        r.is_counted (comment)\n        AND (comment).local) AS diff\nWHERE\n    diff.comments != 0;\nRETURN NULL;\nEND;\n$$);\nCALL r.create_triggers ('post', $$\nBEGIN\n    UPDATE\n        person AS a\n    SET\n        post_count = a.post_count + diff.post_count\n    FROM (\n        SELECT\n            (post).creator_id, coalesce(sum(count_diff), 0) AS post_count\n        FROM select_old_and_new_rows AS old_and_new_rows\n        WHERE\n            r.is_counted (post)\n        GROUP BY (post).creator_id) AS diff\nWHERE\n    a.id = diff.creator_id\n        AND diff.post_count != 0;\nUPDATE\n    community AS a\nSET\n    posts = a.posts + diff.posts,\n    comments = a.comments + diff.comments\nFROM (\n    SELECT\n        (post).community_id,\n        coalesce(sum(count_diff), 0) AS posts,\n        coalesce(sum(count_diff * (post).comments), 0) AS comments\n    FROM\n        select_old_and_new_rows AS old_and_new_rows\n    WHERE\n        r.is_counted (post)\n    GROUP BY\n        (post).community_id) AS diff\nWHERE\n    a.id = diff.community_id\n    AND (diff.posts,\n        diff.comments) != (0,\n        0);\nUPDATE\n    local_site AS a\nSET\n    posts = a.posts + diff.posts\nFROM (\n    SELECT\n        coalesce(sum(count_diff), 0) AS posts\n    FROM\n        select_old_and_new_rows AS old_and_new_rows\n    WHERE\n        r.is_counted (post)\n        AND (post).local) AS diff\nWHERE\n    diff.posts != 0;\nRETURN NULL;\nEND;\n$$);\nCALL r.create_triggers ('community', $$\nBEGIN\n    UPDATE\n        local_site AS a\n    SET\n        communities = a.communities + diff.communities\n    FROM (\n        SELECT\n            coalesce(sum(count_diff), 0) AS communities\n        FROM select_old_and_new_rows AS old_and_new_rows\n        WHERE\n            r.is_counted (community)\n            AND (community).local) AS diff\nWHERE\n    diff.communities != 0;\nRETURN NULL;\nEND;\n$$);\n-- Count subscribers for communities.\n-- subscribers should be updated only when a local community is followed by a local or remote person.\n-- subscribers_local should be updated only when a local person follows a local or remote community.\nCALL r.create_triggers ('community_actions', $$\nBEGIN\n    UPDATE\n        community AS a\n    SET\n        subscribers = a.subscribers + diff.subscribers, subscribers_local = a.subscribers_local + diff.subscribers_local\n    FROM (\n        SELECT\n            (community_actions).community_id, coalesce(sum(count_diff) FILTER (WHERE community.local), 0) AS subscribers, coalesce(sum(count_diff) FILTER (WHERE person.local), 0) AS subscribers_local\n        FROM select_old_and_new_rows AS old_and_new_rows\n        LEFT JOIN community ON community.id = (community_actions).community_id\n        LEFT JOIN person ON person.id = (community_actions).person_id\n        WHERE (community_actions).followed_at IS NOT NULL GROUP BY (community_actions).community_id) AS diff\nWHERE\n    a.id = diff.community_id\n        AND (diff.subscribers, diff.subscribers_local) != (0, 0);\nRETURN NULL;\nEND;\n$$);\nCALL r.create_triggers ('post_report', $$\nBEGIN\n    UPDATE\n        post AS a\n    SET\n        report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count\n    FROM (\n        SELECT\n            (post_report).post_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (post_report).resolved\n                AND NOT (post_report).violates_instance_rules), 0) AS unresolved_report_count\nFROM select_old_and_new_rows AS old_and_new_rows GROUP BY (post_report).post_id) AS diff\nWHERE (diff.report_count, diff.unresolved_report_count) != (0, 0)\nAND a.id = diff.post_id;\nRETURN NULL;\nEND;\n$$);\nCALL r.create_triggers ('comment_report', $$\nBEGIN\n    UPDATE\n        comment AS a\n    SET\n        report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count\n    FROM (\n        SELECT\n            (comment_report).comment_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (comment_report).resolved\n                AND NOT (comment_report).violates_instance_rules), 0) AS unresolved_report_count\nFROM select_old_and_new_rows AS old_and_new_rows GROUP BY (comment_report).comment_id) AS diff\nWHERE (diff.report_count, diff.unresolved_report_count) != (0, 0)\nAND a.id = diff.comment_id;\nRETURN NULL;\nEND;\n$$);\nCALL r.create_triggers ('community_report', $$\nBEGIN\n    UPDATE\n        community AS a\n    SET\n        report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count\n    FROM (\n        SELECT\n            (community_report).community_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (community_report).resolved), 0) AS unresolved_report_count\n    FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (community_report).community_id) AS diff\nWHERE (diff.report_count, diff.unresolved_report_count) != (0, 0)\n    AND a.id = diff.community_id;\nRETURN NULL;\nEND;\n$$);\n-- Change the order of some cascading deletions to make deletion triggers run before the deletion of rows that the triggers need to read\nCREATE FUNCTION r.delete_follow_before_person ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    DELETE FROM community_actions AS c\n    WHERE c.person_id = OLD.id;\n    RETURN OLD;\nEND;\n$$;\nCREATE TRIGGER delete_follow\n    BEFORE DELETE ON person\n    FOR EACH ROW\n    EXECUTE FUNCTION r.delete_follow_before_person ();\n-- Triggers that change values before insert or update\nCREATE FUNCTION r.comment_change_values ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    id text = NEW.id::text;\nBEGIN\n    -- Make `path` end with `id` if it doesn't already\n    IF NOT (NEW.path ~ ('*.' || id)::lquery) THEN\n        NEW.path = NEW.path || id;\n    END IF;\n    -- Set local ap_id\n    IF NEW.local THEN\n        NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/comment/' || id));\n    END IF;\n    RETURN NEW;\nEND\n$$;\nCREATE TRIGGER change_values\n    BEFORE INSERT OR UPDATE ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION r.comment_change_values ();\nCREATE FUNCTION r.post_change_values ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Set local ap_id\n    IF NEW.local THEN\n        NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/post/' || NEW.id::text));\n    END IF;\n    RETURN NEW;\nEND\n$$;\nCREATE TRIGGER change_values\n    BEFORE INSERT ON post\n    FOR EACH ROW\n    EXECUTE FUNCTION r.post_change_values ();\nCREATE FUNCTION r.private_message_change_values ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Set local ap_id\n    IF NEW.local THEN\n        NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/private_message/' || NEW.id::text));\n    END IF;\n    RETURN NEW;\nEND\n$$;\nCREATE TRIGGER change_values\n    BEFORE INSERT ON private_message\n    FOR EACH ROW\n    EXECUTE FUNCTION r.private_message_change_values ();\n-- Combined tables triggers\n-- These insert (published_at, item_id) into X_combined tables\n-- Reports (comment_report, post_report, private_message_report)\nCREATE PROCEDURE r.create_report_combined_trigger (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$ CREATE FUNCTION r.report_combined_thing_insert ( )\n            RETURNS TRIGGER\n            LANGUAGE plpgsql\n            AS $$\n            BEGIN\n                INSERT INTO report_combined (published_at, thing_id)\n                    VALUES (NEW.published_at, NEW.id);\n                RETURN NEW;\n            END $$;\n    CREATE FUNCTION r.report_combined_thing_update ( )\n        RETURNS TRIGGER\n        LANGUAGE plpgsql\n        AS $$\n        BEGIN\n            UPDATE\n                report_combined\n            SET\n                resolved = NEW.resolved\n            WHERE\n                thing_id = NEW.id;\n            RETURN NULL;\n        END $$;\n    CREATE TRIGGER report_combined_insert\n        AFTER INSERT ON thing\n        FOR EACH ROW\n        EXECUTE FUNCTION r.report_combined_thing_insert ( );\n        CREATE TRIGGER report_combined_update\n            AFTER UPDATE OF resolved ON thing\n            FOR EACH ROW\n            EXECUTE FUNCTION r.report_combined_thing_update ( );\n        $b$,\n        'thing',\n        table_name);\nEND;\n$a$;\nCALL r.create_report_combined_trigger ('post_report');\nCALL r.create_report_combined_trigger ('comment_report');\nCALL r.create_report_combined_trigger ('private_message_report');\nCALL r.create_report_combined_trigger ('community_report');\n-- person_content (comment, post)\nCREATE PROCEDURE r.create_person_content_combined_trigger (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$ CREATE FUNCTION r.person_content_combined_thing_insert ( )\n            RETURNS TRIGGER\n            LANGUAGE plpgsql\n            AS $$\n            BEGIN\n                INSERT INTO person_content_combined (published_at, thing_id, creator_id)\n                    VALUES (NEW.published_at, NEW.id, NEW.creator_id);\n                RETURN NEW;\n            END $$;\n    CREATE TRIGGER person_content_combined\n        AFTER INSERT ON thing\n        FOR EACH ROW\n        EXECUTE FUNCTION r.person_content_combined_thing_insert ( );\n        $b$,\n        'thing',\n        table_name);\nEND;\n$a$;\nCALL r.create_person_content_combined_trigger ('post');\nCALL r.create_person_content_combined_trigger ('comment');\n-- person_saved (comment, post)\n-- This one is a little different, because it triggers using x_actions.saved,\n-- Rather than any row insert\n-- TODO a hack because local is not currently on the post_view table\n-- https://github.com/LemmyNet/lemmy/pull/5616#discussion_r2064219628\nCREATE PROCEDURE r.create_person_saved_combined_trigger (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$ CREATE FUNCTION r.person_saved_combined_change_values_thing ( )\n            RETURNS TRIGGER\n            LANGUAGE plpgsql\n            AS $$\n            BEGIN\n                IF (TG_OP = 'DELETE') THEN\n                    DELETE FROM person_saved_combined AS p\n                    WHERE p.person_id = OLD.person_id\n                        AND p.thing_id = OLD.thing_id;\n                ELSIF (TG_OP = 'INSERT') THEN\n                    IF NEW.saved_at IS NOT NULL THEN\n                        INSERT INTO person_saved_combined (saved_at, person_id, thing_id, creator_id)\n                        SELECT\n                            NEW.saved_at,\n                            NEW.person_id,\n                            NEW.thing_id,\n                            t.creator_id\n                        FROM\n                            thing AS t\n                        WHERE\n                            t.id = NEW.thing_id;\n                    END IF;\n                ELSIF (TG_OP = 'UPDATE') THEN\n                    IF NEW.saved_at IS NOT NULL THEN\n                        INSERT INTO person_saved_combined (saved_at, person_id, thing_id, creator_id)\n                        SELECT\n                            NEW.saved_at,\n                            NEW.person_id,\n                            NEW.thing_id,\n                            t.creator_id\n                        FROM\n                            thing AS t\n                        WHERE\n                            t.id = NEW.thing_id;\n                        -- If saved gets set as null, delete the row\n                    ELSE\n                        DELETE FROM person_saved_combined AS p\n                        WHERE p.person_id = NEW.person_id\n                            AND p.thing_id = NEW.thing_id;\n                    END IF;\n                END IF;\n                RETURN NULL;\n            END $$;\n    CREATE TRIGGER person_saved_combined\n        AFTER INSERT OR DELETE OR UPDATE OF saved_at ON thing_actions\n        FOR EACH ROW\n        EXECUTE FUNCTION r.person_saved_combined_change_values_thing ( );\n    $b$,\n    'thing',\n    table_name);\nEND;\n$a$;\nCALL r.create_person_saved_combined_trigger ('post');\nCALL r.create_person_saved_combined_trigger ('comment');\n-- person_liked (comment, post)\n-- This one is a little different, because it triggers using x_actions.liked,\n-- Rather than any row insert\n-- TODO a hack because local is not currently on the post_view table\n-- https://github.com/LemmyNet/lemmy/pull/5616#discussion_r2064219628\nCREATE PROCEDURE r.create_person_liked_combined_trigger (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$ CREATE FUNCTION r.person_liked_combined_change_values_thing ( )\n            RETURNS TRIGGER\n            LANGUAGE plpgsql\n            AS $$\n            BEGIN\n                IF (TG_OP = 'DELETE') THEN\n                    DELETE FROM person_liked_combined AS p\n                    WHERE p.person_id = OLD.person_id\n                        AND p.thing_id = OLD.thing_id;\n                ELSIF (TG_OP = 'INSERT') THEN\n                    IF NEW.voted_at IS NOT NULL AND (\n                        SELECT\n                            local\n                        FROM\n                            person\n                        WHERE\n                            id = NEW.person_id) = TRUE THEN\n                        INSERT INTO person_liked_combined (voted_at, vote_is_upvote, person_id, thing_id, creator_id)\n                        SELECT\n                            NEW.voted_at,\n                            NEW.vote_is_upvote,\n                            NEW.person_id,\n                            NEW.thing_id,\n                            t.creator_id\n                        FROM\n                            thing AS t\n                        WHERE\n                            t.id = NEW.thing_id;\n                    END IF;\n                ELSIF (TG_OP = 'UPDATE') THEN\n                    IF NEW.voted_at IS NOT NULL AND (\n                        SELECT\n                            local\n                        FROM\n                            person\n                        WHERE\n                            id = NEW.person_id) = TRUE THEN\n                        -- Here we have uniques on (person_id, post_id) and (person_id, comment_id)\n                        INSERT INTO person_liked_combined (voted_at, vote_is_upvote, person_id, thing_id, creator_id)\n                        SELECT\n                            NEW.voted_at,\n                            NEW.vote_is_upvote,\n                            NEW.person_id,\n                            NEW.thing_id,\n                            t.creator_id\n                        FROM\n                            thing AS t\n                        WHERE\n                            t.id = NEW.thing_id\n                        ON CONFLICT (person_id,\n                            thing_id)\n                            DO UPDATE SET\n                                voted_at = NEW.voted_at,\n                                vote_is_upvote = NEW.vote_is_upvote;\n                        -- If liked gets set as null, delete the row\n                    ELSE\n                        DELETE FROM person_liked_combined AS p\n                        WHERE p.person_id = NEW.person_id\n                            AND p.thing_id = NEW.thing_id;\n                    END IF;\n                END IF;\n                RETURN NULL;\n            END $$;\n    CREATE TRIGGER person_liked_combined\n        AFTER INSERT OR DELETE OR UPDATE OF voted_at ON thing_actions\n        FOR EACH ROW\n        EXECUTE FUNCTION r.person_liked_combined_change_values_thing ( );\n    $b$,\n    'thing',\n    table_name);\nEND;\n$a$;\nCALL r.create_person_liked_combined_trigger ('post');\nCALL r.create_person_liked_combined_trigger ('comment');\n-- Prevent using delete instead of uplete on action tables\nCREATE FUNCTION r.require_uplete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF pg_trigger_depth() = 1 AND NOT starts_with (current_query(), '/**/') THEN\n        RAISE 'using delete instead of uplete is not allowed for this table';\n    END IF;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER require_uplete\n    BEFORE DELETE ON comment_actions\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION r.require_uplete ();\nCREATE TRIGGER require_uplete\n    BEFORE DELETE ON community_actions\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION r.require_uplete ();\nCREATE TRIGGER require_uplete\n    BEFORE DELETE ON instance_actions\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION r.require_uplete ();\nCREATE TRIGGER require_uplete\n    BEFORE DELETE ON person_actions\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION r.require_uplete ();\nCREATE TRIGGER require_uplete\n    BEFORE DELETE ON post_actions\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION r.require_uplete ();\n-- search: (post, comment, community, person, multi_community)\nCREATE PROCEDURE r.create_search_combined_trigger (table_name text)\nLANGUAGE plpgsql\nAS $a$\nBEGIN\n    EXECUTE replace($b$ CREATE FUNCTION r.search_combined_thing_insert ( )\n            RETURNS TRIGGER\n            LANGUAGE plpgsql\n            AS $$\n            BEGIN\n                -- TODO need to figure out how to do the other columns here\n                INSERT INTO search_combined (published_at, thing_id)\n                    VALUES (NEW.published_at, NEW.id);\n                RETURN NEW;\n            END $$;\n    CREATE TRIGGER search_combined\n        AFTER INSERT ON thing\n        FOR EACH ROW\n        EXECUTE FUNCTION r.search_combined_thing_insert ( );\n        $b$,\n        'thing',\n        table_name);\nEND;\n$a$;\nCALL r.create_search_combined_trigger ('post');\nCALL r.create_search_combined_trigger ('comment');\nCALL r.create_search_combined_trigger ('community');\nCALL r.create_search_combined_trigger ('person');\nCALL r.create_search_combined_trigger ('multi_community');\n-- You also need to triggers to update the `score` column.\n-- post | post::score\n-- comment | comment::score\n-- community | community::users_active_monthly\n-- person | person_aggregates::post_score\n-- multi-community | multi_community::subscribers\n--\n-- Post score\nCREATE FUNCTION r.search_combined_post_score_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        search_combined\n    SET\n        score = NEW.score\n    WHERE\n        post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER search_combined_post_score\n    AFTER UPDATE OF score ON post\n    FOR EACH ROW\n    EXECUTE FUNCTION r.search_combined_post_score_update ();\n-- Comment score\nCREATE FUNCTION r.search_combined_comment_score_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        search_combined\n    SET\n        score = NEW.score\n    WHERE\n        comment_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER search_combined_comment_score\n    AFTER UPDATE OF score ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION r.search_combined_comment_score_update ();\n-- Person score\nCREATE FUNCTION r.search_combined_person_score_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        search_combined\n    SET\n        score = NEW.post_score\n    WHERE\n        person_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER search_combined_person_score\n    AFTER UPDATE OF post_score ON person\n    FOR EACH ROW\n    EXECUTE FUNCTION r.search_combined_person_score_update ();\n-- Community score\nCREATE FUNCTION r.search_combined_community_score_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        search_combined\n    SET\n        score = NEW.users_active_month\n    WHERE\n        community_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER search_combined_community_score\n    AFTER UPDATE OF users_active_month ON community\n    FOR EACH ROW\n    EXECUTE FUNCTION r.search_combined_community_score_update ();\n-- Multi_community score\nCREATE FUNCTION r.search_combined_multi_community_score_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        search_combined\n    SET\n        score = NEW.subscribers\n    WHERE\n        multi_community_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER search_combined_multi_community_score\n    AFTER UPDATE OF subscribers ON multi_community\n    FOR EACH ROW\n    EXECUTE FUNCTION r.search_combined_multi_community_score_update ();\n-- Increment / decrement multi_community counts\nCREATE FUNCTION r.multicommunity_community_increment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        multi_community\n    SET\n        communities = communities + 1\n    WHERE\n        id = NEW.multi_community_id;\n    RETURN NULL;\nEND\n$$;\nCREATE FUNCTION r.multicommunity_community_decrement ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        multi_community\n    SET\n        communities = communities - 1\n    WHERE\n        id = OLD.multi_community_id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER multi_community_add_community\n    AFTER INSERT ON multi_community_entry\n    FOR EACH ROW\n    EXECUTE FUNCTION r.multicommunity_community_increment ();\nCREATE TRIGGER multi_community_remove_community\n    AFTER DELETE ON multi_community_entry\n    FOR EACH ROW\n    EXECUTE FUNCTION r.multicommunity_community_decrement ();\n-- Increment / decrement multi_community subscriber counts\nCREATE FUNCTION r.multicommunity_subscribers_increment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        multi_community AS m\n    SET\n        subscribers = subscribers + 1,\n        subscribers_local = CASE WHEN p.local THEN\n            subscribers_local + 1\n        ELSE\n            subscribers_local\n        END\n    FROM\n        person AS p\n    WHERE\n        m.id = NEW.multi_community_id\n        AND p.id = NEW.person_id;\n    RETURN NULL;\nEND\n$$;\nCREATE FUNCTION r.multicommunity_subscribers_decrement ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        multi_community AS m\n    SET\n        subscribers = subscribers - 1,\n        subscribers_local = CASE WHEN p.local THEN\n            subscribers_local - 1\n        ELSE\n            subscribers_local\n        END\n    FROM\n        person AS p\n    WHERE\n        m.id = OLD.multi_community_id\n        AND p.id = OLD.person_id;\n    RETURN NULL;\nEND\n$$;\nCREATE TRIGGER multi_community_update_add_subscribers\n    AFTER UPDATE OF follow_state ON multi_community_follow\n    FOR EACH ROW\n    WHEN (OLD.follow_state != 'Accepted' AND NEW.follow_state = 'Accepted')\n    EXECUTE FUNCTION r.multicommunity_subscribers_increment ();\nCREATE TRIGGER multi_community_update_remove_subscribers\n    AFTER UPDATE OF follow_state ON multi_community_follow\n    FOR EACH ROW\n    WHEN (OLD.follow_state = 'Accepted' AND NEW.follow_state != 'Accepted')\n    EXECUTE FUNCTION r.multicommunity_subscribers_decrement ();\nCREATE TRIGGER multi_community_add_subscribers\n    AFTER INSERT ON multi_community_follow\n    FOR EACH ROW\n    WHEN (NEW.follow_state = 'Accepted')\n    EXECUTE FUNCTION r.multicommunity_subscribers_increment ();\nCREATE TRIGGER multi_community_remove_subscribers\n    AFTER DELETE ON multi_community_follow\n    FOR EACH ROW\n    WHEN (OLD.follow_state = 'Accepted')\n    EXECUTE FUNCTION r.multicommunity_subscribers_decrement ();\n"
  },
  {
    "path": "crates/diesel_utils/replaceable_schema/utils.sql",
    "content": "-- Each calculation used in triggers should be a single SQL language\n-- expression so it can be inlined in migrations.\nCREATE FUNCTION r.controversy_rank (upvotes numeric, downvotes numeric)\n    RETURNS real\n    LANGUAGE sql\n    IMMUTABLE PARALLEL SAFE RETURN CASE WHEN downvotes <= 0\n        OR upvotes <= 0 THEN\n        0\n    ELSE\n        (\n            upvotes + downvotes) ^ CASE WHEN upvotes > downvotes THEN\n            downvotes::float / upvotes::float\n        ELSE\n            upvotes::float / downvotes::float\n    END\n    END;\n\nCREATE FUNCTION r.hot_rank (score numeric, published_at timestamp with time zone)\n    RETURNS real\n    LANGUAGE sql\n    IMMUTABLE PARALLEL SAFE RETURN\n    -- after a week, it will default to 0.\n    CASE WHEN (\nnow() - published_at) > '0 days'\n        AND (\nnow() - published_at) < '7 days' THEN\n        -- Use greatest(2,score), so that the hot_rank will be positive and not ignored.\n        log (\n            greatest (2, score + 2)) / power (((EXTRACT(EPOCH FROM (now() - published_at)) / 3600) + 2), 1.8)\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        0.0\n    END;\n\nCREATE FUNCTION r.scaled_rank (score numeric, published_at timestamp with time zone, interactions_month numeric)\n    RETURNS real\n    LANGUAGE sql\n    IMMUTABLE PARALLEL SAFE\n    -- Add 2 to avoid divide by zero errors\n    -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621\n    -- There may need to be a scale factor multiplied to interactions_month, to make\n    -- the log curve less pronounced. This can be tuned in the future.\n    RETURN (\n        r.hot_rank (score, published_at) / log(2 + interactions_month)\n);\n\n-- For tables with `deleted` and `removed` columns, this function determines which rows to include in a count.\nCREATE FUNCTION r.is_counted (item record)\n    RETURNS bool\n    LANGUAGE plpgsql\n    IMMUTABLE PARALLEL SAFE\n    AS $$\nBEGIN\n    RETURN COALESCE(NOT (item.deleted\n            OR item.removed), FALSE);\nEND;\n$$;\n\nCREATE FUNCTION r.local_url (url_path text)\n    RETURNS text\n    LANGUAGE sql\n    STABLE PARALLEL SAFE RETURN (\ncurrent_setting('lemmy.protocol_and_hostname') || url_path\n);\n\n-- This function creates statement-level triggers for all operation types. It's designed this way\n-- because of these limitations:\n--   * A trigger that uses transition tables can only handle 1 operation type.\n--   * Transition tables must be relevant for the operation type (for example, `NEW TABLE` is\n--     not allowed for a `DELETE` trigger)\n--   * Transition tables are only provided to the trigger function, not to functions that it calls.\n--\n-- This function can only be called once per table. The trigger function body is given as the 2nd argument\n-- and can contain these names, which are replaced with a `SELECT` statement in parenthesis if needed:\n--   * `select_old_rows`\n--   * `select_new_rows`\n--   * `select_old_and_new_rows` with 2 columns:\n--       1. `count_diff`: `-1` for old rows and `1` for new rows, which can be used with `sum` to get the number\n--          to add to a count\n--       2. (same name as the trigger's table): the old or new row as a composite value\nCREATE PROCEDURE r.create_triggers (table_name text, function_body text)\nLANGUAGE plpgsql\nAS $a$\nDECLARE\n    defs text := $$\n    -- Delete\n    CREATE FUNCTION r.thing_delete_statement ()\n        RETURNS TRIGGER\n        LANGUAGE plpgsql\n        AS function_body_delete;\n    CREATE TRIGGER delete_statement\n        AFTER DELETE ON thing REFERENCING OLD TABLE AS select_old_rows\n        FOR EACH STATEMENT\n        EXECUTE FUNCTION r.thing_delete_statement ( );\n    -- Insert\n    CREATE FUNCTION r.thing_insert_statement ( )\n        RETURNS TRIGGER\n        LANGUAGE plpgsql\n        AS function_body_insert;\n    CREATE TRIGGER insert_statement\n        AFTER INSERT ON thing REFERENCING NEW TABLE AS select_new_rows\n        FOR EACH STATEMENT\n        EXECUTE FUNCTION r.thing_insert_statement ( );\n    -- Update\n    CREATE FUNCTION r.thing_update_statement ( )\n        RETURNS TRIGGER\n        LANGUAGE plpgsql\n        AS function_body_update;\n    CREATE TRIGGER update_statement\n        AFTER UPDATE ON thing REFERENCING OLD TABLE AS select_old_rows NEW TABLE AS select_new_rows\n        FOR EACH STATEMENT\n        EXECUTE FUNCTION r.thing_update_statement ( );\n    $$;\n    select_old_and_new_rows text := $$ (\n        SELECT\n            -1 AS count_diff,\n            old_table::thing AS thing\n        FROM\n            select_old_rows AS old_table\n        UNION ALL\n        SELECT\n            1 AS count_diff,\n            new_table::thing AS thing\n        FROM\n            select_new_rows AS new_table) $$;\n    empty_select_new_rows text := $$ (\n        SELECT\n            *\n        FROM\n            -- Real transition table\n            select_old_rows\n        WHERE\n            FALSE) $$;\n    empty_select_old_rows text := $$ (\n        SELECT\n            *\n        FROM\n            -- Real transition table\n            select_new_rows\n        WHERE\n            FALSE) $$;\n    BEGIN\n        function_body := replace(function_body, 'select_old_and_new_rows', select_old_and_new_rows);\n        -- `select_old_rows` and `select_new_rows` are made available as empty tables if they don't already exist\n        defs := replace(defs, 'function_body_delete', quote_literal(replace(function_body, 'select_new_rows', empty_select_new_rows)));\n        defs := replace(defs, 'function_body_insert', quote_literal(replace(function_body, 'select_old_rows', empty_select_old_rows)));\n        defs := replace(defs, 'function_body_update', quote_literal(function_body));\n        defs := replace(defs, 'thing', table_name);\n        EXECUTE defs;\nEND;\n$a$;\n\n-- Edit community aggregates to include voters as active users\nCREATE OR REPLACE FUNCTION r.community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ integer,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*)::integer,\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published_at > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published_at > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        pa.person_id,\n        p.community_id\n    FROM\n        post_actions pa\n            INNER JOIN post p ON pa.post_id = p.id\n            INNER JOIN person pe ON pa.person_id = pe.id\n        WHERE\n            pa.voted_at > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        ca.person_id,\n        p.community_id\n    FROM\n        comment_actions ca\n            INNER JOIN comment c ON ca.comment_id = c.id\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON ca.person_id = pe.id\n        WHERE\n            ca.voted_at > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\n-- Community aggregate function for adding up total number of interactions\nCREATE OR REPLACE FUNCTION r.community_aggregates_interactions (i text)\n    RETURNS TABLE (\n        count_ integer,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        COALESCE(sum(comments + upvotes + downvotes)::integer, 0) AS count_,\n        community_id AS community_id_\n    FROM\n        post\n    WHERE\n        published_at >= (CURRENT_TIMESTAMP - i::interval)\n    GROUP BY\n        community_id;\nEND;\n$$;\n\n-- Edit site aggregates to include voters and people who have read posts as active users\nCREATE OR REPLACE FUNCTION r.site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published_at > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published_at > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            pa.person_id\n        FROM\n            post_actions pa\n            INNER JOIN person pe ON pa.person_id = pe.id\n        WHERE\n            pa.voted_at > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            ca.person_id\n        FROM\n            comment_actions ca\n            INNER JOIN person pe ON ca.person_id = pe.id\n        WHERE\n            ca.voted_at > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "crates/diesel_utils/src/connection.rs",
    "content": "use deadpool::Runtime;\nuse diesel::result::{\n  ConnectionError,\n  ConnectionResult,\n  Error::{self as DieselError, QueryBuilderError},\n};\nuse diesel_async::{\n  AsyncConnection,\n  pg::AsyncPgConnection,\n  pooled_connection::{\n    AsyncDieselConnectionManager,\n    ManagerConfig,\n    deadpool::{Hook, HookError, Object as PooledConnection, Pool},\n  },\n  scoped_futures::ScopedBoxFuture,\n};\nuse futures_util::{FutureExt, future::BoxFuture};\nuse lemmy_utils::{\n  error::{LemmyError, LemmyResult},\n  settings::SETTINGS,\n};\nuse rustls::{\n  ClientConfig,\n  DigitallySignedStruct,\n  SignatureScheme,\n  client::danger::{\n    DangerousClientConfigBuilder,\n    HandshakeSignatureValid,\n    ServerCertVerified,\n    ServerCertVerifier,\n  },\n  crypto::{self, verify_tls12_signature, verify_tls13_signature},\n  pki_types::{CertificateDer, ServerName, UnixTime},\n};\nuse std::{\n  ops::{Deref, DerefMut},\n  sync::Arc,\n  time::Duration,\n};\nuse tracing::error;\n\npub type ActualDbPool = Pool<AsyncPgConnection>;\n\n/// References a pool or connection. Functions must take `&mut DbPool<'_>` to allow implicit\n/// reborrowing.\n///\n/// https://github.com/rust-lang/rfcs/issues/1403\npub enum DbPool<'a> {\n  Pool(&'a ActualDbPool),\n  Conn(&'a mut AsyncPgConnection),\n}\n\npub enum DbConn<'a> {\n  Pool(PooledConnection<AsyncPgConnection>),\n  Conn(&'a mut AsyncPgConnection),\n}\n\npub async fn get_conn<'a, 'b: 'a>(pool: &'a mut DbPool<'b>) -> Result<DbConn<'a>, DieselError> {\n  Ok(match pool {\n    DbPool::Pool(pool) => DbConn::Pool(pool.get().await.map_err(|e| QueryBuilderError(e.into()))?),\n    DbPool::Conn(conn) => DbConn::Conn(conn),\n  })\n}\n\nimpl DbConn<'_> {\n  pub async fn run_transaction<'a, R, F>(&mut self, callback: F) -> LemmyResult<R>\n  where\n    F: for<'r> FnOnce(&'r mut AsyncPgConnection) -> ScopedBoxFuture<'a, 'r, LemmyResult<R>>\n      + Send\n      + 'a,\n    R: Send + 'a,\n  {\n    self\n      .deref_mut()\n      .transaction::<_, LemmyError, _>(callback)\n      .await\n  }\n}\n\nimpl Deref for DbConn<'_> {\n  type Target = AsyncPgConnection;\n\n  fn deref(&self) -> &Self::Target {\n    match self {\n      DbConn::Pool(conn) => conn.deref(),\n      DbConn::Conn(conn) => conn.deref(),\n    }\n  }\n}\n\nimpl DerefMut for DbConn<'_> {\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    match self {\n      DbConn::Pool(conn) => conn.deref_mut(),\n      DbConn::Conn(conn) => conn.deref_mut(),\n    }\n  }\n}\n\n// Allows functions that take `DbPool<'_>` to be called in a transaction by passing `&mut\n// conn.into()`\nimpl<'a> From<&'a mut AsyncPgConnection> for DbPool<'a> {\n  fn from(value: &'a mut AsyncPgConnection) -> Self {\n    DbPool::Conn(value)\n  }\n}\n\nimpl<'a, 'b: 'a> From<&'a mut DbConn<'b>> for DbPool<'a> {\n  fn from(value: &'a mut DbConn<'b>) -> Self {\n    DbPool::Conn(value.deref_mut())\n  }\n}\n\nimpl<'a> From<&'a ActualDbPool> for DbPool<'a> {\n  fn from(value: &'a ActualDbPool) -> Self {\n    DbPool::Pool(value)\n  }\n}\n\n/// Runs multiple async functions that take `&mut DbPool<'_>` as input and return `Result`. Only\n/// works when the  `futures` crate is listed in `Cargo.toml`.\n///\n/// `$pool` is the value given to each function.\n///\n/// A `Result` is returned (not in a `Future`, so don't use `.await`). The `Ok` variant contains a\n/// tuple with the values returned by the given functions.\n///\n/// The functions run concurrently if `$pool` has the `DbPool::Pool` variant.\n#[macro_export]\nmacro_rules! try_join_with_pool {\n  ($pool:ident => ($($func:expr),+)) => {{\n    // Check type\n    let _: &mut $crate::connection::DbPool<'_> = $pool;\n\n    match $pool {\n      // Run concurrently with `try_join`\n      $crate::connection::DbPool::Pool(__pool) => ::futures_util::try_join!(\n        $(async {\n          let mut __dbpool = $crate::connection::DbPool::Pool(__pool);\n          ($func)(&mut __dbpool).await\n        }),+\n      ),\n      // Run sequentially\n      $crate::connection::DbPool::Conn(__conn) => async {\n        Ok(($({\n          let mut __dbpool = $crate::connection::DbPool::Conn(__conn);\n          // `?` prevents the error type from being inferred in an `async` block, so `match` is used instead\n          match ($func)(&mut __dbpool).await {\n            ::core::result::Result::Ok(__v) => __v,\n            ::core::result::Result::Err(__v) => return ::core::result::Result::Err(__v),\n          }\n        }),+))\n      }.await,\n    }\n  }};\n}\n\npub fn build_db_pool() -> LemmyResult<ActualDbPool> {\n  let db_url = SETTINGS.get_database_url_with_options()?;\n  // diesel-async does not support any TLS connections out of the box, so we need to manually\n  // provide a setup function which handles creating the connection\n  let mut config = ManagerConfig::default();\n  config.custom_setup = Box::new(establish_connection);\n  let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(&db_url, config);\n\n  // Don't allow pool sizes below 2. See https://github.com/LemmyNet/lemmy/issues/5112\n  let pool_size = std::cmp::max(SETTINGS.database.pool_size, 2);\n\n  let pool = Pool::builder(manager)\n    .runtime(Runtime::Tokio1)\n    .max_size(pool_size)\n    .wait_timeout(Some(Duration::from_secs(1)))\n    .create_timeout(Some(Duration::from_secs(5)))\n    .recycle_timeout(Some(Duration::from_secs(5)))\n    // Limit connection age to prevent use of prepared statements that have query plans based on\n    // very old statistics\n    .pre_recycle(Hook::sync_fn(|_conn, metrics| {\n      // Preventing the first recycle can cause an infinite loop when trying to get a new connection\n      // from the pool\n      let conn_was_used = metrics.recycled.is_some();\n      if metrics.age() > Duration::from_secs(3 * 24 * 60 * 60) && conn_was_used {\n        Err(HookError::Message(\"Connection is too old\".into()))\n      } else {\n        Ok(())\n      }\n    }))\n    .build()?;\n\n  crate::schema_setup::run(crate::schema_setup::Options::default().run(), &db_url)?;\n\n  Ok(pool)\n}\n\n#[expect(clippy::expect_used)]\npub fn build_db_pool_for_tests() -> ActualDbPool {\n  build_db_pool().expect(\"db pool missing\")\n}\n\nfn establish_connection(config: &str) -> BoxFuture<'_, ConnectionResult<AsyncPgConnection>> {\n  let fut = async {\n    // We only support TLS with sslmode=require currently\n    let conn = if config.contains(\"sslmode=require\") {\n      let rustls_config = DangerousClientConfigBuilder {\n        cfg: ClientConfig::builder(),\n      }\n      .with_custom_certificate_verifier(Arc::new(NoCertVerifier {}))\n      .with_no_client_auth();\n\n      let tls = tokio_postgres_rustls::MakeRustlsConnect::new(rustls_config);\n      let (client, conn) = tokio_postgres::connect(config, tls)\n        .await\n        .map_err(|e| ConnectionError::BadConnection(e.to_string()))?;\n      tokio::spawn(async move {\n        if let Err(e) = conn.await {\n          error!(\"Database connection failed: {e}\");\n        }\n      });\n      AsyncPgConnection::try_from(client).await?\n    } else {\n      AsyncPgConnection::establish(config).await?\n    };\n\n    Ok(conn)\n  };\n  fut.boxed()\n}\n\n#[derive(Debug)]\nstruct NoCertVerifier {}\n\nimpl ServerCertVerifier for NoCertVerifier {\n  fn verify_server_cert(\n    &self,\n    _end_entity: &CertificateDer,\n    _intermediates: &[CertificateDer],\n    _server_name: &ServerName,\n    _ocsp: &[u8],\n    _now: UnixTime,\n  ) -> Result<ServerCertVerified, rustls::Error> {\n    // Will verify all (even invalid) certs without any checks (sslmode=require)\n    Ok(ServerCertVerified::assertion())\n  }\n\n  fn verify_tls12_signature(\n    &self,\n    message: &[u8],\n    cert: &CertificateDer,\n    dss: &DigitallySignedStruct,\n  ) -> Result<HandshakeSignatureValid, rustls::Error> {\n    verify_tls12_signature(\n      message,\n      cert,\n      dss,\n      &crypto::ring::default_provider().signature_verification_algorithms,\n    )\n  }\n\n  fn verify_tls13_signature(\n    &self,\n    message: &[u8],\n    cert: &CertificateDer,\n    dss: &DigitallySignedStruct,\n  ) -> Result<HandshakeSignatureValid, rustls::Error> {\n    verify_tls13_signature(\n      message,\n      cert,\n      dss,\n      &crypto::ring::default_provider().signature_verification_algorithms,\n    )\n  }\n\n  fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n    crypto::ring::default_provider()\n      .signature_verification_algorithms\n      .supported_schemes()\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/dburl.rs",
    "content": "#[cfg(feature = \"full\")]\nuse activitypub_federation::{\n  fetch::{collection_id::CollectionId, object_id::ObjectId},\n  traits::{Collection, Object},\n};\n#[cfg(feature = \"full\")]\nuse diesel::{\n  backend::Backend,\n  deserialize::{FromSql, FromSqlRow},\n  expression::AsExpression,\n  pg::Pg,\n  serialize::{Output, ToSql},\n  sql_types::Text,\n};\nuse serde::{Deserialize, Serialize};\nuse std::{\n  fmt::{Display, Formatter},\n  ops::Deref,\n};\nuse url::Url;\n\n#[repr(transparent)]\n#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)]\n#[cfg_attr(feature = \"full\", derive(AsExpression, FromSqlRow))]\n#[cfg_attr(feature = \"full\", diesel(sql_type = diesel::sql_types::Text))]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct DbUrl(pub Box<Url>);\n\nimpl DbUrl {\n  pub fn to_lowercase(&self) -> String {\n    self.as_str().to_lowercase()\n  }\n}\n\nimpl DbUrl {\n  pub fn inner(&self) -> &Url {\n    &self.0\n  }\n}\n\nimpl Display for DbUrl {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    self.clone().0.fmt(f)\n  }\n}\n\n// the project doesn't compile with From\n#[expect(clippy::from_over_into)]\nimpl Into<DbUrl> for Url {\n  fn into(self) -> DbUrl {\n    DbUrl(Box::new(self))\n  }\n}\n#[expect(clippy::from_over_into)]\nimpl Into<Url> for DbUrl {\n  fn into(self) -> Url {\n    *self.0\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<T> From<DbUrl> for ObjectId<T>\nwhere\n  T: Object + Send + 'static,\n  for<'de2> <T as Object>::Kind: Deserialize<'de2>,\n{\n  fn from(value: DbUrl) -> Self {\n    let url: Url = value.into();\n    ObjectId::from(url)\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<T> From<DbUrl> for CollectionId<T>\nwhere\n  T: Collection + Send + 'static,\n  for<'de2> <T as Collection>::Kind: Deserialize<'de2>,\n{\n  fn from(value: DbUrl) -> Self {\n    let url: Url = value.into();\n    CollectionId::from(url)\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<T> From<CollectionId<T>> for DbUrl\nwhere\n  T: Collection,\n  for<'de2> <T as Collection>::Kind: Deserialize<'de2>,\n{\n  fn from(value: CollectionId<T>) -> Self {\n    let url: Url = value.into();\n    url.into()\n  }\n}\n\nimpl Deref for DbUrl {\n  type Target = Url;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl ToSql<Text, Pg> for DbUrl {\n  fn to_sql(&self, out: &mut Output<Pg>) -> diesel::serialize::Result {\n    <std::string::String as ToSql<Text, Pg>>::to_sql(&self.0.to_string(), &mut out.reborrow())\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<DB: Backend> FromSql<Text, DB> for DbUrl\nwhere\n  String: FromSql<Text, DB>,\n{\n  fn from_sql(value: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {\n    let str = String::from_sql(value)?;\n    Ok(DbUrl(Box::new(Url::parse(&str)?)))\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<Kind> From<ObjectId<Kind>> for DbUrl\nwhere\n  Kind: Object + Send + 'static,\n  for<'de2> <Kind as Object>::Kind: serde::Deserialize<'de2>,\n{\n  fn from(id: ObjectId<Kind>) -> Self {\n    DbUrl(Box::new(id.into()))\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/lib.rs",
    "content": "#[cfg(feature = \"full\")]\npub mod connection;\npub mod dburl;\npub mod pagination;\n#[cfg(feature = \"full\")]\npub mod schema_setup;\npub mod sensitive;\n#[cfg(feature = \"full\")]\npub mod traits;\n#[cfg(feature = \"full\")]\npub mod utils;\n"
  },
  {
    "path": "crates/diesel_utils/src/main.rs",
    "content": "/// Very minimal wrapper around `lemmy_diesel_utils::run` to allow running migrations without\n/// compiling everything.\nfn main() -> anyhow::Result<()> {\n  if std::env::args().len() > 1 {\n    anyhow::bail!(\"To set parameters for running migrations, use the lemmy_server command.\");\n  }\n\n  lemmy_diesel_utils::schema_setup::run(\n    lemmy_diesel_utils::schema_setup::Options::default().run(),\n    &std::env::var(\"LEMMY_DATABASE_URL\")?,\n  )?;\n\n  Ok(())\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/pagination.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_with::skip_serializing_none;\nuse std::{\n  ops::{Deref, DerefMut},\n  sync::LazyLock,\n};\n#[cfg(feature = \"full\")]\nuse {\n  crate::connection::DbPool,\n  base64::{\n    Engine,\n    alphabet::Alphabet,\n    engine::{GeneralPurpose, general_purpose::NO_PAD},\n  },\n  i_love_jesus::{PaginatedQueryBuilder, SortDirection},\n  itertools::Itertools,\n  lemmy_utils::error::LemmyErrorType,\n  lemmy_utils::error::LemmyResult,\n};\n\n/// Use base 64 engine with custom alphabet based on base64::engine::general_purpose::URL_SAFE\n/// with randomized character order, to prevent clients from parsing or modifying cursor data.\n#[cfg(feature = \"full\")]\n#[expect(clippy::expect_used)]\nstatic BASE64_ENGINE: LazyLock<GeneralPurpose> = LazyLock::new(|| {\n  let alphabet = Alphabet::new(\"AphruVFwvCetlckdZ2g-foxXBGNbyHnD96qUj3KL_YsE7P1OQiaIR0z4T58mMWJS\")\n    .expect(\"create base64 alphabet\");\n  GeneralPurpose::new(&alphabet, NO_PAD)\n});\n\n#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]\npub struct CursorData(String);\n\n#[cfg(feature = \"full\")]\nimpl CursorData {\n  pub fn new_id(id: i32) -> Self {\n    Self(id.to_string())\n  }\n  pub fn id(self) -> LemmyResult<i32> {\n    Ok(self.0.parse()?)\n  }\n\n  pub fn new_with_prefix(prefix: char, id: i32) -> Self {\n    Self(format!(\"{prefix},{id}\"))\n  }\n  pub fn id_and_prefix(self) -> LemmyResult<(char, i32)> {\n    let (prefix, id) = self\n      .0\n      .split_once(',')\n      .ok_or(LemmyErrorType::CouldntParsePaginationToken)?;\n    let prefix = prefix\n      .chars()\n      .next()\n      .ok_or(LemmyErrorType::CouldntParsePaginationToken)?;\n    Ok((prefix, id.parse()?))\n  }\n\n  pub fn new_plain(data: String) -> Self {\n    Self(data)\n  }\n  pub fn plain(self) -> String {\n    self.0\n  }\n\n  pub fn new_multi<const N: usize>(data: [i32; N]) -> Self {\n    Self(data.into_iter().join(\",\"))\n  }\n  pub fn multi<const N: usize>(self) -> LemmyResult<[i32; N]> {\n    Ok(\n      self\n        .0\n        .split(\",\")\n        .flat_map(|id| id.parse::<i32>().ok())\n        .collect::<Vec<_>>()\n        .try_into()\n        .map_err(|_e| LemmyErrorType::CouldntParsePaginationToken)?,\n    )\n  }\n}\n\n#[cfg(feature = \"full\")]\npub trait PaginationCursorConversion {\n  type PaginatedType: Send;\n\n  fn to_cursor(&self) -> CursorData;\n\n  fn from_cursor(\n    cursor: CursorData,\n    pool: &mut DbPool<'_>,\n  ) -> impl Future<Output = LemmyResult<Self::PaginatedType>> + Send;\n\n  /// Paginate a db query.\n  fn paginate<Q: Send>(\n    query: Q,\n    cursor: &Option<PaginationCursor>,\n    sort_direction: SortDirection,\n    pool: &mut DbPool<'_>,\n    // this is only used by PostView for optimization\n    page_before_or_equal: Option<Self::PaginatedType>,\n  ) -> impl std::future::Future<Output = LemmyResult<PaginatedQueryBuilder<Self::PaginatedType, Q>>> + Send\n  {\n    async move {\n      let (page_after, page_back, recovery) = if let Some(cursor) = cursor {\n        let internal = cursor.clone().into_internal()?;\n        let object = Self::from_cursor(internal.data, pool).await?;\n        (Some(object), Some(internal.back), internal.recovery)\n      } else {\n        (None, None, false)\n      };\n      let mut query = PaginatedQueryBuilder::new(query, sort_direction);\n\n      if page_back.unwrap_or_default() {\n        if recovery {\n          query = query.before_or_equal(page_after);\n        } else {\n          query = query.before(page_after);\n        }\n      } else if recovery {\n        query = query.after_or_equal(page_after);\n      } else {\n        query = query.after(page_after);\n      }\n\n      if page_back.unwrap_or_default() {\n        query = query\n          .after_or_equal(page_before_or_equal)\n          .limit_and_offset_from_end();\n      } else {\n        query = query.before_or_equal(page_before_or_equal);\n      }\n\n      Ok(query)\n    }\n  }\n}\n\n/// To get the next or previous page, pass this string unchanged as `page_cursor` in a new request\n/// to the same endpoint.\n///\n/// Do not attempt to parse or modify the cursor string. The format is internal and may change in\n/// minor Lemmy versions.\n#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PaginationCursor(String);\n\n#[cfg(feature = \"full\")]\nimpl PaginationCursor {\n  fn into_internal(self) -> LemmyResult<PaginationCursorInternal> {\n    let decoded = BASE64_ENGINE.decode(self.0)?;\n    Ok(serde_urlencoded::from_str(&String::from_utf8(decoded)?)?)\n  }\n  fn from_internal(other: PaginationCursorInternal) -> LemmyResult<Self> {\n    let encoded = BASE64_ENGINE.encode(serde_urlencoded::to_string(other)?);\n    Ok(Self(encoded))\n  }\n\n  // only used for PostView optimization\n  pub fn is_back(self) -> LemmyResult<bool> {\n    Ok(self.into_internal()?.back)\n  }\n}\n\n/// The actual data which is stored inside a cursor, not accessible outside this file.\n/// Uses serde rename to keep the cursor string short.\n#[skip_serializing_none]\n#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]\nstruct PaginationCursorInternal {\n  #[serde(rename = \"b\")]\n  back: bool,\n  #[serde(rename = \"d\")]\n  data: CursorData,\n  #[serde(rename = \"r\")]\n  /// Allows to recover from empty pages without skipping an item by including the pointed to item\n  /// in responses.\n  recovery: bool,\n}\n\n/// This response contains only a single page of items. To get the next page, take the\n/// cursor string from `next_page` and pass it to the same API endpoint via `page_cursor`\n/// parameter. For going to the previous page, use `prev_page` instead.\n#[skip_serializing_none]\n#[derive(Debug, Serialize, Deserialize, Clone)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct PagedResponse<#[cfg(feature = \"ts-rs\")] T: ts_rs::TS, #[cfg(not(feature = \"ts-rs\"))] T> {\n  pub items: Vec<T>,\n  pub next_page: Option<PaginationCursor>,\n  pub prev_page: Option<PaginationCursor>,\n}\n\n#[cfg(feature = \"full\")]\nimpl<#[cfg(feature = \"ts-rs\")] T: ts_rs::TS, #[cfg(not(feature = \"ts-rs\"))] T> Deref\n  for PagedResponse<T>\n{\n  type Target = Vec<T>;\n  fn deref(&self) -> &Vec<T> {\n    &self.items\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<#[cfg(feature = \"ts-rs\")] T: ts_rs::TS, #[cfg(not(feature = \"ts-rs\"))] T> DerefMut\n  for PagedResponse<T>\n{\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    &mut self.items\n  }\n}\n\n#[cfg(feature = \"full\")]\nimpl<#[cfg(feature = \"ts-rs\")] T: ts_rs::TS, #[cfg(not(feature = \"ts-rs\"))] T> IntoIterator\n  for PagedResponse<T>\n{\n  type Item = T;\n  type IntoIter = std::vec::IntoIter<Self::Item>;\n\n  // Required method\n  fn into_iter(self) -> Self::IntoIter {\n    self.items.into_iter()\n  }\n}\n\n/// Add prev/next cursors to query result.\n#[cfg(feature = \"full\")]\n// https://github.com/rust-lang/rust/issues/115590\n#[expect(clippy::multiple_bound_locations)]\npub fn paginate_response<#[cfg(feature = \"ts-rs\")] T: ts_rs::TS, #[cfg(not(feature = \"ts-rs\"))] T>(\n  data: Vec<T>,\n  limit: i64,\n  request_cursor: Option<PaginationCursor>,\n) -> LemmyResult<PagedResponse<T>>\nwhere\n  T: PaginationCursorConversion + Serialize + for<'a> Deserialize<'a>,\n{\n  let make_cursor = |item: Option<&T>, back: bool| -> LemmyResult<Option<PaginationCursor>> {\n    if let Some(item) = item {\n      let data = item.to_cursor();\n      let cursor = PaginationCursorInternal {\n        data,\n        back,\n        recovery: false,\n      };\n      Ok(Some(PaginationCursor::from_internal(cursor)?))\n    } else {\n      Ok(None)\n    }\n  };\n  let mut prev_page = make_cursor(data.first(), true)?;\n  let mut next_page = make_cursor(data.last(), false)?;\n\n  if let Ok(request_cursor) = &request_cursor\n    .map(PaginationCursor::into_internal)\n    .transpose()\n  {\n    // Need to convert here because diesel takes i64 for limit while vec length is usize.\n    let limit: usize = limit.try_into().unwrap_or_default();\n    // Hide next and back buttons when possible.\n    let back = request_cursor.as_ref().map(|r| r.back);\n    match (data.len() < limit, back) {\n      (false, None) => {\n        prev_page = None; // no page before first\n      }\n      (true, None) => {\n        prev_page = None; // no page before first\n        next_page = None;\n      }\n      (true, Some(true)) => {\n        prev_page = None;\n      }\n      (true, Some(false)) => {\n        next_page = None;\n      }\n      (false, Some(_)) => {}\n    };\n\n    // When a page_cursor points to the very last or first item, the response for that cursor\n    // contains no items and therefore ordinarily no cursors. Simply changing the direction of the\n    // request_cursor would allow users to escape these empty pages, but would skip the item that\n    // the cursor points to. Marking the cursor as recovery cursor allows to include this item, and\n    // as long as the list remains unchanged, to recover at the start or end of the list. The\n    // easiest way to reproduce this is to press next on the first page, then back twice.\n    if data.is_empty()\n      && let Some(PaginationCursorInternal {\n        back,\n        data,\n        recovery: false,\n      }) = request_cursor\n    {\n      if *back {\n        next_page = Some(PaginationCursor::from_internal(PaginationCursorInternal {\n          back: false,\n          data: data.clone(),\n          recovery: true,\n        })?);\n      } else {\n        prev_page = Some(PaginationCursor::from_internal(PaginationCursorInternal {\n          back: true,\n          data: data.clone(),\n          recovery: true,\n        })?);\n      }\n    }\n  }\n  Ok(PagedResponse {\n    items: data,\n    next_page,\n    prev_page,\n  })\n}\n\n#[cfg(test)]\nmod test {\n  use super::*;\n\n  #[test]\n  fn test_cursor() -> LemmyResult<()> {\n    let data = CursorData::new_id(1);\n    do_test_cursor(data)?;\n\n    let data = CursorData::new_multi([1, 2]);\n    do_test_cursor(data)?;\n\n    Ok(())\n  }\n\n  fn do_test_cursor(data: CursorData) -> LemmyResult<()> {\n    let cursor = PaginationCursorInternal {\n      back: true,\n      data: data.clone(),\n      recovery: false,\n    };\n    let encoded = PaginationCursor::from_internal(cursor.clone())?;\n    let cursor2 = encoded.into_internal()?;\n    assert_eq!(cursor, cursor2);\n    assert_eq!(data, cursor2.data);\n    Ok(())\n  }\n\n  #[test]\n  fn test_internal_format() -> LemmyResult<()> {\n    assert_eq!(\n      serde_urlencoded::to_string(PaginationCursorInternal {\n        back: true,\n        data: CursorData::new_plain(\"test\".into()),\n        recovery: false,\n      })?,\n      \"b=true&d=test&r=false\"\n    );\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/schema_setup/diff_check.rs",
    "content": "#![cfg(test)]\n#![expect(clippy::expect_used)]\nuse itertools::Itertools;\nuse lemmy_utils::settings::SETTINGS;\nuse pathfinding::matrix::Matrix;\nuse std::{\n  borrow::Cow,\n  io::Write,\n  process::{Command, Stdio},\n};\n\n// It's not possible to call `export_snapshot()` for each dump and run the dumps in parallel with\n// the `--snapshot` flag. Don't waste your time!!!!\n\n/// Returns almost all things currently in the database, represented as SQL statements that would\n/// recreate them.\npub(crate) fn get_dump() -> String {\n  let db_url = SETTINGS.get_database_url();\n  let output = Command::new(\"pg_dump\")\n    .args([\n      // Specify database URL\n      \"--dbname\",\n      &db_url,\n      // Allow differences in row data and old fast tables\n      \"--schema-only\",\n      \"--exclude-table=comment_aggregates_fast\",\n      \"--exclude-table=community_aggregates_fast\",\n      \"--exclude-table=post_aggregates_fast\",\n      \"--exclude-table=user_fast\",\n      // Ignore some things to reduce the amount of queries done by pg_dump\n      \"--no-owner\",\n      \"--no-privileges\",\n      \"--no-comments\",\n      \"--no-publications\",\n      \"--no-security-labels\",\n      \"--no-subscriptions\",\n      \"--no-table-access-method\",\n      \"--no-tablespaces\",\n      \"--no-large-objects\",\n      // Use a fake restrict key, rather than an auto-generated one.\n      // See https://github.com/sqlc-dev/sqlc/issues/4065\n      \"--restrict-key\",\n      \"empty\",\n    ])\n    .stderr(Stdio::inherit())\n    .output()\n    .expect(\"failed to start pg_dump process\");\n\n  if !output.status.success() {\n    std::io::stdout()\n      .write_all(&output.stdout)\n      .expect(\"write to stdout\");\n    std::process::exit(1);\n  }\n\n  String::from_utf8(output.stdout).expect(\"pg_dump output is not valid UTF-8 text\")\n}\n\n/// Checks dumps returned by [`get_dump`] and panics if they differ in a way that indicates a\n/// mistake in whatever was run in between the dumps.\n///\n/// The panic message shows `label_of_change_from_0_to_1` and a diff from `dumps[0]` to `dumps[1]`.\n/// For example, if something only exists in `dumps[1]`, then the diff represents the addition of\n/// that thing.\n///\n/// `label_of_change_from_0_to_1` must say something about the change from `dumps[0]` to `dumps[1]`,\n/// not `dumps[1]` to `dumps[0]`. This requires the two `dumps` elements being in an order that fits\n/// with `label_of_change_from_0_to_1`. This does not necessarily match the order in which the dumps\n/// were created.\npub(crate) fn check_dump_diff(dumps: [&str; 2], label_of_change_from_0_to_1: &str) {\n  let [sorted_statements_in_0, sorted_statements_in_1] = dumps.map(|dump| {\n    dump\n      .split(\"\\n\\n\")\n      .map(str::trim_start)\n      .filter(|&chunk| !(is_ignored_trigger(chunk) || is_view(chunk) || is_comment(chunk)))\n      .map(remove_ignored_uniqueness_from_statement)\n      .sorted_unstable()\n      .collect::<Vec<_>>()\n  });\n  let mut statements_only_in_0 = Vec::new();\n  let mut statements_only_in_1 = Vec::new();\n  for diff in diff::slice(&sorted_statements_in_0, &sorted_statements_in_1) {\n    match diff {\n      diff::Result::Left(statement) => statements_only_in_0.push(&**statement),\n      diff::Result::Right(statement) => statements_only_in_1.push(&**statement),\n      diff::Result::Both(_, _) => {}\n    }\n  }\n\n  if !(statements_only_in_0.is_empty() && statements_only_in_1.is_empty()) {\n    let (a, b): (String, String) = select_pairs([&statements_only_in_0, &statements_only_in_1])\n      .flat_map(|[a, b]| [(a, b), (\"\\n\\n\", \"\\n\\n\")])\n      .unzip();\n    let diff = unified_diff::diff(a.as_bytes(), \"\", b.as_bytes(), \"\", 10000);\n    panic!(\n      \"{label_of_change_from_0_to_1}\\n\\n{}\",\n      String::from_utf8_lossy(&diff)\n    );\n  }\n}\n\nfn is_ignored_trigger(chunk: &str) -> bool {\n  [\n    \"refresh_comment_like\",\n    \"refresh_comment\",\n    \"refresh_community_follower\",\n    \"refresh_community_user_ban\",\n    \"refresh_community\",\n    \"refresh_post_like\",\n    \"refresh_post\",\n    \"refresh_private_message\",\n    \"refresh_user\",\n  ]\n  .into_iter()\n  .any(|trigger_name| {\n    [(\"CREATE FUNCTION public.\", '('), (\"CREATE TRIGGER \", ' ')]\n      .into_iter()\n      .any(|(before, after)| {\n        chunk\n          .strip_prefix(before)\n          .and_then(|s| s.strip_prefix(trigger_name))\n          .is_some_and(|s| s.starts_with(after))\n      })\n  })\n}\n\nfn is_view(chunk: &str) -> bool {\n  [\n    \"CREATE VIEW \",\n    \"CREATE OR REPLACE VIEW \",\n    \"CREATE MATERIALIZED VIEW \",\n  ]\n  .into_iter()\n  .any(|prefix| chunk.starts_with(prefix))\n}\n\nfn is_comment(s: &str) -> bool {\n  s.lines().all(|line| line.starts_with(\"--\"))\n}\n\nfn remove_ignored_uniqueness_from_statement(statement: &str) -> Cow<'_, str> {\n  // Sort column names, so differences in column order are ignored\n  if statement.starts_with(\"CREATE TABLE \") {\n    let mut lines = statement\n      .lines()\n      .map(|line| line.strip_suffix(',').unwrap_or(line))\n      .collect::<Vec<_>>();\n\n    sort_within_sections(&mut lines, |line| {\n      match line.chars().next() {\n        // CREATE\n        Some('C') => 0,\n        // Indented column name\n        Some(' ') => 1,\n        // End of column list\n        Some(')') => 2,\n        _ => panic!(\"unrecognized part of `CREATE TABLE` statement: {line}\"),\n      }\n    });\n\n    Cow::Owned(lines.join(\"\\n\"))\n  } else {\n    Cow::Borrowed(statement)\n  }\n}\n\nfn sort_within_sections<T: Ord + ?Sized>(vec: &mut [&T], mut section: impl FnMut(&T) -> u8) {\n  vec.sort_unstable_by_key(|&i| (section(i), i));\n}\n\n/// For each string in list 0, makes a guess of which string in list 1 is a variant of it (or vice\n/// versa).\nfn select_pairs<'a>([a, b]: [&'a [&'a str]; 2]) -> impl Iterator<Item = [&'a str; 2]> {\n  let len = std::cmp::max(a.len(), b.len());\n  let get_candidate_pair_at =\n    |(row, column)| [a.get(row), b.get(column)].map(|item| *item.unwrap_or(&\"\"));\n  let difference_amounts = Matrix::from_fn(len, len, |position| {\n    amount_of_difference_between(get_candidate_pair_at(position))\n  });\n  pathfinding::kuhn_munkres::kuhn_munkres_min(&difference_amounts)\n    .1\n    .into_iter()\n    .enumerate()\n    .map(get_candidate_pair_at)\n}\n\n/// Computes string distance, using the already required [`diff`] crate to avoid adding another\n/// dependency.\nfn amount_of_difference_between([a, b]: [&str; 2]) -> isize {\n  diff::chars(a, b)\n    .into_iter()\n    .filter(|i| !matches!(i, diff::Result::Both(_, _)))\n    .fold(0, |count, _| count.saturating_add(1))\n}\n\n/// Makes sure the after dump does not contain any DEFERRABLE constraints.\npub(crate) fn deferr_constraint_check(dump: &str) {\n  if dump.contains(\" DEFERR\") {\n    panic!(\"Schema should not have DEFER constraints.\")\n  }\n}\n\n// `#[cfg(test)]` would be redundant here\nmod tests {\n  #[test]\n  fn test_select_pairs() {\n    let x = \"Cupcake\";\n    let x_variant = \"Cupcaaaaake\";\n    let y = \"eee\";\n    let y_variant = \"ee\";\n    let z = \"bruh\";\n    assert_eq!(\n      super::select_pairs([&[x, y, z], &[y_variant, x_variant]]).collect::<Vec<_>>(),\n      vec![[x, x_variant], [y, y_variant], [z, \"\"]]\n    );\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/schema_setup/mod.rs",
    "content": "mod diff_check;\nuse anyhow::{Context, anyhow};\nuse chrono::TimeDelta;\nuse diesel::{\n  BoolExpressionMethods,\n  Connection,\n  ExpressionMethods,\n  PgConnection,\n  QueryDsl,\n  RunQueryDsl,\n  connection::SimpleConnection,\n  dsl::exists,\n  migration::{Migration, MigrationVersion},\n  pg::Pg,\n  select,\n  update,\n};\nuse diesel_migrations::MigrationHarness;\nuse std::time::Instant;\nuse tracing::debug;\n\ndiesel::table! {\n  pg_namespace (nspname) {\n    nspname -> Text,\n  }\n}\n\ndiesel::table! {\n  previously_run_sql (id) {\n    id -> Bool,\n    content -> Text,\n  }\n}\n\nfn migrations() -> diesel_migrations::EmbeddedMigrations {\n  // Using `const` here is required by the borrow checker\n  const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations::embed_migrations!();\n  MIGRATIONS\n}\n\n/// This SQL code sets up the `r` schema, which contains things that can be safely dropped and\n/// replaced instead of being changed using migrations. It may not create or modify things outside\n/// of the `r` schema (indicated by `r.` before the name), unless a comment says otherwise.\nfn replaceable_schema() -> String {\n  [\n    \"CREATE SCHEMA r;\",\n    include_str!(\"../../replaceable_schema/utils.sql\"),\n    include_str!(\"../../replaceable_schema/triggers.sql\"),\n  ]\n  .join(\"\\n\")\n}\n\nconst REPLACEABLE_SCHEMA_PATH: &str = \"crates/diesel_utils/replaceable_schema\";\n\nstruct MigrationHarnessWrapper<'a> {\n  conn: &'a mut PgConnection,\n  #[cfg(test)]\n  enable_diff_check: bool,\n  options: &'a Options,\n}\n\nimpl MigrationHarnessWrapper<'_> {\n  fn run_migration_inner(\n    &mut self,\n    migration: &dyn Migration<Pg>,\n  ) -> diesel::migration::Result<MigrationVersion<'static>> {\n    let start_time = Instant::now();\n\n    let result = self.conn.run_migration(migration);\n\n    let duration = TimeDelta::from_std(start_time.elapsed())\n      .map(|d| d.to_string())\n      .unwrap_or_default();\n    let name = migration.name();\n    self.options.print(&format!(\"{duration} run {name}\"));\n\n    result\n  }\n}\n\nimpl MigrationHarness<Pg> for MigrationHarnessWrapper<'_> {\n  fn run_migration(\n    &mut self,\n    migration: &dyn Migration<Pg>,\n  ) -> diesel::migration::Result<MigrationVersion<'static>> {\n    #[cfg(test)]\n    if self.enable_diff_check {\n      let before = diff_check::get_dump();\n\n      self.run_migration_inner(migration)?;\n      self.revert_migration(migration)?;\n\n      let after = diff_check::get_dump();\n\n      diff_check::check_dump_diff(\n        [&after, &before],\n        &format!(\n          \"These changes need to be applied in migrations/{}/down.sql:\",\n          migration.name()\n        ),\n      );\n    }\n\n    self.run_migration_inner(migration)\n  }\n\n  fn revert_migration(\n    &mut self,\n    migration: &dyn Migration<Pg>,\n  ) -> diesel::migration::Result<MigrationVersion<'static>> {\n    let start_time = Instant::now();\n\n    let result = self.conn.revert_migration(migration);\n\n    let duration = TimeDelta::from_std(start_time.elapsed())\n      .map(|d| d.to_string())\n      .unwrap_or_default();\n    let name = migration.name();\n    self.options.print(&format!(\"{duration} revert {name}\"));\n\n    result\n  }\n\n  fn applied_migrations(&mut self) -> diesel::migration::Result<Vec<MigrationVersion<'static>>> {\n    self.conn.applied_migrations()\n  }\n}\n\n#[derive(Default, Clone, Copy)]\npub struct Options {\n  #[cfg(test)]\n  enable_diff_check: bool,\n  revert: bool,\n  run: bool,\n  print_output: bool,\n  limit: Option<u64>,\n}\n\nimpl Options {\n  #[cfg(test)]\n  fn enable_diff_check(mut self) -> Self {\n    self.enable_diff_check = true;\n    self\n  }\n\n  pub fn run(mut self) -> Self {\n    self.run = true;\n    self\n  }\n\n  pub fn revert(mut self) -> Self {\n    self.revert = true;\n    self\n  }\n\n  pub fn limit(mut self, limit: u64) -> Self {\n    self.limit = Some(limit);\n    self\n  }\n\n  /// If print_output is true, use println!.\n  /// Otherwise, use debug!\n  pub fn print_output(mut self) -> Self {\n    self.print_output = true;\n    self\n  }\n\n  fn print(&self, text: &str) {\n    if self.print_output {\n      println!(\"{text}\");\n    } else {\n      debug!(\"{text}\");\n    }\n  }\n}\n\n/// Checked by tests\n#[derive(PartialEq, Eq, Debug)]\npub enum Branch {\n  EarlyReturn,\n  ReplaceableSchemaRebuilt,\n  ReplaceableSchemaNotRebuilt,\n}\n\npub fn run(options: Options, db_url: &str) -> anyhow::Result<Branch> {\n  // Migrations don't support async connection, and this function doesn't need to be async\n  let conn = &mut PgConnection::establish(db_url)?;\n\n  // If possible, skip getting a lock and recreating the \"r\" schema, so\n  // lemmy_server processes in a horizontally scaled setup can start without causing locks\n  if !options.revert\n    && options.run\n    && options.limit.is_none()\n    && !conn\n      .has_pending_migration(migrations())\n      .map_err(convert_err)?\n  {\n    // The condition above implies that the migration that creates the previously_run_sql table was\n    // already run\n    let sql_unchanged = exists(\n      previously_run_sql::table.filter(previously_run_sql::content.eq(replaceable_schema())),\n    );\n\n    let schema_exists = exists(pg_namespace::table.find(\"r\"));\n\n    if select(sql_unchanged.and(schema_exists)).get_result(conn)? {\n      return Ok(Branch::EarlyReturn);\n    }\n  }\n\n  // Block concurrent attempts to run migrations until `conn` is closed, and disable the\n  // trigger that prevents the Diesel CLI from running migrations\n  options.print(\"Waiting for lock...\");\n  conn.batch_execute(\"SELECT pg_advisory_lock(0);\")?;\n  options.print(\"Running Database migrations (This may take a long time)...\");\n\n  // Drop `r` schema, so migrations don't need to be made to work both with and without things in\n  // it existing\n  revert_replaceable_schema(conn)?;\n\n  run_selected_migrations(conn, &options).map_err(convert_err)?;\n\n  // Only run replaceable_schema if newest migration was applied\n  let output = if (options.run && options.limit.is_none())\n    || !conn\n      .has_pending_migration(migrations())\n      .map_err(convert_err)?\n  {\n    #[cfg(test)]\n    if options.enable_diff_check {\n      let before = diff_check::get_dump();\n\n      run_replaceable_schema(conn)?;\n      revert_replaceable_schema(conn)?;\n\n      let after = diff_check::get_dump();\n\n      diff_check::check_dump_diff(\n        [&before, &after],\n        \"The code in crates/diesel_utils/replaceable_schema incorrectly created or modified things outside of the `r` schema, causing these changes to be left behind after dropping the schema:\",\n      );\n\n      diff_check::deferr_constraint_check(&after);\n    }\n\n    run_replaceable_schema(conn)?;\n\n    Branch::ReplaceableSchemaRebuilt\n  } else {\n    Branch::ReplaceableSchemaNotRebuilt\n  };\n\n  options.print(\"Database migrations complete.\");\n\n  Ok(output)\n}\n\nfn run_replaceable_schema(conn: &mut PgConnection) -> anyhow::Result<()> {\n  conn.transaction(|conn| {\n    conn\n      .batch_execute(&replaceable_schema())\n      .with_context(|| format!(\"Failed to run SQL files in {REPLACEABLE_SCHEMA_PATH}\"))?;\n\n    let num_rows_updated = update(previously_run_sql::table)\n      .set(previously_run_sql::content.eq(replaceable_schema()))\n      .execute(conn)?;\n\n    debug_assert_eq!(num_rows_updated, 1);\n\n    Ok(())\n  })\n}\n\nfn revert_replaceable_schema(conn: &mut PgConnection) -> anyhow::Result<()> {\n  conn\n    .batch_execute(\"DROP SCHEMA IF EXISTS r CASCADE;\")\n    .with_context(|| format!(\"Failed to revert SQL files in {REPLACEABLE_SCHEMA_PATH}\"))?;\n\n  // Value in `previously_run_sql` table is not set here because the table might not exist,\n  // and that's fine because the existence of the `r` schema is also checked\n\n  Ok(())\n}\n\nfn run_selected_migrations(\n  conn: &mut PgConnection,\n  options: &Options,\n) -> diesel::migration::Result<()> {\n  let mut wrapper = MigrationHarnessWrapper {\n    conn,\n    options,\n    #[cfg(test)]\n    enable_diff_check: options.enable_diff_check,\n  };\n\n  if options.revert {\n    if let Some(limit) = options.limit {\n      for _ in 0..limit {\n        wrapper.revert_last_migration(migrations())?;\n      }\n    } else {\n      wrapper.revert_all_migrations(migrations())?;\n    }\n  }\n\n  if options.run {\n    if let Some(limit) = options.limit {\n      for _ in 0..limit {\n        wrapper.run_next_migration(migrations())?;\n      }\n    } else {\n      wrapper.run_pending_migrations(migrations())?;\n    }\n  }\n\n  Ok(())\n}\n\n/// Makes `diesel::migration::Result` work with `anyhow` and `LemmyError`\nfn convert_err(e: Box<dyn std::error::Error + Send + Sync>) -> anyhow::Error {\n  anyhow!(e)\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing, clippy::unwrap_used)]\nmod tests {\n  use super::{\n    Branch::{EarlyReturn, ReplaceableSchemaNotRebuilt, ReplaceableSchemaRebuilt},\n    *,\n  };\n  use diesel::{\n    dsl::{not, sql},\n    sql_types,\n  };\n  use diesel_ltree::Ltree;\n  use lemmy_utils::{error::LemmyResult, settings::SETTINGS};\n  use serial_test::serial;\n  // The number of migrations that should be run to set up some test data.\n  // Currently, this includes migrations until\n  // 2020-04-07-135912_add_user_community_apub_constraints, since there are some mandatory apub\n  // fields need to be added.\n\n  const INITIAL_MIGRATIONS_COUNT: u64 = 40;\n\n  // Test data IDs\n  const TEST_USER_ID_1: i32 = 101;\n  const USER1_NAME: &str = \"test_user_1\";\n  const USER1_ACTOR_ID: &str = \"test_user_1@fedi.example\";\n  const USER1_PREFERRED_NAME: &str = \"preferred_1\";\n  const USER1_EMAIL: &str = \"email1@example.com\";\n  const USER1_PASSWORD: &str = \"test_password_1\";\n  const USER1_PUBLIC_KEY: &str = \"test_public_key_1\";\n\n  const TEST_USER_ID_2: i32 = 102;\n  const USER2_NAME: &str = \"test_user_2\";\n  const USER2_ACTOR_ID: &str = \"test_user_2@fedi.example\";\n  const USER2_PREFERRED_NAME: &str = \"preferred2\";\n  const USER2_EMAIL: &str = \"email2@example.com\";\n  const USER2_PASSWORD: &str = \"test_password_2\";\n  const USER2_PUBLIC_KEY: &str = \"test_public_key_2\";\n\n  const TEST_COMMUNITY_ID_1: i32 = 101;\n  const COMMUNITY_NAME: &str = \"test_community_1\";\n  const COMMUNITY_TITLE: &str = \"Test Community 1\";\n  const COMMUNITY_DESCRIPTION: &str = \"This is a test community.\";\n  const CATEGORY_ID: i32 = 4; // Should be a valid category \"Movies\"\n  const COMMUNITY_ACTOR_ID: &str = \"https://fedi.example/community/12345\";\n  const COMMUNITY_PUBLIC_KEY: &str = \"test_public_key_community_1\";\n\n  const TEST_POST_ID_1: i32 = 101;\n  const POST_NAME: &str = \"Post Title\";\n  const POST_URL: &str = \"https://fedi.example/post/12345\";\n  const POST_BODY: &str = \"Post Body.\";\n  const POST_AP_ID: &str = \"https://fedi.example/post/12345\";\n\n  const TEST_COMMENT_ID_1: i32 = 101;\n  const COMMENT1_CONTENT: &str = \"Comment\";\n  const COMMENT1_AP_ID: &str = \"https://fedi.example/comment/12345\";\n\n  const TEST_COMMENT_ID_2: i32 = 102;\n  const COMMENT2_CONTENT: &str = \"Reply\";\n  const COMMENT2_AP_ID: &str = \"https://fedi.example/comment/12346\";\n\n  #[test]\n  #[serial]\n  fn test_schema_setup() -> LemmyResult<()> {\n    let o = Options::default();\n    let db_url = SETTINGS.get_database_url();\n    let conn = &mut PgConnection::establish(&db_url)?;\n\n    // Start with consistent state by dropping everything\n    conn.batch_execute(\"DROP OWNED BY CURRENT_USER;\")?;\n\n    // Run initial migrations to prepare basic tables\n    assert_eq!(\n      run(o.run().limit(INITIAL_MIGRATIONS_COUNT), &db_url)?,\n      ReplaceableSchemaNotRebuilt\n    );\n\n    // Insert the test data\n    insert_test_data(conn)?;\n\n    // Run all migrations, and make sure that changes can be correctly reverted\n    assert_eq!(\n      run(o.run().enable_diff_check(), &db_url)?,\n      ReplaceableSchemaRebuilt\n    );\n\n    // Check the test data we inserted before after running migrations\n    check_test_data(conn)?;\n\n    // Check the current schema\n    assert_eq!(\n      get_foreign_keys_with_missing_indexes(conn)?,\n      Vec::<String>::new(),\n      \"each foreign key needs an index so that deleting the referenced row does not scan the whole referencing table\"\n    );\n\n    // Check for early return\n    assert_eq!(run(o.run(), &db_url)?, EarlyReturn);\n\n    // Test `limit`\n    assert_eq!(\n      run(o.revert().limit(1), &db_url)?,\n      ReplaceableSchemaNotRebuilt\n    );\n    assert_eq!(\n      conn\n        .pending_migrations(migrations())\n        .map_err(convert_err)?\n        .len(),\n      1\n    );\n    assert_eq!(run(o.run().limit(1), &db_url)?, ReplaceableSchemaRebuilt);\n\n    // Get a new connection, workaround for error `cache lookup failed for function 26633`\n    // on `migrations/2025-10-15-114811-0000_merge-modlog-tables/down.sql`.\n    let conn = &mut PgConnection::establish(&db_url)?;\n\n    // This should throw an error saying to use lemmy_server instead of diesel CLI\n    conn.batch_execute(\"DROP OWNED BY CURRENT_USER;\")?;\n    assert!(matches!(\n      conn.run_pending_migrations(migrations()),\n      Err(e) if e.to_string().contains(\"lemmy_server\")\n    ));\n\n    // Diesel CLI's way of running migrations shouldn't break the custom migration runner\n    assert_eq!(run(o.run(), &db_url)?, ReplaceableSchemaRebuilt);\n\n    Ok(())\n  }\n\n  fn insert_test_data(conn: &mut PgConnection) -> LemmyResult<()> {\n    // Users\n    conn.batch_execute(&format!(\n      \"INSERT INTO user_ (id, name, actor_id, preferred_username, password_encrypted, email, public_key) \\\n          VALUES ({}, '{}', '{}', '{}', '{}', '{}', '{}')\",\n      TEST_USER_ID_1,\n      USER1_NAME,\n      USER1_ACTOR_ID,\n      USER1_PREFERRED_NAME,\n      USER1_PASSWORD,\n      USER1_EMAIL,\n      USER1_PUBLIC_KEY\n    ))?;\n\n    conn.batch_execute(&format!(\n      \"INSERT INTO user_ (id, name, actor_id, preferred_username, password_encrypted, email, public_key) \\\n          VALUES ({}, '{}', '{}', '{}', '{}', '{}', '{}')\",\n      TEST_USER_ID_2,\n      USER2_NAME,\n      USER2_ACTOR_ID,\n      USER2_PREFERRED_NAME,\n      USER2_PASSWORD,\n      USER2_EMAIL,\n      USER2_PUBLIC_KEY\n    ))?;\n\n    // Community\n    conn.batch_execute(&format!(\n      \"INSERT INTO community (id, actor_id, public_key, name, title, description, category_id, creator_id) \\\n          VALUES ({}, '{}', '{}', '{}', '{}', '{}', {}, {})\",\n      TEST_COMMUNITY_ID_1,\n      COMMUNITY_ACTOR_ID,\n      COMMUNITY_PUBLIC_KEY,\n      COMMUNITY_NAME,\n      COMMUNITY_TITLE,\n      COMMUNITY_DESCRIPTION,\n      CATEGORY_ID,\n      TEST_USER_ID_1\n    ))?;\n\n    conn.batch_execute(&format!(\n      \"INSERT INTO community_moderator (community_id, user_id) \\\n          VALUES ({}, {})\",\n      TEST_COMMUNITY_ID_1, TEST_USER_ID_1\n    ))?;\n\n    // Post\n    conn.batch_execute(&format!(\n      \"INSERT INTO post (id, name, url, body, creator_id, community_id, ap_id) \\\n          VALUES ({}, '{}', '{}', '{}', {}, {}, '{}')\",\n      TEST_POST_ID_1,\n      POST_NAME,\n      POST_URL,\n      POST_BODY,\n      TEST_USER_ID_1,\n      TEST_COMMUNITY_ID_1,\n      POST_AP_ID\n    ))?;\n\n    // Comment\n    conn.batch_execute(&format!(\n      \"INSERT INTO comment (id, creator_id, post_id, parent_id, content, ap_id) \\\n           VALUES ({}, {}, {}, NULL, '{}', '{}')\",\n      TEST_COMMENT_ID_1, TEST_USER_ID_2, TEST_POST_ID_1, COMMENT1_CONTENT, COMMENT1_AP_ID\n    ))?;\n\n    conn.batch_execute(&format!(\n      \"INSERT INTO comment (id, creator_id, post_id, parent_id, content, ap_id) \\\n           VALUES ({}, {}, {}, {}, '{}', '{}')\",\n      TEST_COMMENT_ID_2,\n      TEST_USER_ID_1,\n      TEST_POST_ID_1,\n      TEST_COMMENT_ID_1,\n      COMMENT2_CONTENT,\n      COMMENT2_AP_ID\n    ))?;\n\n    conn.batch_execute(&format!(\n      \"INSERT INTO comment_like (user_id, comment_id, post_id, score) \\\n           VALUES ({}, {}, {}, {})\",\n      TEST_USER_ID_1, TEST_COMMENT_ID_1, TEST_POST_ID_1, 1\n    ))?;\n\n    Ok(())\n  }\n\n  fn check_test_data(conn: &mut PgConnection) -> LemmyResult<()> {\n    use lemmy_db_schema_file::schema::{comment, community, notification, person, post};\n\n    // Check users\n    let users: Vec<(i32, String, Option<String>, String, String)> = person::table\n      .select((\n        person::id,\n        person::name,\n        person::display_name,\n        person::ap_id,\n        person::public_key,\n      ))\n      .order_by(person::id)\n      .load(conn)\n      .map_err(|e| anyhow!(\"Failed to read users: {}\", e))?;\n\n    assert_eq!(users.len(), 2);\n    assert_eq!(users[0].0, TEST_USER_ID_1);\n    assert_eq!(users[0].1, USER1_NAME);\n    assert_eq!(users[0].2.clone().unwrap(), USER1_PREFERRED_NAME);\n    assert_eq!(users[0].3, USER1_ACTOR_ID);\n    assert_eq!(users[0].4, USER1_PUBLIC_KEY);\n\n    assert_eq!(users[1].0, TEST_USER_ID_2);\n    assert_eq!(users[1].1, USER2_NAME);\n    assert_eq!(users[1].2.clone().unwrap(), USER2_PREFERRED_NAME);\n    assert_eq!(users[1].3, USER2_ACTOR_ID);\n    assert_eq!(users[1].4, USER2_PUBLIC_KEY);\n\n    // Check communities\n    let communities: Vec<(i32, String, String, String)> = community::table\n      .select((\n        community::id,\n        community::name,\n        community::ap_id,\n        community::public_key,\n      ))\n      .load(conn)\n      .map_err(|e| anyhow!(\"Failed to read communities: {}\", e))?;\n\n    assert_eq!(communities.len(), 1);\n    assert_eq!(communities[0].0, TEST_COMMUNITY_ID_1);\n    assert_eq!(communities[0].1, COMMUNITY_NAME);\n    assert_eq!(communities[0].2, COMMUNITY_ACTOR_ID);\n    assert_eq!(communities[0].3, COMMUNITY_PUBLIC_KEY);\n\n    let posts: Vec<(i32, String, String, Option<String>, i32, i32)> = post::table\n      .select((\n        post::id,\n        post::name,\n        post::ap_id,\n        post::body,\n        post::community_id,\n        post::creator_id,\n      ))\n      .load(conn)\n      .map_err(|e| anyhow!(\"Failed to read posts: {}\", e))?;\n\n    assert_eq!(posts.len(), 1);\n    assert_eq!(posts[0].0, TEST_POST_ID_1);\n    assert_eq!(posts[0].1, POST_NAME);\n    assert_eq!(posts[0].2, POST_AP_ID);\n    assert_eq!(posts[0].3.clone().unwrap(), POST_BODY);\n    assert_eq!(posts[0].4, TEST_COMMUNITY_ID_1);\n    assert_eq!(posts[0].5, TEST_USER_ID_1);\n\n    let comments: Vec<(i32, String, String, i32, i32, Ltree, i32)> = comment::table\n      .select((\n        comment::id,\n        comment::content,\n        comment::ap_id,\n        comment::post_id,\n        comment::creator_id,\n        comment::path,\n        comment::upvotes,\n      ))\n      .order_by(comment::id)\n      .load(conn)\n      .map_err(|e| anyhow!(\"Failed to read comments: {}\", e))?;\n\n    assert_eq!(comments.len(), 2);\n    assert_eq!(comments[0].0, TEST_COMMENT_ID_1);\n    assert_eq!(comments[0].1, COMMENT1_CONTENT);\n    assert_eq!(comments[0].2, COMMENT1_AP_ID);\n    assert_eq!(comments[0].3, TEST_POST_ID_1);\n    assert_eq!(comments[0].4, TEST_USER_ID_2);\n    assert_eq!(\n      comments[0].5,\n      Ltree(format!(\"0.{}\", TEST_COMMENT_ID_1).to_string())\n    );\n    assert_eq!(comments[0].6, 1); // One upvote\n\n    assert_eq!(comments[1].0, TEST_COMMENT_ID_2);\n    assert_eq!(comments[1].1, COMMENT2_CONTENT);\n    assert_eq!(comments[1].2, COMMENT2_AP_ID);\n    assert_eq!(comments[1].3, TEST_POST_ID_1);\n    assert_eq!(comments[1].4, TEST_USER_ID_1);\n    assert_eq!(\n      comments[1].5,\n      Ltree(format!(\"0.{}.{}\", TEST_COMMENT_ID_1, TEST_COMMENT_ID_2).to_string())\n    );\n    assert_eq!(comments[1].6, 0); // Zero upvotes\n\n    // Check comment replies\n    let replies: Vec<(Option<i32>, i32)> = notification::table\n      .select((notification::comment_id, notification::recipient_id))\n      .order_by(notification::comment_id)\n      .load(conn)\n      .map_err(|e| anyhow!(\"Failed to read comment replies: {}\", e))?;\n\n    assert_eq!(replies.len(), 2);\n    assert_eq!(replies[0].0, Some(TEST_COMMENT_ID_1));\n    assert_eq!(replies[0].1, TEST_USER_ID_1);\n    assert_eq!(replies[1].0, Some(TEST_COMMENT_ID_2));\n    assert_eq!(replies[1].1, TEST_USER_ID_2);\n\n    Ok(())\n  }\n\n  const FOREIGN_KEY: &str = \"f\";\n\n  fn get_foreign_keys_with_missing_indexes(conn: &mut PgConnection) -> LemmyResult<Vec<String>> {\n    diesel::table! {\n      pg_constraint (table_oid, name, kind, column_numbers) {\n        #[sql_name = \"conrelid\"]\n        table_oid -> Oid,\n        #[sql_name = \"conname\"]\n        name -> Text,\n        #[sql_name = \"contype\"]\n        kind -> Text,\n        #[sql_name = \"conkey\"]\n        column_numbers -> Array<Int2>,\n      }\n    }\n\n    diesel::table! {\n      pg_index (table_oid, key_length, column_numbers) {\n        #[sql_name = \"indrelid\"]\n        table_oid -> Oid,\n        #[sql_name = \"indnkeyatts\"]\n        key_length -> Int2,\n        #[sql_name = \"indkey\"]\n        column_numbers -> Array<Int2>,\n      }\n    }\n\n    diesel::allow_tables_to_appear_in_same_query!(pg_constraint, pg_index);\n\n    let matching_index = pg_index::table\n      .filter(pg_index::table_oid.eq(pg_constraint::table_oid))\n      // Check if the index's key (not columns listed with `INCLUDE`) starts with the foreign key.\n      // TODO: use Diesel array slice function when it's added.\n      .filter(sql::<sql_types::Bool>(\n        \"((pg_index.indkey[:pg_index.indnkeyatts])[:array_length(pg_constraint.conkey, 1)] = pg_constraint.conkey)\"\n      ));\n\n    let res = pg_constraint::table\n      .select(pg_constraint::name)\n      .filter(pg_constraint::kind.eq(FOREIGN_KEY))\n      .filter(not(exists(matching_index)))\n      .load(conn)?;\n\n    Ok(res)\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/sensitive.rs",
    "content": "#[cfg(feature = \"full\")]\nuse diesel_derive_newtype::DieselNewType;\nuse serde::{Deserialize, Serialize};\nuse std::{fmt::Debug, ops::Deref};\n\n#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Default)]\n#[cfg_attr(feature = \"full\", derive(DieselNewType))]\n#[serde(transparent)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub struct SensitiveString(String);\n\nimpl SensitiveString {\n  pub fn into_inner(self) -> String {\n    self.0\n  }\n}\n\nimpl Debug for SensitiveString {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    f.debug_struct(\"Sensitive\").finish()\n  }\n}\n\nimpl AsRef<[u8]> for SensitiveString {\n  fn as_ref(&self) -> &[u8] {\n    self.0.as_ref()\n  }\n}\n\nimpl Deref for SensitiveString {\n  type Target = str;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<String> for SensitiveString {\n  fn from(t: String) -> Self {\n    SensitiveString(t)\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/traits.rs",
    "content": "use crate::connection::{DbPool, get_conn};\nuse diesel::{\n  associations::HasTable,\n  dsl,\n  query_builder::{DeleteStatement, IntoUpdateTarget},\n  query_dsl::methods::{FindDsl, LimitDsl},\n};\nuse diesel_async::{\n  AsyncPgConnection,\n  RunQueryDsl,\n  methods::{ExecuteDsl, LoadQuery},\n};\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse std::future::Future;\n\n/// Returned by `diesel::delete`\ntype Delete<T> = DeleteStatement<<T as HasTable>::Table, <T as IntoUpdateTarget>::WhereClause>;\n\n/// Returned by `Self::table().find(id)`\ntype Find<T> = dsl::Find<<T as HasTable>::Table, <T as Crud>::IdType>;\n\n// Trying to create default implementations for `create` and `update` results in a lifetime mess and\n// weird compile errors. https://github.com/rust-lang/rust/issues/102211\npub trait Crud: HasTable + Sized\nwhere\n  Self::Table: FindDsl<Self::IdType>,\n  Find<Self>: LimitDsl + IntoUpdateTarget + Send,\n  Delete<Find<Self>>: ExecuteDsl<AsyncPgConnection> + Send + 'static,\n  // Used by `RunQueryDsl::first`\n  dsl::Limit<Find<Self>>: LoadQuery<'static, AsyncPgConnection, Self> + Send + 'static,\n{\n  type InsertForm;\n  type UpdateForm;\n  type IdType: Send;\n\n  fn create(\n    pool: &mut DbPool<'_>,\n    form: &Self::InsertForm,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n\n  fn read(pool: &mut DbPool<'_>, id: Self::IdType) -> impl Future<Output = LemmyResult<Self>> + Send\n  where\n    Self: Send,\n  {\n    async {\n      let query: Find<Self> = Self::table().find(id);\n      let conn = &mut *get_conn(pool).await?;\n      query\n        .first(conn)\n        .await\n        .with_lemmy_type(LemmyErrorType::NotFound)\n    }\n  }\n\n  /// when you want to null out a column, you have to send Some(None)), since sending None means you\n  /// just don't want to update that column.\n  fn update(\n    pool: &mut DbPool<'_>,\n    id: Self::IdType,\n    form: &Self::UpdateForm,\n  ) -> impl Future<Output = LemmyResult<Self>> + Send;\n\n  fn delete(\n    pool: &mut DbPool<'_>,\n    id: Self::IdType,\n  ) -> impl Future<Output = LemmyResult<usize>> + Send {\n    async {\n      let query: Delete<Find<Self>> = diesel::delete(Self::table().find(id));\n      let conn = &mut *get_conn(pool).await?;\n      query\n        .execute(conn)\n        .await\n        .with_lemmy_type(LemmyErrorType::Deleted)\n    }\n  }\n}\n"
  },
  {
    "path": "crates/diesel_utils/src/utils.rs",
    "content": "use crate::dburl::DbUrl;\nuse diesel::{\n  Expression,\n  IntoSql,\n  dsl,\n  helper_types::AsExprOf,\n  pg::{Pg, data_types::PgInterval},\n  query_builder::{Query, QueryFragment, QueryId},\n  query_dsl::methods::LimitDsl,\n  result::Error::{self as DieselError},\n  sql_types::{self, Timestamptz},\n};\nuse futures_util::future::BoxFuture;\nuse i_love_jesus::CursorKey;\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  utils::validation::clean_url,\n};\nuse url::Url;\n\n/// Necessary to be able to use cursors with the lower SQL function\npub struct LowerKey<K>(pub K);\n\nimpl<K, C> CursorKey<C> for LowerKey<K>\nwhere\n  K: CursorKey<C, SqlType = sql_types::Text>,\n{\n  type SqlType = sql_types::Text;\n  type CursorValue = functions::lower<K::CursorValue>;\n  type SqlValue = functions::lower<K::SqlValue>;\n\n  fn get_cursor_value(cursor: &C) -> Self::CursorValue {\n    functions::lower(K::get_cursor_value(cursor))\n  }\n\n  fn get_sql_value() -> Self::SqlValue {\n    functions::lower(K::get_sql_value())\n  }\n}\n\n/// Necessary to be able to use cursors with the subpath SQL function\npub struct Subpath<K>(pub K);\n\nimpl<K, C> CursorKey<C> for Subpath<K>\nwhere\n  K: CursorKey<C, SqlType = diesel_ltree::sql_types::Ltree>,\n{\n  type SqlType = diesel_ltree::sql_types::Ltree;\n  type CursorValue = diesel_ltree::subpath<K::CursorValue, i32, i32>;\n  type SqlValue = diesel_ltree::subpath<K::SqlValue, i32, i32>;\n\n  fn get_cursor_value(cursor: &C) -> Self::CursorValue {\n    diesel_ltree::subpath(K::get_cursor_value(cursor), 0, -1)\n  }\n\n  fn get_sql_value() -> Self::SqlValue {\n    diesel_ltree::subpath(K::get_sql_value(), 0, -1)\n  }\n}\n\npub struct CoalesceKey<A, B>(pub A, pub B);\n\nimpl<A, B, C> CursorKey<C> for CoalesceKey<A, B>\nwhere\n  A: CursorKey<C, SqlType = sql_types::Nullable<B::SqlType>>,\n  B: CursorKey<C, SqlType: Send>,\n{\n  type SqlType = B::SqlType;\n  type CursorValue = functions::coalesce<B::SqlType, A::CursorValue, B::CursorValue>;\n  type SqlValue = functions::coalesce<B::SqlType, A::SqlValue, B::SqlValue>;\n\n  fn get_cursor_value(cursor: &C) -> Self::CursorValue {\n    // TODO: for slight optimization, use unwrap_or_else here (this requires the CursorKey trait to\n    // be changed to allow non-binded CursorValue)\n    functions::coalesce(A::get_cursor_value(cursor), B::get_cursor_value(cursor))\n  }\n\n  fn get_sql_value() -> Self::SqlValue {\n    functions::coalesce(A::get_sql_value(), B::get_sql_value())\n  }\n}\n\n/// Includes an SQL comment before `T`, which can be used to label auto_explain output\n#[derive(QueryId)]\npub struct Commented<T> {\n  comment: String,\n  inner: T,\n}\n\nimpl<T> Commented<T> {\n  pub fn new(inner: T) -> Self {\n    Commented {\n      comment: String::new(),\n      inner,\n    }\n  }\n\n  /// Adds `text` to the comment if `condition` is true\n  fn text_if(mut self, text: &str, condition: bool) -> Self {\n    if condition {\n      if !self.comment.is_empty() {\n        self.comment.push_str(\", \");\n      }\n      self.comment.push_str(text);\n    }\n    self\n  }\n\n  /// Adds `text` to the comment\n  pub fn text(self, text: &str) -> Self {\n    self.text_if(text, true)\n  }\n}\n\nimpl<T: Query> Query for Commented<T> {\n  type SqlType = T::SqlType;\n}\n\nimpl<T: QueryFragment<Pg>> QueryFragment<Pg> for Commented<T> {\n  fn walk_ast<'b>(\n    &'b self,\n    mut out: diesel::query_builder::AstPass<'_, 'b, Pg>,\n  ) -> Result<(), DieselError> {\n    for line in self.comment.lines() {\n      out.push_sql(\"\\n-- \");\n      out.push_sql(line);\n    }\n    out.push_sql(\"\\n\");\n    self.inner.walk_ast(out.reborrow())\n  }\n}\n\nimpl<T: LimitDsl> LimitDsl for Commented<T> {\n  type Output = Commented<T::Output>;\n\n  fn limit(self, limit: i64) -> Self::Output {\n    Commented {\n      comment: self.comment,\n      inner: self.inner.limit(limit),\n    }\n  }\n}\n\npub fn fuzzy_search(q: &str) -> String {\n  let replaced = q\n    .replace('\\\\', \"\\\\\\\\\")\n    .replace('%', \"\\\\%\")\n    .replace('_', \"\\\\_\")\n    .replace(' ', \"%\");\n  format!(\"%{replaced}%\")\n}\n\n/// Takes an API optional text input, and converts it to an optional diesel DB update.\npub fn diesel_string_update(opt: Option<&str>) -> Option<Option<String>> {\n  match opt {\n    // An empty string is an erase\n    Some(\"\") => Some(None),\n    Some(str) => Some(Some(str.into())),\n    None => None,\n  }\n}\n\n/// Takes an API optional number, and converts it to an optional diesel DB update. Zero means erase.\npub fn diesel_opt_number_update(opt: Option<i32>) -> Option<Option<i32>> {\n  match opt {\n    // Zero is an erase\n    Some(0) => Some(None),\n    Some(num) => Some(Some(num)),\n    None => None,\n  }\n}\n\n/// Takes an API optional text input, and converts it to an optional diesel DB update (for non\n/// nullable properties).\npub fn diesel_required_string_update(opt: Option<&str>) -> Option<String> {\n  match opt {\n    // An empty string is no change\n    Some(\"\") => None,\n    Some(str) => Some(str.into()),\n    None => None,\n  }\n}\n\n/// Takes an optional API URL-type input, and converts it to an optional diesel DB update.\n/// Also cleans the url params.\npub fn diesel_url_update(opt: Option<&str>) -> LemmyResult<Option<Option<DbUrl>>> {\n  match opt {\n    // An empty string is an erase\n    Some(\"\") => Ok(Some(None)),\n    Some(str_url) => Url::parse(str_url)\n      .map(|u| Some(Some(clean_url(&u).into())))\n      .with_lemmy_type(LemmyErrorType::InvalidUrl),\n    None => Ok(None),\n  }\n}\n\n/// Takes an optional API URL-type input, and converts it to an optional diesel DB update (for non\n/// nullable properties). Also cleans the url params.\npub fn diesel_required_url_update(opt: Option<&str>) -> LemmyResult<Option<DbUrl>> {\n  match opt {\n    // An empty string is no change\n    Some(\"\") => Ok(None),\n    Some(str_url) => Url::parse(str_url)\n      .map(|u| Some(clean_url(&u).into()))\n      .with_lemmy_type(LemmyErrorType::InvalidUrl),\n    None => Ok(None),\n  }\n}\n\n/// Takes an optional API URL-type input, and converts it to an optional diesel DB create.\n/// Also cleans the url params.\npub fn diesel_url_create(opt: Option<&str>) -> LemmyResult<Option<DbUrl>> {\n  match opt {\n    Some(str_url) => Url::parse(str_url)\n      .map(|u| Some(clean_url(&u).into()))\n      .with_lemmy_type(LemmyErrorType::InvalidUrl),\n    None => Ok(None),\n  }\n}\n\npub mod functions {\n  use diesel::{\n    define_sql_function,\n    sql_types::{Int4, Text, Timestamptz},\n  };\n\n  define_sql_function! {\n    #[sql_name = \"r.hot_rank\"]\n    fn hot_rank(score: Int4, time: Timestamptz) -> Float;\n  }\n\n  define_sql_function! {\n    #[sql_name = \"r.scaled_rank\"]\n    fn scaled_rank(score: Int4, time: Timestamptz, interactions_month: Int4) -> Float;\n  }\n\n  define_sql_function!(fn lower(x: Text) -> Text);\n\n  define_sql_function!(fn random() -> Text);\n\n  define_sql_function!(fn random_smallint() -> SmallInt);\n\n  // really this function is variadic, this just adds the two-argument version\n  define_sql_function!(fn coalesce<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(x: diesel::sql_types::Nullable<T>, y: T) -> T);\n\n  define_sql_function! {\n    #[aggregate]\n    fn json_agg<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(obj: T) -> Json\n  }\n\n  define_sql_function!(#[sql_name = \"coalesce\"] fn coalesce_2_nullable<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(x: diesel::sql_types::Nullable<T>, y: diesel::sql_types::Nullable<T>) -> diesel::sql_types::Nullable<T>);\n\n  define_sql_function!(#[sql_name = \"coalesce\"] fn coalesce_3_nullable<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(x: diesel::sql_types::Nullable<T>, y: diesel::sql_types::Nullable<T>, z: diesel::sql_types::Nullable<T>) -> diesel::sql_types::Nullable<T>);\n}\n\npub fn now() -> AsExprOf<diesel::dsl::now, diesel::sql_types::Timestamptz> {\n  // https://github.com/diesel-rs/diesel/issues/1514\n  diesel::dsl::now.into_sql::<Timestamptz>()\n}\n\npub fn seconds_to_pg_interval(seconds: i32) -> PgInterval {\n  PgInterval::from_microseconds(i64::from(seconds) * 1_000_000)\n}\n\n/// Output of `IntoSql::into_sql` for a type that implements `AsRecord`\npub type AsRecordOutput<T> = dsl::AsExprOf<T, sql_types::Record<<T as Expression>::SqlType>>;\n\npub type ResultFuture<'a, T> = BoxFuture<'a, Result<T, DieselError>>;\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use pretty_assertions::assert_eq;\n\n  #[test]\n  fn test_fuzzy_search() {\n    let test = \"This %is% _a_ fuzzy search\";\n    assert_eq!(\n      fuzzy_search(test),\n      \"%This%\\\\%is\\\\%%\\\\_a\\\\_%fuzzy%search%\".to_string()\n    );\n  }\n\n  #[test]\n  fn test_diesel_option_overwrite() {\n    assert_eq!(diesel_string_update(None), None);\n    assert_eq!(diesel_string_update(Some(\"\")), Some(None));\n    assert_eq!(\n      diesel_string_update(Some(\"test\")),\n      Some(Some(\"test\".to_string()))\n    );\n  }\n\n  #[test]\n  fn test_diesel_option_overwrite_to_url() -> LemmyResult<()> {\n    assert!(matches!(diesel_url_update(None), Ok(None)));\n    assert!(matches!(diesel_url_update(Some(\"\")), Ok(Some(None))));\n    assert!(diesel_url_update(Some(\"invalid_url\")).is_err());\n    let example_url = \"https://example.com\";\n    assert!(matches!(\n      diesel_url_update(Some(example_url)),\n      Ok(Some(Some(url))) if url == Url::parse(example_url)?.into()\n    ));\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/email/Cargo.toml",
    "content": "[package]\nname = \"lemmy_email\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_email\"\npath = \"src/lib.rs\"\ndoctest = false\ntest = false\n\n[lints]\nworkspace = true\n\n[features]\nfull = []\n\n[dependencies]\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_schema_file = { workspace = true }\nuuid = { workspace = true, features = [\"v4\"] }\nrosetta-i18n = { workspace = true }\nhtml2text = { workspace = true }\nlettre = { version = \"0.11.19\", default-features = false, features = [\n  \"builder\",\n  \"pool\",\n  \"smtp-transport\",\n  \"tokio1-rustls-tls\",\n] }\nlemmy_diesel_utils = { workspace = true }\n\n[dev-dependencies]\n\n[build-dependencies]\nrosetta-build = { version = \"0.1.3\", default-features = false }\n"
  },
  {
    "path": "crates/email/build.rs",
    "content": "use std::fs::read_dir;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n  let mut config = rosetta_build::config();\n\n  for path in read_dir(\"translations/backend/\")? {\n    let path = path?.path();\n    if let Some(name) = path.file_name() {\n      let mut lang = name.to_string_lossy().to_string().replace(\".json\", \"\");\n\n      // Rename Chinese simplified variant because there is no translation zh\n      if lang == \"zh_Hans\" {\n        lang = \"zh\".to_string();\n      }\n      // Rosetta doesnt support these language variants.\n      if lang.contains('_') {\n        continue;\n      }\n\n      let path = path.to_string_lossy();\n      rosetta_build::config()\n        .source(&lang, path.clone())\n        .fallback(&lang)\n        .generate()?;\n\n      config = config.source(lang, path);\n    }\n  }\n\n  config.fallback(\"en\").generate()?;\n\n  Ok(())\n}\n"
  },
  {
    "path": "crates/email/src/account.rs",
    "content": "use crate::{send::send_email, user_email, user_language};\nuse lemmy_db_schema::source::{\n  email_verification::{EmailVerification, EmailVerificationForm},\n  local_site::LocalSite,\n  password_reset_request::PasswordResetRequest,\n};\nuse lemmy_db_schema_file::enums::RegistrationMode;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::{connection::DbPool, sensitive::SensitiveString};\nuse lemmy_utils::{\n  error::LemmyResult,\n  settings::structs::Settings,\n  utils::markdown::markdown_to_html,\n};\n\npub async fn send_password_reset_email(\n  user: &LocalUserView,\n  pool: &mut DbPool<'_>,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  // Generate a random token\n  let token = uuid::Uuid::new_v4().to_string();\n\n  let lang = user_language(&user.local_user);\n  let subject = lang.password_reset_subject(&user.person.name);\n  let protocol_and_hostname = settings.get_protocol_and_hostname();\n  let reset_link = format!(\"{}/password_change/{}\", protocol_and_hostname, &token);\n  let email = user_email(user)?;\n  let body = lang.password_reset_body(reset_link, &user.person.name);\n  send_email(subject, email, user.person.name.clone(), body, settings);\n\n  // Insert the row after successful send, to avoid using daily reset limit while\n  // email sending is broken.\n  let local_user_id = user.local_user.id;\n  PasswordResetRequest::create(pool, local_user_id, token.clone()).await?;\n  Ok(())\n}\n\n/// Send a verification email\npub async fn send_verification_email(\n  local_site: &LocalSite,\n  user: &LocalUserView,\n  new_email: SensitiveString,\n  pool: &mut DbPool<'_>,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  let form = EmailVerificationForm {\n    local_user_id: user.local_user.id,\n    email: new_email.to_string(),\n    verification_token: uuid::Uuid::new_v4().to_string(),\n  };\n  let verify_link = format!(\n    \"{}/verify_email/{}\",\n    settings.get_protocol_and_hostname(),\n    &form.verification_token\n  );\n  EmailVerification::create(pool, &form).await?;\n\n  let lang = user_language(&user.local_user);\n  let subject = lang.verify_email_subject(&settings.hostname);\n\n  // If an application is required, use a translation that includes that warning.\n  let body = if local_site.registration_mode == RegistrationMode::RequireApplication {\n    lang.verify_email_body_with_application(&settings.hostname, &user.person.name, verify_link)\n  } else {\n    lang.verify_email_body(&settings.hostname, &user.person.name, verify_link)\n  };\n\n  send_email(subject, new_email, user.person.name.clone(), body, settings);\n  Ok(())\n}\n\n/// Returns true if email was sent.\npub async fn send_verification_email_if_required(\n  local_site: &LocalSite,\n  user: &LocalUserView,\n  pool: &mut DbPool<'_>,\n  settings: &'static Settings,\n) -> LemmyResult<bool> {\n  if !user.local_user.admin\n    && local_site.require_email_verification\n    && !user.local_user.email_verified\n  {\n    let email = user_email(user)?;\n    send_verification_email(local_site, user, email, pool, settings).await?;\n    Ok(true)\n  } else {\n    Ok(false)\n  }\n}\n\npub fn send_application_approved_email(\n  user: &LocalUserView,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  let lang = user_language(&user.local_user);\n  let subject = lang.registration_approved_subject(&user.person.name);\n  let email = user_email(user)?;\n  let body = lang.registration_approved_body(&settings.hostname);\n  send_email(subject, email, user.person.name.clone(), body, settings);\n  Ok(())\n}\n\npub fn send_application_denied_email(\n  user: &LocalUserView,\n  deny_reason: Option<String>,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  let lang = user_language(&user.local_user);\n  let subject = lang.registration_denied_subject(&user.person.name);\n  let email = user_email(user)?;\n  let body = match deny_reason {\n    Some(deny_reason) => {\n      let markdown = markdown_to_html(&deny_reason);\n      lang.registration_denied_reason_body(&settings.hostname, &markdown)\n    }\n    None => lang.registration_denied_body(&settings.hostname),\n  };\n  send_email(subject, email, user.person.name.clone(), body, settings);\n  Ok(())\n}\n\npub fn send_email_verified_email(\n  user: &LocalUserView,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  let lang = user_language(&user.local_user);\n  let subject = lang.email_verified_subject(&user.person.name);\n  let email = user_email(user)?;\n  let body = lang.email_verified_body();\n  send_email(\n    subject,\n    email,\n    user.person.name.clone(),\n    body.to_string(),\n    settings,\n  );\n  Ok(())\n}\n"
  },
  {
    "path": "crates/email/src/admin.rs",
    "content": "use crate::{send::send_email, user_language};\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::connection::DbPool;\nuse lemmy_utils::{error::LemmyResult, settings::structs::Settings};\n\n/// Send a new applicant email notification to all admins\npub async fn send_new_applicant_email_to_admins(\n  applicant_username: &str,\n  pool: &mut DbPool<'_>,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  // Collect the admins with emails\n  let admins = LocalUserView::list_admins_with_emails(pool).await?;\n\n  let applications_link = &format!(\n    \"{}/registration_applications\",\n    settings.get_protocol_and_hostname(),\n  );\n\n  for admin in admins {\n    let lang = user_language(&admin.local_user);\n    if let Some(email) = admin.local_user.email {\n      let subject = lang.new_application_subject(&settings.hostname, applicant_username);\n      let body = lang.new_application_body(applications_link);\n      send_email(subject, email, admin.person.name, body, settings);\n    }\n  }\n  Ok(())\n}\n\n/// Send a report to all admins\npub async fn send_new_report_email_to_admins(\n  reporter_username: &str,\n  reported_username: &str,\n  pool: &mut DbPool<'_>,\n  settings: &'static Settings,\n) -> LemmyResult<()> {\n  // Collect the admins with emails\n  let admins = LocalUserView::list_admins_with_emails(pool).await?;\n\n  let reports_link = &format!(\"{}/reports\", settings.get_protocol_and_hostname(),);\n\n  for admin in admins {\n    let lang = user_language(&admin.local_user);\n    if let Some(email) = admin.local_user.email {\n      let subject =\n        lang.new_report_subject(&settings.hostname, reported_username, reporter_username);\n      let body = lang.new_report_body(reports_link);\n      send_email(subject, email, admin.person.name, body, settings);\n    }\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/email/src/lib.rs",
    "content": "use lemmy_db_schema::source::local_user::LocalUser;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::sensitive::SensitiveString;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse rosetta_i18n::{Language, LanguageId};\nuse translations::Lang;\n\npub mod account;\npub mod admin;\npub mod notifications;\nmod send;\n\n/// Avoid warnings for unused 0.19 translations\n#[expect(mismatched_lifetime_syntaxes)]\npub mod translations {\n  rosetta_i18n::include_translations!();\n}\n\nfn inbox_link(settings: &Settings) -> String {\n  format!(\"{}/inbox\", settings.get_protocol_and_hostname())\n}\n\n#[expect(clippy::expect_used)]\npub fn user_language(local_user: &LocalUser) -> Lang {\n  let lang_id = LanguageId::new(&local_user.interface_language);\n  Lang::from_language_id(&lang_id).unwrap_or_else(|| {\n    let en = LanguageId::new(\"en\");\n    Lang::from_language_id(&en).expect(\"default language\")\n  })\n}\n\nfn user_email(local_user_view: &LocalUserView) -> LemmyResult<SensitiveString> {\n  local_user_view\n    .local_user\n    .email\n    .clone()\n    .ok_or(LemmyErrorType::EmailRequired.into())\n}\n"
  },
  {
    "path": "crates/email/src/notifications.rs",
    "content": "use crate::{inbox_link, send::send_email, user_language};\nuse lemmy_db_schema::source::{comment::Comment, community::Community, person::Person, post::Post};\nuse lemmy_db_schema_file::enums::ModlogKind;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse lemmy_utils::{settings::structs::Settings, utils::markdown::markdown_to_html};\n\npub enum NotificationEmailData<'a> {\n  Mention {\n    content: String,\n    person: &'a Person,\n  },\n  PostSubscribed {\n    post: &'a Post,\n    comment: &'a Comment,\n  },\n  CommunitySubscribed {\n    post: &'a Post,\n    community: &'a Community,\n  },\n  Reply {\n    comment: &'a Comment,\n    person: &'a Person,\n    parent_comment: Option<Comment>,\n    post: &'a Post,\n  },\n  PrivateMessage {\n    sender: &'a Person,\n    content: &'a String,\n  },\n  ModAction {\n    kind: ModlogKind,\n    reason: Option<&'a str>,\n    is_revert: bool,\n  },\n}\n\npub fn send_notification_email(\n  local_user_view: LocalUserView,\n  link: DbUrl,\n  data: NotificationEmailData,\n  settings: &'static Settings,\n) {\n  if local_user_view.banned || !local_user_view.local_user.send_notifications_to_email {\n    return;\n  }\n\n  let inbox_link = inbox_link(settings);\n  let lang = user_language(&local_user_view.local_user);\n  let (subject, body) = match data {\n    NotificationEmailData::Mention { content, person } => {\n      let content = markdown_to_html(&content);\n      (\n        lang.notification_mentioned_by_subject(&person.name),\n        lang.notification_mentioned_by_body(&link, &content, &inbox_link, &person.name),\n      )\n    }\n    NotificationEmailData::PostSubscribed { post, comment } => {\n      let content = markdown_to_html(&comment.content);\n      (\n        lang.notification_post_subscribed_subject(&post.name),\n        lang.notification_post_subscribed_body(&content, &link, inbox_link),\n      )\n    }\n    NotificationEmailData::CommunitySubscribed { post, community } => {\n      let content = post\n        .body\n        .as_ref()\n        .map(|b| markdown_to_html(b))\n        .unwrap_or_default();\n      (\n        lang.notification_community_subscribed_subject(&post.name, &community.title),\n        lang.notification_community_subscribed_body(&content, &link, inbox_link),\n      )\n    }\n    NotificationEmailData::Reply {\n      comment,\n      person,\n      parent_comment: Some(parent_comment),\n      post,\n    } => {\n      let content = markdown_to_html(&comment.content);\n      (\n        lang.notification_comment_reply_subject(&person.name),\n        lang.notification_comment_reply_body(\n          link,\n          &content,\n          &inbox_link,\n          &parent_comment.content,\n          &post.name,\n          &person.name,\n        ),\n      )\n    }\n    NotificationEmailData::Reply {\n      comment,\n      person,\n      parent_comment: None,\n      post,\n    } => {\n      let content = markdown_to_html(&comment.content);\n      (\n        lang.notification_post_reply_subject(&person.name),\n        lang.notification_post_reply_body(link, &content, &inbox_link, &post.name, &person.name),\n      )\n    }\n    NotificationEmailData::PrivateMessage { sender, content } => {\n      let sender_name = &sender.name;\n      let content = markdown_to_html(content);\n      (\n        lang.notification_private_message_subject(sender_name),\n        lang.notification_private_message_body(inbox_link, &content, sender_name),\n      )\n    }\n    NotificationEmailData::ModAction {\n      kind,\n      reason,\n      is_revert,\n    } => {\n      // Some actions like AdminAdd and ModAddToCommunity dont have any reason\n      let reason = reason.unwrap_or_default();\n      if is_revert {\n        (\n          lang.notification_mod_action_subject(kind).clone(),\n          lang.notification_mod_action_body(reason, inbox_link),\n        )\n      } else {\n        (\n          lang.notification_mod_action_reverted_subject(kind).clone(),\n          lang.notification_mod_action_reverted_body(reason, inbox_link),\n        )\n      }\n    }\n  };\n\n  if let Some(user_email) = local_user_view.local_user.email {\n    send_email(\n      subject,\n      user_email,\n      local_user_view.person.name,\n      body,\n      settings,\n    );\n  }\n}\n"
  },
  {
    "path": "crates/email/src/send.rs",
    "content": "use lemmy_diesel_utils::sensitive::SensitiveString;\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType},\n  settings::structs::Settings,\n  spawn_try_task,\n};\nuse lettre::{\n  Address,\n  AsyncTransport,\n  Message,\n  message::{Mailbox, MultiPart},\n  transport::smtp::extension::ClientId,\n};\nuse std::{str::FromStr, sync::OnceLock};\nuse uuid::Uuid;\n\ntype AsyncSmtpTransport = lettre::AsyncSmtpTransport<lettre::Tokio1Executor>;\n\npub(crate) fn send_email(\n  subject: String,\n  to_email: SensitiveString,\n  to_username: String,\n  html: String,\n  settings: &'static Settings,\n) {\n  spawn_try_task(async move {\n    static MAILER: OnceLock<AsyncSmtpTransport> = OnceLock::new();\n    let email_config = settings.email.clone().ok_or(LemmyErrorType::NoEmailSetup)?;\n\n    #[expect(clippy::expect_used)]\n    let mailer = MAILER.get_or_init(|| {\n      AsyncSmtpTransport::from_url(&email_config.connection)\n        .expect(\"init email transport\")\n        .hello_name(ClientId::Domain(settings.hostname.clone()))\n        .build()\n    });\n\n    // use usize::MAX as the line wrap length, since lettre handles the wrapping for us\n    let plain_text = html2text::from_read(html.as_bytes(), usize::MAX)?;\n\n    let smtp_from_address = &email_config.smtp_from_address;\n\n    let email = Message::builder()\n      .from(\n        smtp_from_address\n          .parse()\n          .with_lemmy_type(LemmyErrorType::InvalidEmailAddress(\n            smtp_from_address.into(),\n          ))?,\n      )\n      .to(Mailbox::new(\n        Some(to_username.clone()),\n        Address::from_str(&to_email)\n          .with_lemmy_type(LemmyErrorType::InvalidEmailAddress(to_email.into_inner()))?,\n      ))\n      .message_id(Some(format!(\"<{}@{}>\", Uuid::new_v4(), settings.hostname)))\n      .subject(subject)\n      .multipart(MultiPart::alternative_plain_html(plain_text, html.clone()))\n      .with_lemmy_type(LemmyErrorType::EmailSendFailed)?;\n\n    mailer\n      .send(email)\n      .await\n      .with_lemmy_type(LemmyErrorType::EmailSendFailed)?;\n\n    Ok(())\n  })\n}\n"
  },
  {
    "path": "crates/routes/Cargo.toml",
    "content": "[package]\nname = \"lemmy_routes\"\npublish = false\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\ndoctest = false\n\n[lints]\nworkspace = true\n\n# dummy to make `./scripts/test.sh lemmy_routes` work\n[features]\nfull = []\nts-rs = [\"dep:ts-rs\"]\n\n[dependencies]\nlemmy_db_views_community = { workspace = true, features = [\"full\"] }\nlemmy_db_views_post = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_image = { workspace = true, features = [\"full\"] }\nlemmy_db_views_local_user = { workspace = true, features = [\"full\"] }\nlemmy_db_views_notification = { workspace = true, features = [\"full\"] }\nlemmy_db_views_modlog = { workspace = true, features = [\"full\"] }\nlemmy_db_views_person_content_combined = { workspace = true, features = [\n  \"full\",\n] }\nlemmy_db_views_site = { workspace = true, features = [\"full\"] }\nlemmy_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema = { workspace = true, features = [\"full\"] }\nlemmy_api_utils = { workspace = true, features = [\"full\"] }\nlemmy_db_schema_file = { workspace = true }\nactivitypub_federation = { workspace = true }\nlemmy_email = { workspace = true }\nactix-web = { workspace = true, features = [\"cookies\"] }\nchrono = { workspace = true }\nfutures = { workspace = true }\nreqwest = { workspace = true, features = [\"stream\"] }\nreqwest-middleware = { workspace = true, features = [\"form\", \"query\"] }\nserde = { workspace = true }\nurl = { workspace = true }\ntracing = { workspace = true }\ntokio = { workspace = true }\nfutures-util.workspace = true\nhttp.workspace = true\ndiesel.workspace = true\ndiesel-async.workspace = true\nclokwerk = \"0.4.0\"\nprometheus = { version = \"0.14.0\", features = [\n  \"process\",\n], default-features = false }\nrss = \"2.0.12\"\nactix-web-prom = \"0.10.0\"\nactix-cors = \"0.7.1\"\nrand = \"0.10.0\"\npercent-encoding = \"2.3.2\"\ndiesel-uplete.workspace = true\nlemmy_diesel_utils = { workspace = true }\nrosetta-i18n = { workspace = true }\nstrum = { workspace = true }\nts-rs = { workspace = true, optional = true }\n\n[dev-dependencies]\npretty_assertions.workspace = true\nserial_test.workspace = true\n"
  },
  {
    "path": "crates/routes/src/feeds/mod.rs",
    "content": "mod negotiate_content;\nuse actix_web::{Error, HttpRequest, HttpResponse, Result, error::ErrorBadRequest, web};\nuse chrono::{DateTime, Utc};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{check_private_instance, local_user_view_from_jwt},\n};\nuse lemmy_db_schema::{\n  PersonContentType,\n  source::{\n    community::Community,\n    multi_community::MultiCommunity,\n    notification::Notification,\n    person::Person,\n  },\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::enums::{ListingType, ModlogKind, NotificationType, PostSortType};\nuse lemmy_db_views_modlog::{ModlogView, impls::ModlogQuery};\nuse lemmy_db_views_notification::{NotificationData, NotificationView, impls::NotificationQuery};\nuse lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery;\nuse lemmy_db_views_post::{PostView, impls::PostQuery};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_email::{translations::Lang, user_language};\nuse lemmy_utils::{\n  cache_header::cache_1hour,\n  error::LemmyResult,\n  settings::structs::Settings,\n  utils::markdown::markdown_to_html,\n};\nuse negotiate_content::get_lang_or_negotiate;\nuse rss::{\n  Category,\n  Channel,\n  EnclosureBuilder,\n  Guid,\n  Item,\n  extension::{ExtensionBuilder, ExtensionMap, dublincore::DublinCoreExtension},\n};\nuse serde::Deserialize;\nuse std::{collections::BTreeMap, sync::LazyLock};\n\nconst RSS_FETCH_LIMIT: i64 = 20;\n\n#[derive(Deserialize)]\nstruct Params {\n  sort: Option<PostSortType>,\n  limit: Option<i64>,\n}\n\nimpl Params {\n  fn sort_type(&self) -> PostSortType {\n    self.sort.unwrap_or_default()\n  }\n  fn get_limit(&self) -> i64 {\n    self.limit.unwrap_or(RSS_FETCH_LIMIT)\n  }\n}\n\npub fn config(cfg: &mut web::ServiceConfig) {\n  cfg.service(\n    web::scope(\"/feeds\")\n      .route(\"/u/{user_name}.xml\", web::get().to(get_feed_user))\n      .route(\"/c/{community_name}.xml\", web::get().to(get_feed_community))\n      .route(\n        \"/m/{multi_name}.xml\",\n        web::get().to(get_feed_multi_community),\n      )\n      .route(\"/front/{jwt}.xml\", web::get().to(get_feed_front))\n      .route(\"/modlog/{jwt}.xml\", web::get().to(get_feed_modlog))\n      .route(\"/notifications/{jwt}.xml\", web::get().to(get_feed_notifs))\n      // Also redirect inbox to notifications. This should probably be deprecated tho.\n      .service(web::redirect(\n        \"/inbox/{jwt}.xml\",\n        \"/notifications/{jwt}.xml\",\n      ))\n      .route(\"/all.xml\", web::get().to(get_all_feed).wrap(cache_1hour()))\n      .route(\n        \"/local.xml\",\n        web::get().to(get_local_feed).wrap(cache_1hour()),\n      ),\n  );\n}\n\nstatic RSS_NAMESPACE: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {\n  let mut h = BTreeMap::new();\n  h.insert(\n    \"dc\".to_string(),\n    rss::extension::dublincore::NAMESPACE.to_string(),\n  );\n  h.insert(\n    \"media\".to_string(),\n    \"http://search.yahoo.com/mrss/\".to_string(),\n  );\n  h\n});\n\nasync fn get_all_feed(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let lang = get_lang_or_negotiate(&req, &context).await?;\n\n  get_feed_data(\n    &context,\n    ListingType::All,\n    info.sort_type(),\n    info.get_limit(),\n    lang,\n  )\n  .await\n}\n\nasync fn get_local_feed(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let lang = get_lang_or_negotiate(&req, &context).await?;\n\n  get_feed_data(\n    &context,\n    ListingType::Local,\n    info.sort_type(),\n    info.get_limit(),\n    lang,\n  )\n  .await\n}\n\nasync fn get_feed_data(\n  context: &LemmyContext,\n  listing_type: ListingType,\n  sort_type: PostSortType,\n  limit: i64,\n  lang: Lang,\n) -> Result<HttpResponse, Error> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  check_private_instance(&None, &site_view.local_site)?;\n\n  let posts = PostQuery {\n    listing_type: Some(listing_type),\n    sort: Some(sort_type),\n    limit: Some(limit),\n    ..Default::default()\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await?\n  .items;\n\n  let title = format!(\n    \"{} - {}\",\n    site_view.site.name,\n    if listing_type == ListingType::Local {\n      lang.local()\n    } else {\n      lang.all()\n    }\n  );\n\n  let link = context.settings().get_protocol_and_hostname();\n  let items = create_post_items(posts, context.settings(), lang)?;\n  Ok(send_feed_response(title, link, None, items, site_view))\n}\n\nasync fn get_feed_user(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  name: web::Path<String>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let (name, domain) = split_name(&name);\n\n  let person = Person::read_from_name(&mut context.pool(), name, domain, false)\n    .await?\n    .ok_or(ErrorBadRequest(\"not_found\"))?;\n\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  check_private_instance(&None, &site_view.local_site)?;\n  let lang = get_lang_or_negotiate(&req, &context).await?;\n\n  let content = PersonContentCombinedQuery {\n    creator_id: person.id,\n    type_: Some(PersonContentType::Posts),\n    page_cursor: None,\n    limit: Some(info.get_limit()),\n    no_limit: None,\n  }\n  .list(&mut context.pool(), None, site_view.site.instance_id)\n  .await?\n  .items;\n\n  let posts = content\n    .iter()\n    // Filter map to collect posts\n    .filter_map(|f| f.to_post_view())\n    .cloned()\n    .collect::<Vec<PostView>>();\n\n  let title = format!(\"{} - {}\", site_view.site.name, person.name);\n  let link = person.ap_id.to_string();\n  let items = create_post_items(posts, context.settings(), lang)?;\n  Ok(send_feed_response(\n    title, link, person.bio, items, site_view,\n  ))\n}\n\n/// Takes a user/community name either in the format `name` or `name@example.com`. Splits\n/// it on `@` and returns a tuple of name and optional domain.\nfn split_name(name: &str) -> (&str, Option<&str>) {\n  if let Some(split) = name.split_once('@') {\n    (split.0, Some(split.1))\n  } else {\n    (name, None)\n  }\n}\n\nasync fn get_feed_community(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  name: web::Path<String>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let (name, domain) = split_name(&name);\n  let community = Community::read_from_name(&mut context.pool(), name, domain, false)\n    .await?\n    .ok_or(ErrorBadRequest(\"not_found\"))?;\n\n  if !community.visibility.can_view_without_login() {\n    return Err(ErrorBadRequest(\"not_found\"));\n  }\n\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  check_private_instance(&None, &site_view.local_site)?;\n  let lang = get_lang_or_negotiate(&req, &context).await?;\n\n  let posts = PostQuery {\n    sort: Some(info.sort_type()),\n    community_id: Some(community.id),\n    limit: Some(info.get_limit()),\n    ..Default::default()\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await?\n  .items;\n\n  let title = format!(\"{} - {}\", site_view.site.name, community.name);\n  let link = community.ap_id.to_string();\n  let items = create_post_items(posts, context.settings(), lang)?;\n  Ok(send_feed_response(\n    title,\n    link,\n    community.summary,\n    items,\n    site_view,\n  ))\n}\n\nasync fn get_feed_multi_community(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  name: web::Path<String>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let (name, domain) = split_name(&name);\n  let multi_community = MultiCommunity::read_from_name(&mut context.pool(), name, domain, false)\n    .await?\n    .ok_or(ErrorBadRequest(\"not_found\"))?;\n\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  check_private_instance(&None, &site_view.local_site)?;\n  let lang = get_lang_or_negotiate(&req, &context).await?;\n\n  let posts = PostQuery {\n    sort: Some(info.sort_type()),\n    multi_community_id: Some(multi_community.id),\n    limit: Some(info.get_limit()),\n    ..Default::default()\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await?\n  .items;\n\n  let title = format!(\"{} - {}\", site_view.site.name, multi_community.name);\n  let link = multi_community.ap_id.to_string();\n  let items = create_post_items(posts, context.settings(), lang)?;\n  Ok(send_feed_response(\n    title,\n    link,\n    multi_community.summary,\n    items,\n    site_view,\n  ))\n}\n\nasync fn get_feed_front(\n  req: HttpRequest,\n  web::Query(info): web::Query<Params>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let jwt: String = req.match_info().get(\"jwt\").unwrap_or(\"none\").parse()?;\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_user = local_user_view_from_jwt(&jwt, &context).await?;\n  let lang = user_language(&local_user.local_user);\n\n  check_private_instance(&Some(local_user.clone()), &site_view.local_site)?;\n\n  let posts = PostQuery {\n    listing_type: Some(ListingType::Subscribed),\n    local_user: Some(&local_user.local_user),\n    sort: Some(info.sort_type()),\n    limit: Some(info.get_limit()),\n    ..Default::default()\n  }\n  .list(&site_view.site, &mut context.pool())\n  .await?\n  .items;\n\n  let title = format!(\"{} - {}\", site_view.site.name, lang.subscribed());\n  let link = context.settings().get_protocol_and_hostname();\n  let items = create_post_items(posts, context.settings(), lang)?;\n  Ok(send_feed_response(title, link, None, items, site_view))\n}\n\nfn send_feed_response(\n  title: String,\n  link: String,\n  description: Option<String>,\n  items: Vec<Item>,\n  site_view: SiteView,\n) -> HttpResponse {\n  let mut channel = Channel {\n    namespaces: RSS_NAMESPACE.clone(),\n    title,\n    link,\n    items,\n    ..Default::default()\n  };\n\n  let description = description.or(site_view.site.summary);\n  if let Some(desc) = description {\n    channel.set_description(markdown_to_html(&desc));\n  }\n\n  HttpResponse::Ok()\n    .content_type(\"application/rss+xml\")\n    .body(channel.to_string())\n}\n\nasync fn get_feed_notifs(\n  req: HttpRequest,\n  _info: web::Query<Params>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let jwt: String = req.match_info().get(\"jwt\").unwrap_or(\"none\").parse()?;\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_user = local_user_view_from_jwt(&jwt, &context).await?;\n  let show_bot_accounts = Some(local_user.local_user.show_bot_accounts);\n  let lang = user_language(&local_user.local_user);\n\n  check_private_instance(&Some(local_user.clone()), &site_view.local_site)?;\n\n  let notifications = NotificationQuery {\n    show_bot_accounts,\n    ..Default::default()\n  }\n  .list(&mut context.pool(), &local_user.person)\n  .await?\n  .items;\n\n  let protocol_and_hostname = context.settings().get_protocol_and_hostname();\n  let title = format!(\"{} - {}\", site_view.site.name, lang.notifications());\n  let link = format!(\"{protocol_and_hostname}/notifications\");\n  let items = create_reply_and_mention_items(notifications, &context, lang)?;\n  Ok(send_feed_response(title, link, None, items, site_view))\n}\n\n/// Gets your ModeratorView modlog\nasync fn get_feed_modlog(\n  req: HttpRequest,\n  _info: web::Query<Params>,\n  context: web::Data<LemmyContext>,\n) -> Result<HttpResponse, Error> {\n  let jwt: String = req.match_info().get(\"jwt\").unwrap_or(\"none\").parse()?;\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n  let local_user = local_user_view_from_jwt(&jwt, &context).await?;\n  let lang = user_language(&local_user.local_user);\n  check_private_instance(&Some(local_user.clone()), &site_view.local_site)?;\n\n  let modlog = ModlogQuery {\n    listing_type: Some(ListingType::ModeratorView),\n    local_user: Some(&local_user.local_user),\n    hide_modlog_names: Some(false),\n    ..Default::default()\n  }\n  .list(&mut context.pool())\n  .await?\n  .items;\n\n  let protocol_and_hostname = context.settings().get_protocol_and_hostname();\n  let title = format!(\"{} - {}\", local_user.person.name, lang.modlog());\n  let link = format!(\"{protocol_and_hostname}/modlog\");\n  let items = create_modlog_items(modlog, context.settings(), lang)?;\n  Ok(send_feed_response(title, link, None, items, site_view))\n}\n\nfn create_reply_and_mention_items(\n  notifs: Vec<NotificationView>,\n  context: &LemmyContext,\n  lang: Lang,\n) -> LemmyResult<Vec<Item>> {\n  let reply_items: Vec<Item> = notifs\n    .iter()\n    .flat_map(|v| {\n      match &v.data {\n        NotificationData::Post(post) => {\n          let mention_url = post.post.local_url(context.settings()).ok()?;\n          Some(build_item(\n            &post.creator,\n            &post.post.published_at,\n            mention_url.as_str(),\n            &post.post.body.clone().unwrap_or_default(),\n            &v.notification,\n            context.settings(),\n            lang,\n          ))\n        }\n        NotificationData::Comment(comment) => {\n          let reply_url = comment.comment.local_url(context.settings()).ok()?;\n          Some(build_item(\n            &comment.creator,\n            &comment.comment.published_at,\n            reply_url.as_str(),\n            &comment.comment.content,\n            &v.notification,\n            context.settings(),\n            lang,\n          ))\n        }\n        NotificationData::PrivateMessage(pm) => {\n          let notifs_url = format!(\n            \"{}/notifications\",\n            context.settings().get_protocol_and_hostname()\n          );\n          Some(build_item(\n            &pm.creator,\n            &pm.private_message.published_at,\n            &notifs_url,\n            &pm.private_message.content,\n            &v.notification,\n            context.settings(),\n            lang,\n          ))\n        }\n        // skip modlog items\n        NotificationData::ModAction(_) => None,\n      }\n    })\n    .collect::<LemmyResult<Vec<Item>>>()?;\n\n  Ok(reply_items)\n}\n\nfn create_modlog_items(\n  modlog: Vec<ModlogView>,\n  settings: &Settings,\n  lang: Lang,\n) -> LemmyResult<Vec<Item>> {\n  // All of these go to your modlog url\n  let modlog_url = format!(\n    \"{}/modlog?listing_type=ModeratorView\",\n    settings.get_protocol_and_hostname()\n  );\n\n  let modlog_items: Vec<Item> = modlog\n    .iter()\n    .map(|r| {\n      let u = |x: Option<String>| x.unwrap_or_else(|| \"unknown\".to_string());\n      let target_instance_domain = u(r.target_instance.as_ref().map(|i| i.domain.clone()));\n      let target_person_name = u(r.target_person.as_ref().map(|i| i.name.clone()));\n      let target_community_name = u(r.target_community.as_ref().map(|i| i.name.clone()));\n      let target_post_name = u(r.target_post.as_ref().map(|i| i.name.clone()));\n      let target_comment_content = u(r.target_comment.as_ref().map(|i| i.content.clone()));\n      match r.modlog.kind {\n        ModlogKind::AdminAllowInstance => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.admin_disallowed_instance_x(&target_instance_domain)\n          } else {\n            lang.admin_allowed_instance_x(&target_instance_domain)\n          },\n          settings,\n        ),\n        ModlogKind::AdminBlockInstance => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.admin_unblocked_instance_x(&target_instance_domain)\n          } else {\n            lang.admin_blocked_instance_x(&target_instance_domain)\n          },\n          settings,\n        ),\n        ModlogKind::AdminPurgeComment => {\n          build_modlog_item(r, &modlog_url, lang.admin_purged_comment(), settings)\n        }\n        ModlogKind::AdminPurgeCommunity => {\n          build_modlog_item(r, &modlog_url, lang.admin_purged_community(), settings)\n        }\n        ModlogKind::AdminPurgePerson => {\n          build_modlog_item(r, &modlog_url, lang.admin_purged_person(), settings)\n        }\n        ModlogKind::AdminPurgePost => {\n          build_modlog_item(r, &modlog_url, lang.admin_purged_post(), settings)\n        }\n        ModlogKind::AdminAdd => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.added_admin_x(&target_person_name)\n          } else {\n            lang.removed_admin_x(&target_person_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModAddToCommunity => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.added_mod_x_to_community_y(&target_community_name, &target_person_name)\n          } else {\n            lang.removed_mod_x_from_community_y(&target_community_name, &target_person_name)\n          },\n          settings,\n        ),\n        ModlogKind::AdminBan => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.unbanned_user_x(&target_person_name)\n          } else {\n            lang.banned_user_x(&target_person_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModBanFromCommunity => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.unbanned_user_x_from_community_y(&target_community_name, &target_person_name)\n          } else {\n            lang.banned_user_x_from_community_y(&target_community_name, &target_person_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModFeaturePostCommunity => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.featured_post_x(&target_post_name)\n          } else {\n            lang.unfeatured_post_x(&target_post_name)\n          },\n          settings,\n        ),\n        ModlogKind::AdminFeaturePostSite => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.featured_post_x(&target_post_name)\n          } else {\n            lang.unfeatured_post_x(&target_post_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModChangeCommunityVisibility => build_modlog_item(\n          r,\n          &modlog_url,\n          lang.changed_community_x_visibility(&target_community_name),\n          settings,\n        ),\n        ModlogKind::ModLockPost => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.unlocked_post_x(&target_post_name)\n          } else {\n            lang.locked_post_x(&target_post_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModRemoveComment => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.restored_comment_x(&target_comment_content)\n          } else {\n            lang.removed_comment_x(&target_comment_content)\n          },\n          settings,\n        ),\n        ModlogKind::AdminRemoveCommunity => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.restored_community_x(&target_community_name)\n          } else {\n            lang.removed_community_x(&target_community_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModRemovePost => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.restored_post_x(&target_post_name)\n          } else {\n            lang.removed_post_x(&target_post_name)\n          },\n          settings,\n        ),\n        ModlogKind::ModTransferCommunity => build_modlog_item(\n          r,\n          &modlog_url,\n          lang.transferred_community_x_to_user_y(&target_community_name, &target_person_name),\n          settings,\n        ),\n        ModlogKind::ModLockComment => build_modlog_item(\n          r,\n          &modlog_url,\n          if r.modlog.is_revert {\n            lang.unlocked_comment_x(&target_comment_content)\n          } else {\n            lang.locked_comment_x(&target_comment_content)\n          },\n          settings,\n        ),\n        ModlogKind::ModWarnComment => build_modlog_item(\n          r,\n          &modlog_url,\n          format!(\n            \"Warned user {} about comment {}\",\n            &&target_person_name, &&target_comment_content\n          ),\n          settings,\n        ),\n        ModlogKind::ModWarnPost => build_modlog_item(\n          r,\n          &modlog_url,\n          format!(\n            \"Warned user {} about post {}\",\n            &&target_person_name, &&target_post_name\n          ),\n          settings,\n        ),\n      }\n    })\n    .collect::<LemmyResult<Vec<Item>>>()?;\n\n  Ok(modlog_items)\n}\n\nfn build_modlog_item<T: Into<String>>(\n  view: &ModlogView,\n  url: &str,\n  action: T,\n  settings: &Settings,\n) -> LemmyResult<Item> {\n  let guid = Some(Guid {\n    permalink: true,\n    value: view.modlog.id.0.to_string(),\n  });\n  let author = if let Some(mod_) = &view.moderator {\n    Some(format!(\n      \"/u/{} <a href=\\\"{}\\\">(link)</a>\",\n      mod_.name,\n      mod_.actor_url(settings)?\n    ))\n  } else {\n    None\n  };\n\n  Ok(Item {\n    title: Some(action.into()),\n    author,\n    pub_date: Some(view.modlog.published_at.to_rfc2822()),\n    link: Some(url.to_owned()),\n    guid,\n    description: view.modlog.reason.clone(),\n    ..Default::default()\n  })\n}\n\nfn build_item(\n  creator: &Person,\n  published: &DateTime<Utc>,\n  url: &str,\n  content: &str,\n  notification: &Notification,\n  settings: &Settings,\n  lang: Lang,\n) -> LemmyResult<Item> {\n  let guid = Some(Guid {\n    permalink: true,\n    value: url.to_owned(),\n  });\n  let description = Some(markdown_to_html(content));\n\n  let title = match notification.kind {\n    NotificationType::Mention => lang.mention_from_x(creator.name.clone()),\n    NotificationType::Reply => lang.reply_from_x(creator.name.clone()),\n    NotificationType::Subscribed => lang.subscribed().to_string(),\n    NotificationType::PrivateMessage => lang.private_message_from_x(creator.name.clone()),\n    NotificationType::ModAction => lang.mod_action().to_string(),\n  };\n  Ok(Item {\n    title: Some(title),\n    author: Some(format!(\n      \"/u/{} <a href=\\\"{}\\\">(link)</a>\",\n      creator.name,\n      creator.actor_url(settings)?\n    )),\n    pub_date: Some(published.to_rfc2822()),\n    comments: Some(url.to_owned()),\n    link: Some(url.to_owned()),\n    guid,\n    description,\n    ..Default::default()\n  })\n}\n\nfn create_post_items(\n  posts: Vec<PostView>,\n  settings: &Settings,\n  lang: Lang,\n) -> LemmyResult<Vec<Item>> {\n  let mut items: Vec<Item> = Vec::new();\n\n  for p in posts {\n    let post_url = p.post.local_url(settings)?;\n    let community_url = &p.community.actor_url(settings)?;\n    let dublin_core_ext = Some(DublinCoreExtension {\n      creators: vec![p.creator.ap_id.to_string()],\n      ..DublinCoreExtension::default()\n    });\n    let guid = Some(Guid {\n      permalink: true,\n      value: post_url.to_string(),\n    });\n    let mut description = lang.submitted_post_with_meta_info(\n      p.creator.actor_url(settings)?,\n      &p.community.name,\n      community_url,\n      &p.creator.name,\n      p.post.comments,\n      p.post.score,\n      &post_url,\n    );\n\n    // If its a url post, add it to the description\n    // and see if we can parse it as a media enclosure.\n    let enclosure_opt = p.post.url.map(|url| {\n      let mime_type = p\n        .post\n        .url_content_type\n        .unwrap_or_else(|| \"application/octet-stream\".to_string());\n\n      // If the url directly links to an image, wrap it in an <img> tag for display.\n      let link_html = if mime_type.starts_with(\"image/\") {\n        format!(\"<br><a href=\\\"{url}\\\"><img src=\\\"{url}\\\"/></a>\")\n      } else {\n        format!(\"<br><a href=\\\"{url}\\\">{url}</a>\")\n      };\n      description.push_str(&link_html);\n\n      let mut enclosure_bld = EnclosureBuilder::default();\n      enclosure_bld.url(url.as_str().to_string());\n      enclosure_bld.mime_type(mime_type);\n      enclosure_bld.length(\"0\".to_string());\n      enclosure_bld.build()\n    });\n\n    if let Some(body) = p.post.body {\n      let html = markdown_to_html(&body);\n      description.push_str(&html);\n    }\n\n    let mut extensions = ExtensionMap::new();\n\n    // If there's a thumbnail URL, add a media:content tag to display it.\n    // See https://www.rssboard.org/media-rss#media-content for details.\n    if let Some(url) = p.post.thumbnail_url {\n      let mut thumbnail_ext = ExtensionBuilder::default();\n      thumbnail_ext.name(\"media:content\".to_string());\n      thumbnail_ext.attrs(BTreeMap::from([\n        (\"url\".to_string(), url.to_string()),\n        (\"medium\".to_string(), \"image\".to_string()),\n      ]));\n\n      extensions.insert(\n        \"media\".to_string(),\n        BTreeMap::from([(\"content\".to_string(), vec![thumbnail_ext.build()])]),\n      );\n    }\n    let category = Category {\n      name: p.community.title,\n      domain: Some(p.community.ap_id.to_string()),\n    };\n\n    let i = Item {\n      title: Some(p.post.name),\n      pub_date: Some(p.post.published_at.to_rfc2822()),\n      comments: Some(post_url.to_string()),\n      guid,\n      description: Some(description),\n      dublin_core_ext,\n      link: Some(post_url.to_string()),\n      extensions,\n      enclosure: enclosure_opt,\n      categories: vec![category],\n      ..Default::default()\n    };\n\n    items.push(i);\n  }\n\n  Ok(items)\n}\n"
  },
  {
    "path": "crates/routes/src/feeds/negotiate_content.rs",
    "content": "use actix_web::{Error, HttpRequest, http::header::*, web};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{local_user_view_from_jwt, read_auth_token},\n};\nuse lemmy_email::{translations::Lang, user_language};\nuse rosetta_i18n::{Language, LanguageId};\n\npub(crate) async fn get_lang_or_negotiate(\n  req: &HttpRequest,\n  context: &web::Data<LemmyContext>,\n) -> Result<Lang, Error> {\n  let jwt = read_auth_token(req)?;\n\n  let lang = if let Some(jwt) = jwt {\n    let local_user_view = local_user_view_from_jwt(&jwt, context).await?;\n    user_language(&local_user_view.local_user)\n  } else if req.headers().contains_key(ACCEPT_LANGUAGE) {\n    negotiate_lang(req).unwrap_or(Lang::En)\n  } else {\n    Lang::En\n  };\n  Ok(lang)\n}\n\nfn negotiate_lang(req: &HttpRequest) -> Option<Lang> {\n  let client_langs = AcceptLanguage::parse(req).ok()?;\n\n  client_langs.ranked().iter().find_map(|cl| {\n    let l = cl.item().map(|l| LanguageId::new(l.primary_language()))?;\n    Lang::from_language_id(&l)\n  })\n}\n\n#[cfg(test)]\n#[expect(clippy::unwrap_used)]\nmod tests {\n  use super::*;\n  use actix_web::test::TestRequest;\n\n  fn parse_lang_items(\n    accept_language_header_value: &str,\n  ) -> Vec<QualityItem<Preference<LanguageTag>>> {\n    accept_language_header_value\n      .split(',')\n      .map(|s| s.parse().unwrap())\n      .collect()\n  }\n\n  #[test]\n  fn test_negotiate_language_lang_supported_by_server() {\n    let req = TestRequest::default()\n      .insert_header(AcceptLanguage(parse_lang_items(\n        \"fj, sm, lo, da, en-GB;q=0.8, en;q=0.7\",\n      )))\n      .to_http_request();\n\n    let resolved_lang = negotiate_lang(&req).unwrap();\n\n    // This test will fail if support for Fijian language is introduced\n    // Fix: Remove it and simply move one of the other (rare) languages to the top of the list\n    assert_eq!(resolved_lang, Lang::Da);\n  }\n\n  #[test]\n  fn test_negotiate_language_lang_unsupported_by_server() {\n    let req = TestRequest::default()\n      .insert_header(AcceptLanguage(parse_lang_items(\"fj, sm, lo, km\")))\n      .to_http_request();\n\n    let resolved_lang = negotiate_lang(&req);\n\n    // This test will fail if support for Fijian language is introduced\n    // Fix: Remove it and simply move one of the other (rare) languages to the top of the list\n    assert!(resolved_lang.is_none());\n  }\n\n  #[test]\n  fn test_negotiate_language_wildcard_alone() {\n    let req = TestRequest::default()\n      .insert_header(AcceptLanguage(parse_lang_items(\"*\")))\n      .to_http_request();\n\n    let resolved_lang = negotiate_lang(&req);\n\n    assert!(resolved_lang.is_none());\n  }\n\n  #[test]\n  fn test_negotiate_language_wildcard_with_langs_after() {\n    let req = TestRequest::default()\n      .insert_header(AcceptLanguage(parse_lang_items(\"*, fr\")))\n      .to_http_request();\n\n    let resolved_lang = negotiate_lang(&req);\n\n    assert!(resolved_lang.is_some());\n  }\n}\n"
  },
  {
    "path": "crates/routes/src/images/delete.rs",
    "content": "use super::utils::delete_old_image;\nuse actix_web::web::*;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  request::{delete_image_alias, purge_image_from_pictrs},\n  utils::{is_admin, is_mod_or_admin},\n};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityUpdateForm},\n  images::LocalImage,\n  person::{Person, PersonUpdateForm},\n  site::{Site, SiteUpdateForm},\n};\nuse lemmy_db_views_community::api::CommunityIdQuery;\nuse lemmy_db_views_local_image::api::DeleteImageParams;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::{SiteView, api::SuccessResponse};\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::LemmyResult;\n\npub async fn delete_site_icon(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let site = SiteView::read_local(&mut context.pool()).await?.site;\n  is_admin(&local_user_view)?;\n\n  delete_old_image(&site.icon, &context).await?;\n\n  let form = SiteUpdateForm {\n    icon: Some(None),\n    ..Default::default()\n  };\n  Site::update(&mut context.pool(), site.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\npub async fn delete_site_banner(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let site = SiteView::read_local(&mut context.pool()).await?.site;\n  is_admin(&local_user_view)?;\n\n  delete_old_image(&site.banner, &context).await?;\n\n  let form = SiteUpdateForm {\n    banner: Some(None),\n    ..Default::default()\n  };\n  Site::update(&mut context.pool(), site.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\npub async fn delete_community_icon(\n  Json(data): Json<CommunityIdQuery>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let community = Community::read(&mut context.pool(), data.id).await?;\n  is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?;\n\n  delete_old_image(&community.icon, &context).await?;\n\n  let form = CommunityUpdateForm {\n    icon: Some(None),\n    ..Default::default()\n  };\n  Community::update(&mut context.pool(), community.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\npub async fn delete_community_banner(\n  Json(data): Json<CommunityIdQuery>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  let community = Community::read(&mut context.pool(), data.id).await?;\n  is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?;\n\n  delete_old_image(&community.icon, &context).await?;\n\n  let form = CommunityUpdateForm {\n    icon: Some(None),\n    ..Default::default()\n  };\n  Community::update(&mut context.pool(), community.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\npub async fn delete_user_avatar(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  delete_old_image(&local_user_view.person.avatar, &context).await?;\n\n  let form = PersonUpdateForm {\n    avatar: Some(None),\n    ..Default::default()\n  };\n  Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\npub async fn delete_user_banner(\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  delete_old_image(&local_user_view.person.banner, &context).await?;\n\n  let form = PersonUpdateForm {\n    banner: Some(None),\n    ..Default::default()\n  };\n  Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\n/// Deletes an image for a specific user.\npub async fn delete_image(\n  Json(data): Json<DeleteImageParams>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  LocalImage::validate_by_alias_and_user(\n    &mut context.pool(),\n    &data.filename,\n    local_user_view.person.id,\n  )\n  .await?;\n\n  delete_image_alias(&data.filename, &context).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n\n/// Deletes any image, only for admins.\npub async fn delete_image_admin(\n  Json(data): Json<DeleteImageParams>,\n  context: Data<LemmyContext>,\n  local_user_view: LocalUserView,\n) -> LemmyResult<Json<SuccessResponse>> {\n  is_admin(&local_user_view)?;\n\n  // Use purge, since it should remove any other aliases.\n  purge_image_from_pictrs(&data.filename, &context).await?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/routes/src/images/download.rs",
    "content": "use super::utils::{adapt_request, convert_header};\nuse actix_web::{\n  HttpRequest,\n  HttpResponse,\n  Responder,\n  body::{BodyStream, BoxBody},\n  http::StatusCode,\n  web::{Data, *},\n};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::source::images::RemoteImage;\nuse lemmy_db_views_local_image::api::{ImageGetParams, ImageProxyParams};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};\nuse std::str::FromStr;\nuse strum::{Display, EnumString};\nuse url::Url;\n\npub async fn get_image(\n  filename: Path<String>,\n  Query(params): Query<ImageGetParams>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let name = &filename.into_inner();\n\n  // If there are no query params, the URL is original\n  let pictrs_url = context.settings().pictrs()?.url;\n  let processed_url = if params.file_type.is_none() && params.max_size.is_none() {\n    format!(\"{}image/original/{}\", pictrs_url, name)\n  } else {\n    let file_type = file_type(params.file_type, name).unwrap_or_default();\n\n    let mut url = format!(\"{}image/process.{}?src={}\", pictrs_url, file_type, name);\n\n    if let Some(size) = params.max_size {\n      url = format!(\"{url}&thumbnail={size}\",);\n    }\n    url\n  };\n\n  do_get_image(processed_url, req, &context).await\n}\n\npub async fn image_proxy(\n  Query(params): Query<ImageProxyParams>,\n  req: HttpRequest,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Either<HttpResponse<()>, HttpResponse<BoxBody>>> {\n  let url = Url::parse(&params.url)?;\n  let encoded_url = utf8_percent_encode(&params.url, NON_ALPHANUMERIC).to_string();\n\n  // Check that url corresponds to a federated image so that this can't be abused as a proxy\n  // for arbitrary purposes.\n  RemoteImage::validate(&mut context.pool(), url.clone().into()).await?;\n\n  let pictrs_config = context.settings().pictrs()?;\n  let processed_url = if params.file_type.is_none() && params.max_size.is_none() {\n    format!(\"{}image/original?proxy={}\", pictrs_config.url, encoded_url)\n  } else {\n    let file_type = file_type(params.file_type, url.path()).unwrap_or_default();\n\n    let mut url = format!(\n      \"{}image/process.{}?proxy={}\",\n      pictrs_config.url, file_type, encoded_url\n    );\n\n    if let Some(size) = params.max_size {\n      url = format!(\"{url}&thumbnail={size}\",);\n    }\n    url\n  };\n\n  let proxy_bypass_domains = SiteView::read_local(&mut context.pool())\n    .await?\n    .local_site\n    .image_proxy_bypass_domains\n    .map(|e| e.split(',').map(ToString::to_string).collect::<Vec<_>>())\n    .unwrap_or_default();\n\n  let bypass_proxy = proxy_bypass_domains\n    .iter()\n    .any(|s| url.domain().is_some_and(|d| d == s));\n  if bypass_proxy {\n    // Bypass proxy and redirect user to original image\n    Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req)))\n  } else {\n    // Proxy the image data through Lemmy\n    Ok(Either::Right(\n      do_get_image(processed_url, req, &context).await?,\n    ))\n  }\n}\n\npub(super) async fn do_get_image(\n  url: String,\n  req: HttpRequest,\n  context: &LemmyContext,\n) -> LemmyResult<HttpResponse> {\n  let mut client_req = adapt_request(&req, url, context);\n\n  if let Some(addr) = req.head().peer_addr {\n    client_req = client_req.header(\"X-Forwarded-For\", addr.to_string());\n  }\n\n  if let Some(addr) = req.head().peer_addr {\n    client_req = client_req.header(\"X-Forwarded-For\", addr.to_string());\n  }\n\n  let res = client_req.send().await?;\n\n  if res.status() == http::StatusCode::NOT_FOUND {\n    return Ok(HttpResponse::NotFound().finish());\n  }\n\n  let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?);\n\n  for (name, value) in res.headers().iter().filter(|(h, _)| *h != \"connection\") {\n    client_res.insert_header(convert_header(name, value));\n  }\n\n  Ok(client_res.body(BodyStream::new(res.bytes_stream())))\n}\n\n#[derive(EnumString, Display, PartialEq, Debug, Default)]\n#[strum(ascii_case_insensitive, serialize_all = \"snake_case\")]\nenum PictrsFileType {\n  Apng,\n  Avif,\n  Gif,\n  #[default]\n  Jpg,\n  Jxl,\n  Png,\n  Webp,\n}\n\n/// Take file type from param, name, or use jpg if nothing is given\nfn file_type(file_type: Option<String>, name: &str) -> LemmyResult<PictrsFileType> {\n  let type_str = file_type\n    .clone()\n    .unwrap_or_else(|| name.split('.').next_back().unwrap_or(\"jpg\").to_string());\n\n  PictrsFileType::from_str(&type_str).with_lemmy_type(LemmyErrorType::NotAnImageType)\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::images::download::{PictrsFileType, file_type};\n  use lemmy_utils::error::LemmyResult;\n\n  #[tokio::test]\n  async fn image_file_type_tests() -> LemmyResult<()> {\n    // Make sure files type outputs are getting lower-cased\n    assert_eq!(PictrsFileType::Jpg.to_string(), \"jpg\".to_string());\n\n    let file_url = \"a8a7f07f-3ef2-40fa-849c-ae952f68f3ec.jpg\";\n\n    // Make sure wrong-cased file type requests are okay\n    assert_eq!(\n      PictrsFileType::Jpg,\n      file_type(Some(\"JPg\".to_string()), file_url)?\n    );\n\n    // Make sure converts are working\n    assert_eq!(\n      PictrsFileType::Avif,\n      file_type(Some(\"AVif\".to_string()), file_url)?\n    );\n\n    // Make sure wrong file type requests are okay with unwrap_or_default\n    assert_eq!(\n      PictrsFileType::Jpg,\n      file_type(Some(\"jpeg\".to_string()), file_url).unwrap_or_default()\n    );\n    assert_eq!(\n      PictrsFileType::Jpg,\n      file_type(Some(\"nonsense\".to_string()), file_url).unwrap_or_default()\n    );\n\n    // Make sure missing file type requests are okay\n    assert_eq!(PictrsFileType::Jpg, file_type(None, file_url)?);\n\n    // jpeg\n    let file_url = \"a8a7f07f-3ef2-40fa-849c-ae952f68f3ec.jpeg\";\n\n    // Make sure jpeg one is okay\n    assert_eq!(\n      PictrsFileType::Jpg,\n      file_type(None, file_url).unwrap_or_default()\n    );\n\n    // Make sure proxy ones are okay\n    let proxy_url = \"https://test.tld/pictrs/image/6d3b2f3f-7b29-4d9a-868e-b269423f4d6c.WEbP\";\n    assert_eq!(PictrsFileType::Webp, file_type(None, proxy_url)?);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/routes/src/images/mod.rs",
    "content": "use actix_web::web::*;\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_views_site::api::SuccessResponse;\nuse lemmy_utils::error::LemmyResult;\n\npub mod delete;\npub mod download;\npub mod upload;\nmod utils;\n\npub async fn pictrs_health(context: Data<LemmyContext>) -> LemmyResult<Json<SuccessResponse>> {\n  let pictrs_config = context.settings().pictrs()?;\n  let url = format!(\"{}healthz\", pictrs_config.url);\n\n  context\n    .pictrs_client()\n    .get(url)\n    .send()\n    .await?\n    .error_for_status()?;\n\n  Ok(Json(SuccessResponse::default()))\n}\n"
  },
  {
    "path": "crates/routes/src/images/upload.rs",
    "content": "use super::utils::{adapt_request, delete_old_image, make_send};\nuse UploadType::*;\nuse actix_web::{self, HttpRequest, web::*};\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  request::PictrsResponse,\n  utils::{is_admin, is_mod_or_admin},\n};\nuse lemmy_db_schema::source::{\n  community::{Community, CommunityUpdateForm},\n  images::{LocalImage, LocalImageForm},\n  local_site::LocalSite,\n  person::{Person, PersonUpdateForm},\n  site::{Site, SiteUpdateForm},\n};\nuse lemmy_db_views_community::api::CommunityIdQuery;\nuse lemmy_db_views_local_image::api::UploadImageResponse;\nuse lemmy_db_views_local_user::LocalUserView;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::traits::Crud;\nuse lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse reqwest::Body;\nuse std::time::Duration;\n\npub enum UploadType {\n  Avatar,\n  Banner,\n  Other,\n}\n\npub async fn upload_image(\n  req: HttpRequest,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  if local_site.image_upload_disabled {\n    return Err(LemmyErrorType::ImageUploadDisabled.into());\n  }\n\n  Ok(Json(\n    do_upload_image(req, body, Other, &local_user_view, &local_site, &context).await?,\n  ))\n}\n\npub async fn upload_user_avatar(\n  req: HttpRequest,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&local_user_view.person.avatar, &context).await?;\n\n  let form = PersonUpdateForm {\n    avatar: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;\n\n  Ok(Json(image))\n}\n\npub async fn upload_user_banner(\n  req: HttpRequest,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&local_user_view.person.banner, &context).await?;\n\n  let form = PersonUpdateForm {\n    banner: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;\n\n  Ok(Json(image))\n}\n\npub async fn upload_community_icon(\n  req: HttpRequest,\n  query: Query<CommunityIdQuery>,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let community: Community = Community::read(&mut context.pool(), query.id).await?;\n  is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?;\n\n  let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&community.icon, &context).await?;\n\n  let form = CommunityUpdateForm {\n    icon: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Community::update(&mut context.pool(), community.id, &form).await?;\n\n  Ok(Json(image))\n}\n\npub async fn upload_community_banner(\n  req: HttpRequest,\n  query: Query<CommunityIdQuery>,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;\n\n  let community: Community = Community::read(&mut context.pool(), query.id).await?;\n  is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?;\n\n  let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&community.banner, &context).await?;\n\n  let form = CommunityUpdateForm {\n    banner: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Community::update(&mut context.pool(), community.id, &form).await?;\n\n  Ok(Json(image))\n}\n\npub async fn upload_site_icon(\n  req: HttpRequest,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  is_admin(&local_user_view)?;\n\n  let SiteView {\n    site, local_site, ..\n  } = SiteView::read_local(&mut context.pool()).await?;\n\n  let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&site.icon, &context).await?;\n\n  let form = SiteUpdateForm {\n    icon: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Site::update(&mut context.pool(), site.id, &form).await?;\n\n  Ok(Json(image))\n}\n\npub async fn upload_site_banner(\n  req: HttpRequest,\n  body: Payload,\n  local_user_view: LocalUserView,\n  context: Data<LemmyContext>,\n) -> LemmyResult<Json<UploadImageResponse>> {\n  is_admin(&local_user_view)?;\n\n  let SiteView {\n    site, local_site, ..\n  } = SiteView::read_local(&mut context.pool()).await?;\n\n  let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?;\n  delete_old_image(&site.banner, &context).await?;\n\n  let form = SiteUpdateForm {\n    banner: Some(Some(image.image_url.clone().into())),\n    ..Default::default()\n  };\n  Site::update(&mut context.pool(), site.id, &form).await?;\n\n  Ok(Json(image))\n}\n\nasync fn do_upload_image(\n  req: HttpRequest,\n  body: Payload,\n  upload_type: UploadType,\n  local_user_view: &LocalUserView,\n  local_site: &LocalSite,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<UploadImageResponse> {\n  let pictrs_url = context.settings().pictrs()?.url;\n  let max_upload_size = local_site.image_max_upload_size.to_string();\n  let image_url = format!(\"{}image\", pictrs_url);\n\n  let mut client_req = adapt_request(&req, image_url, context);\n\n  // Set pictrs parameters to downscale images and restrict file types.\n  // https://git.asonix.dog/asonix/pict-rs/#api\n  client_req = match upload_type {\n    Avatar => {\n      let max_size = local_site.image_max_avatar_size.to_string();\n      client_req.query(&[\n        (\"resize\", max_size.as_ref()),\n        (\"allow_animation\", \"false\"),\n        (\"allow_video\", \"false\"),\n      ])\n    }\n    Banner => {\n      let max_size = local_site.image_max_banner_size.to_string();\n      client_req.query(&[\n        (\"resize\", max_size.as_ref()),\n        (\"allow_animation\", \"false\"),\n        (\"allow_video\", \"false\"),\n      ])\n    }\n    Other => {\n      let mut query = vec![(\n        \"allow_video\",\n        local_site.image_allow_video_uploads.to_string(),\n      )];\n      query.push((\"resize\", max_upload_size));\n      client_req.query(&query)\n    }\n  };\n  if let Some(addr) = req.head().peer_addr {\n    client_req = client_req.header(\"X-Forwarded-For\", addr.to_string())\n  };\n  // Make HTTP request to pict-rs with the user provided image data.\n  let res = client_req\n    .timeout(Duration::from_secs(\n      local_site.image_upload_timeout_seconds.try_into()?,\n    ))\n    .body(Body::wrap_stream(make_send(body)))\n    .send()\n    .await\n    // Dont check for status code here and dont call `error_for_status()`. If the upload failed,\n    // this is handled below as `images.files` is empty.\n    .with_lemmy_type(LemmyErrorType::PictrsInvalidImageUpload(\n      \"HTTP request to pict-rs failed\".to_string(),\n    ))?;\n\n  let mut images = res.json::<PictrsResponse>().await?;\n  for image in &images.files {\n    // Pictrs allows uploading multiple images in a single request. Lemmy doesnt need this,\n    // but still a user may upload multiple and so we need to store all links in db for\n    // to allow deletion via web ui.\n    let form = LocalImageForm {\n      pictrs_alias: image.file.clone(),\n      person_id: local_user_view.person.id,\n      thumbnail_for_post_id: None,\n    };\n\n    let protocol_and_hostname = context.settings().get_protocol_and_hostname();\n    let thumbnail_url = image.image_url(&protocol_and_hostname)?;\n\n    // Also store the details for the image\n    let details_form = image.details.build_image_details_form(&thumbnail_url);\n    LocalImage::create(&mut context.pool(), &form, &details_form).await?;\n  }\n  let image = images\n    .files\n    .pop()\n    .ok_or(LemmyErrorType::PictrsInvalidImageUpload(images.msg))?;\n\n  let url = image.image_url(&context.settings().get_protocol_and_hostname())?;\n  Ok(UploadImageResponse {\n    image_url: url,\n    filename: image.file,\n  })\n}\n"
  },
  {
    "path": "crates/routes/src/images/utils.rs",
    "content": "use actix_web::{\n  HttpRequest,\n  http::{\n    Method,\n    header::{ACCEPT_ENCODING, HOST, HeaderName},\n  },\n  web::Data,\n};\nuse diesel::NotFound;\nuse futures::stream::{Stream, StreamExt};\nuse http::HeaderValue;\nuse lemmy_api_utils::{context::LemmyContext, request::delete_image_alias};\nuse lemmy_diesel_utils::dburl::DbUrl;\nuse lemmy_utils::{REQWEST_TIMEOUT, error::LemmyResult};\nuse reqwest_middleware::RequestBuilder;\n\npub(super) fn adapt_request(\n  request: &HttpRequest,\n  url: String,\n  context: &LemmyContext,\n) -> RequestBuilder {\n  // remove accept-encoding header so that pictrs doesn't compress the response\n  const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST];\n\n  let client_request = context\n    .pictrs_client()\n    .request(convert_method(request.method()), url)\n    .timeout(REQWEST_TIMEOUT);\n\n  request\n    .headers()\n    .iter()\n    .fold(client_request, |client_req, (key, value)| {\n      if INVALID_HEADERS.contains(key) {\n        client_req\n      } else {\n        // TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0\n        client_req.header(key.as_str(), value.as_bytes())\n      }\n    })\n}\n\npub(super) fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static\nwhere\n  S: Stream + Unpin + 'static,\n  S::Item: Send,\n{\n  // NOTE: the 8 here is arbitrary\n  let (tx, rx) = tokio::sync::mpsc::channel(8);\n\n  // NOTE: spawning stream into a new task can potentially hit this bug:\n  // - https://github.com/actix/actix-web/issues/1679\n  //\n  // Since 4.0.0-beta.2 this issue is incredibly less frequent. I have not personally reproduced it.\n  // That said, it is still technically possible to encounter.\n  actix_web::rt::spawn(async move {\n    while let Some(res) = stream.next().await {\n      if tx.send(res).await.is_err() {\n        break;\n      }\n    }\n  });\n\n  SendStream { rx }\n}\n\nstruct SendStream<T> {\n  rx: tokio::sync::mpsc::Receiver<T>,\n}\n\nimpl<T> Stream for SendStream<T>\nwhere\n  T: Send,\n{\n  type Item = T;\n\n  fn poll_next(\n    mut self: std::pin::Pin<&mut Self>,\n    cx: &mut std::task::Context<'_>,\n  ) -> std::task::Poll<Option<Self::Item>> {\n    std::pin::Pin::new(&mut self.rx).poll_recv(cx)\n  }\n}\n\n// TODO: remove these conversions after actix-web upgrades to http 1.0\n#[expect(clippy::expect_used)]\npub(super) fn convert_method(method: &Method) -> http::Method {\n  http::Method::from_bytes(method.as_str().as_bytes()).expect(\"method can be converted\")\n}\n\npub(super) fn convert_header<'a>(\n  name: &'a http::HeaderName,\n  value: &'a HeaderValue,\n) -> (&'a str, &'a [u8]) {\n  (name.as_str(), value.as_bytes())\n}\n\n/// When adding a new avatar, banner or similar image, delete the old one.\npub(super) async fn delete_old_image(\n  old_image: &Option<DbUrl>,\n  context: &Data<LemmyContext>,\n) -> LemmyResult<()> {\n  if let Some(old_image) = old_image {\n    let alias = old_image.as_str().split('/').next_back().ok_or(NotFound)?;\n    delete_image_alias(alias, context).await?;\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "crates/routes/src/lib.rs",
    "content": "pub mod feeds;\npub mod images;\npub mod middleware;\npub mod nodeinfo;\npub mod utils;\npub mod webfinger;\n"
  },
  {
    "path": "crates/routes/src/middleware/idempotency.rs",
    "content": "use actix_web::{\n  Error,\n  HttpMessage,\n  HttpResponse,\n  body::EitherBody,\n  dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},\n  http::Method,\n};\nuse futures_util::future::LocalBoxFuture;\nuse lemmy_db_schema::newtypes::LocalUserId;\nuse lemmy_db_views_local_user::LocalUserView;\nuse std::{\n  collections::HashSet,\n  future::{Ready, ready},\n  hash::{Hash, Hasher},\n  sync::{Arc, LazyLock, RwLock},\n  time::{Duration, Instant},\n};\n\n/// https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-01.html\nconst IDEMPOTENCY_HEADER: &str = \"Idempotency-Key\";\n\n/// Delete idempotency keys older than this\nconst CLEANUP_INTERVAL_SECS: u32 = 120;\n\n/// Smaller than `std::time::Instant` because it uses a smaller integer for seconds and doesn't\n/// store nanoseconds\n#[derive(PartialEq, Debug, Clone, Copy, Hash)]\nstruct InstantSecs {\n  pub secs: u32,\n}\n\nstatic START_TIME: LazyLock<Instant> = LazyLock::new(Instant::now);\n\n#[expect(clippy::expect_used)]\nimpl InstantSecs {\n  pub fn now() -> Self {\n    InstantSecs {\n      secs: u32::try_from(START_TIME.elapsed().as_secs())\n        .expect(\"server has been running for over 136 years\"),\n    }\n  }\n}\n\n#[derive(Debug)]\nstruct Entry {\n  user_id: LocalUserId,\n  key: String,\n  // Creation time is ignored for Eq, Hash and only used to cleanup old entries\n  created: InstantSecs,\n}\n\nimpl PartialEq for Entry {\n  fn eq(&self, other: &Self) -> bool {\n    self.user_id == other.user_id && self.key == other.key\n  }\n}\nimpl Eq for Entry {}\n\nimpl Hash for Entry {\n  fn hash<H: Hasher>(&self, state: &mut H) {\n    self.user_id.hash(state);\n    self.key.hash(state);\n  }\n}\n\n#[derive(Clone)]\npub struct IdempotencySet {\n  set: Arc<RwLock<HashSet<Entry>>>,\n}\n\nimpl Default for IdempotencySet {\n  fn default() -> Self {\n    let set: Arc<RwLock<HashSet<Entry>>> = Default::default();\n\n    let set_ = set.clone();\n    tokio::spawn(async move {\n      let interval = Duration::from_secs(CLEANUP_INTERVAL_SECS.into());\n      let state_weak_ref = Arc::downgrade(&set_);\n\n      // Run at every interval to delete entries older than the interval.\n      // This loop stops when all other references to `state` are dropped.\n      while let Some(state) = state_weak_ref.upgrade() {\n        tokio::time::sleep(interval).await;\n        let now = InstantSecs::now();\n        #[expect(clippy::expect_used)]\n        let mut lock = state.write().expect(\"lock failed\");\n        lock.retain(|e| e.created.secs > now.secs.saturating_sub(CLEANUP_INTERVAL_SECS));\n        lock.shrink_to_fit();\n      }\n    });\n    Self { set }\n  }\n}\n\npub struct IdempotencyMiddleware {\n  idempotency_set: IdempotencySet,\n}\n\nimpl IdempotencyMiddleware {\n  pub fn new(idempotency_set: IdempotencySet) -> Self {\n    Self { idempotency_set }\n  }\n}\n\nimpl<S, B> Transform<S, ServiceRequest> for IdempotencyMiddleware\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<EitherBody<B>>;\n  type Error = Error;\n  type InitError = ();\n  type Transform = IdempotencyService<S>;\n  type Future = Ready<Result<Self::Transform, Self::InitError>>;\n\n  fn new_transform(&self, service: S) -> Self::Future {\n    ready(Ok(IdempotencyService {\n      service,\n      idempotency_set: self.idempotency_set.clone(),\n    }))\n  }\n}\n\npub struct IdempotencyService<S> {\n  service: S,\n  idempotency_set: IdempotencySet,\n}\n\nimpl<S, B> Service<ServiceRequest> for IdempotencyService<S>\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<EitherBody<B>>;\n  type Error = Error;\n  type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;\n\n  forward_ready!(service);\n\n  #[expect(clippy::expect_used)]\n  fn call(&self, req: ServiceRequest) -> Self::Future {\n    let is_post_or_put = req.method() == Method::POST || req.method() == Method::PUT;\n    let idempotency = req\n      .headers()\n      .get(IDEMPOTENCY_HEADER)\n      .map(|i| i.to_str().unwrap_or_default().to_string())\n      // Ignore values longer than 32 chars\n      .and_then(|i| (i.len() <= 32).then_some(i))\n      // Only use idempotency for POST and PUT requests\n      .and_then(|i| is_post_or_put.then_some(i));\n\n    let user_id = {\n      let ext = req.extensions();\n      ext.get().map(|u: &LocalUserView| u.local_user.id)\n    };\n\n    if let (Some(key), Some(user_id)) = (idempotency, user_id) {\n      let value = Entry {\n        user_id,\n        key,\n        created: InstantSecs::now(),\n      };\n      if self\n        .idempotency_set\n        .set\n        .read()\n        .expect(\"lock failed\")\n        .contains(&value)\n      {\n        // Duplicate request, return error\n        let (req, _pl) = req.into_parts();\n        let response = HttpResponse::UnprocessableEntity()\n          .finish()\n          .map_into_right_body();\n        return Box::pin(async { Ok(ServiceResponse::new(req, response)) });\n      } else {\n        // New request, store key and continue\n        self\n          .idempotency_set\n          .set\n          .write()\n          .expect(\"lock failed\")\n          .insert(value);\n      }\n    }\n\n    let fut = self.service.call(req);\n\n    Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })\n  }\n}\n"
  },
  {
    "path": "crates/routes/src/middleware/mod.rs",
    "content": "pub mod idempotency;\npub mod session;\n"
  },
  {
    "path": "crates/routes/src/middleware/session.rs",
    "content": "use actix_web::{\n  Error,\n  HttpMessage,\n  body::MessageBody,\n  dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},\n  http::header::{CACHE_CONTROL, HeaderValue},\n};\nuse core::future::Ready;\nuse futures_util::future::LocalBoxFuture;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  utils::{local_user_view_from_jwt, read_auth_token},\n};\nuse std::{future::ready, rc::Rc};\n\n#[derive(Clone)]\npub struct SessionMiddleware {\n  context: LemmyContext,\n}\n\nimpl SessionMiddleware {\n  pub fn new(context: LemmyContext) -> Self {\n    SessionMiddleware { context }\n  }\n}\nimpl<S, B> Transform<S, ServiceRequest> for SessionMiddleware\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,\n  S::Future: 'static,\n  B: MessageBody + 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = Error;\n  type Transform = SessionService<S>;\n  type InitError = ();\n  type Future = Ready<Result<Self::Transform, Self::InitError>>;\n\n  fn new_transform(&self, service: S) -> Self::Future {\n    ready(Ok(SessionService {\n      service: Rc::new(service),\n      context: self.context.clone(),\n    }))\n  }\n}\n\npub struct SessionService<S> {\n  service: Rc<S>,\n  context: LemmyContext,\n}\n\nimpl<S, B> Service<ServiceRequest> for SessionService<S>\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = Error;\n  type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;\n\n  forward_ready!(service);\n\n  fn call(&self, req: ServiceRequest) -> Self::Future {\n    let svc = self.service.clone();\n    let context = self.context.clone();\n\n    Box::pin(async move {\n      let jwt = read_auth_token(req.request())?;\n\n      if let Some(jwt) = &jwt {\n        // Ignore any invalid auth so the site can still be used\n        // This means it is be impossible to get any error message for invalid jwt. Need\n        // to use `/api/v4/account/validate_auth` for that.\n        let local_user_view = local_user_view_from_jwt(jwt, &context).await.ok();\n        if let Some(local_user_view) = local_user_view {\n          req.extensions_mut().insert(local_user_view);\n        }\n      }\n\n      let mut res = svc.call(req).await?;\n\n      // Add cache-control header if none is present\n      if !res.headers().contains_key(CACHE_CONTROL) {\n        // If user is authenticated, mark as private. Otherwise cache\n        // up to one minute.\n        let cache_value = if jwt.is_some() {\n          \"private\"\n        } else {\n          \"public, max-age=60\"\n        };\n        res\n          .headers_mut()\n          .insert(CACHE_CONTROL, HeaderValue::from_static(cache_value));\n      }\n      Ok(res)\n    })\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use actix_web::test::TestRequest;\n  use lemmy_api_utils::{claims::Claims, context::LemmyContext};\n  use lemmy_db_schema::source::{\n    instance::Instance,\n    local_user::{LocalUser, LocalUserInsertForm},\n    person::{Person, PersonInsertForm},\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::error::LemmyResult;\n  use pretty_assertions::assert_eq;\n  use serial_test::serial;\n\n  #[tokio::test]\n  #[serial]\n  async fn test_session_auth() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n\n    let inserted_instance = Instance::read_or_create(&mut context.pool(), \"my_domain.tld\").await?;\n\n    let new_person = PersonInsertForm::test_form(inserted_instance.id, \"Gerry9812\");\n\n    let inserted_person = Person::create(&mut context.pool(), &new_person).await?;\n\n    let local_user_form = LocalUserInsertForm::test_form(inserted_person.id);\n\n    let inserted_local_user =\n      LocalUser::create(&mut context.pool(), &local_user_form, vec![]).await?;\n\n    let req = TestRequest::default().to_http_request();\n    let jwt = Claims::generate(inserted_local_user.id, None, req, &context).await?;\n\n    let valid = Claims::validate(&jwt, &context).await;\n    assert!(valid.is_ok());\n\n    let num_deleted = Person::delete(&mut context.pool(), inserted_person.id).await?;\n    assert_eq!(1, num_deleted);\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/routes/src/nodeinfo.rs",
    "content": "use actix_web::{Error, HttpResponse, Result, web};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema_file::enums::RegistrationMode;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_utils::{\n  VERSION,\n  cache_header::{cache_1hour, cache_3days},\n  error::LemmyResult,\n};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse url::Url;\n\n/// A description of the nodeinfo endpoint is here:\n/// https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md\npub fn config(cfg: &mut web::ServiceConfig) {\n  cfg\n    .route(\n      \"/nodeinfo/2.1\",\n      web::get().to(node_info).wrap(cache_1hour()),\n    )\n    .service(web::redirect(\"/version\", \"/nodeinfo/2.1\"))\n    // For backwards compatibility, can be removed after Lemmy 0.20\n    .service(web::redirect(\"/nodeinfo/2.0.json\", \"/nodeinfo/2.1\"))\n    .service(web::redirect(\"/nodeinfo/2.1.json\", \"/nodeinfo/2.1\"))\n    .route(\n      \"/.well-known/nodeinfo\",\n      web::get().to(node_info_well_known).wrap(cache_3days()),\n    );\n}\n\nasync fn node_info_well_known(context: web::Data<LemmyContext>) -> LemmyResult<HttpResponse> {\n  let node_info = NodeInfoWellKnown {\n    links: vec![NodeInfoWellKnownLinks {\n      rel: Url::parse(\"http://nodeinfo.diaspora.software/ns/schema/2.1\")?,\n      href: Url::parse(&format!(\n        \"{}/nodeinfo/2.1\",\n        &context.settings().get_protocol_and_hostname(),\n      ))?,\n    }],\n  };\n  Ok(HttpResponse::Ok().json(node_info))\n}\n\nasync fn node_info(context: web::Data<LemmyContext>) -> Result<HttpResponse, Error> {\n  let site_view = SiteView::read_local(&mut context.pool()).await?;\n\n  // Since there are 3 registration options,\n  // we need to set open_registrations as true if RegistrationMode is not Closed.\n  let open_registrations = Some(site_view.local_site.registration_mode != RegistrationMode::Closed);\n  let json = NodeInfo {\n    version: Some(\"2.1\".to_string()),\n    software: Some(NodeInfoSoftware {\n      name: Some(\"lemmy\".to_string()),\n      version: Some(VERSION.to_string()),\n      repository: Some(\"https://github.com/LemmyNet/lemmy\".to_string()),\n      homepage: Some(\"https://join-lemmy.org/\".to_string()),\n    }),\n    protocols: Some(vec![\"activitypub\".to_string()]),\n    usage: Some(NodeInfoUsage {\n      users: Some(NodeInfoUsers {\n        total: Some(site_view.local_site.users),\n        active_halfyear: Some(site_view.local_site.users_active_half_year),\n        active_month: Some(site_view.local_site.users_active_month),\n      }),\n      local_posts: Some(site_view.local_site.posts),\n      local_comments: Some(site_view.local_site.comments),\n    }),\n    open_registrations,\n    services: Some(NodeInfoServices {\n      inbound: Some(vec![]),\n      outbound: Some(vec![]),\n    }),\n    metadata: Some(HashMap::new()),\n  };\n\n  Ok(HttpResponse::Ok().json(json))\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub(crate) struct NodeInfoWellKnown {\n  pub links: Vec<NodeInfoWellKnownLinks>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub(crate) struct NodeInfoWellKnownLinks {\n  pub rel: Url,\n  pub href: Url,\n}\n\n/// Nodeinfo spec: http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.1\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\", default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub(crate) struct NodeInfo {\n  pub version: Option<String>,\n  pub software: Option<NodeInfoSoftware>,\n  pub protocols: Option<Vec<String>>,\n  pub usage: Option<NodeInfoUsage>,\n  pub open_registrations: Option<bool>,\n  /// These fields are required by the spec for no reason\n  pub services: Option<NodeInfoServices>,\n  pub metadata: Option<HashMap<String, String>>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub(crate) struct NodeInfoSoftware {\n  pub name: Option<String>,\n  pub version: Option<String>,\n  pub repository: Option<String>,\n  pub homepage: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\", default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub(crate) struct NodeInfoUsage {\n  pub users: Option<NodeInfoUsers>,\n  pub local_posts: Option<i32>,\n  pub local_comments: Option<i32>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\", default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub(crate) struct NodeInfoUsers {\n  pub total: Option<i32>,\n  pub active_halfyear: Option<i32>,\n  pub active_month: Option<i32>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(rename_all = \"camelCase\", default)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(optional_fields, export))]\npub(crate) struct NodeInfoServices {\n  pub inbound: Option<Vec<String>>,\n  pub outbound: Option<Vec<String>>,\n}\n"
  },
  {
    "path": "crates/routes/src/utils/mod.rs",
    "content": "use actix_cors::Cors;\nuse lemmy_utils::settings::structs::Settings;\n\npub mod prometheus_metrics;\npub mod scheduled_tasks;\npub mod setup_local_site;\n\npub fn cors_config(settings: &Settings) -> Cors {\n  let self_origin = settings.get_protocol_and_hostname();\n  let cors_origin_setting = settings.cors_origin();\n\n  let mut cors = Cors::default()\n    .allow_any_method()\n    .allow_any_header()\n    .expose_any_header()\n    .max_age(3600);\n\n  if cfg!(debug_assertions)\n    || cors_origin_setting.is_empty()\n    || cors_origin_setting.contains(&\"*\".to_string())\n  {\n    cors = cors.allow_any_origin();\n  } else {\n    cors = cors.allowed_origin(&self_origin);\n    for c in cors_origin_setting {\n      cors = cors.allowed_origin(&c);\n    }\n  }\n  cors\n}\n"
  },
  {
    "path": "crates/routes/src/utils/prometheus_metrics.rs",
    "content": "use actix_web::{App, HttpServer, rt::System, web};\nuse actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_utils::{\n  error::{LemmyErrorType, LemmyResult},\n  settings::structs::PrometheusConfig,\n};\nuse prometheus::{Encoder, Gauge, Opts, TextEncoder, default_registry};\nuse std::{sync::Arc, thread};\nuse tracing::error;\n\n/// Creates a middleware that populates http metrics for each path, method, and status code\npub fn new_prometheus_metrics() -> LemmyResult<PrometheusMetrics> {\n  Ok(\n    PrometheusMetricsBuilder::new(\"lemmy_api\")\n      .registry(default_registry().clone())\n      .build()\n      .map_err(|e| LemmyErrorType::Unknown(format!(\"Should always be buildable: {e}\")))?,\n  )\n}\n\nstruct PromContext {\n  lemmy: LemmyContext,\n  db_pool_metrics: DbPoolMetrics,\n}\n\nstruct DbPoolMetrics {\n  max_size: Gauge,\n  size: Gauge,\n  available: Gauge,\n}\n\npub fn serve_prometheus(config: PrometheusConfig, lemmy_context: LemmyContext) -> LemmyResult<()> {\n  let context = Arc::new(PromContext {\n    lemmy: lemmy_context,\n    db_pool_metrics: create_db_pool_metrics()?,\n  });\n\n  // spawn thread that blocks on handling requests\n  // only mapping /metrics to a handler\n  thread::spawn(move || {\n    let sys = System::new();\n    sys.block_on(async {\n      let server = HttpServer::new(move || {\n        App::new()\n          .app_data(web::Data::new(Arc::clone(&context)))\n          .route(\"/metrics\", web::get().to(metrics))\n      })\n      .bind((config.bind, config.port))\n      .unwrap_or_else(|e| panic!(\"Cannot bind to {}:{}: {e}\", config.bind, config.port))\n      .run();\n\n      if let Err(err) = server.await {\n        error!(\"Prometheus server error: {err}\");\n      }\n    })\n  });\n  Ok(())\n}\n\n// handler for the /metrics path\nasync fn metrics(context: web::Data<Arc<PromContext>>) -> LemmyResult<String> {\n  // collect metrics\n  collect_db_pool_metrics(&context);\n\n  let mut buffer = Vec::new();\n  let encoder = TextEncoder::new();\n\n  // gather metrics from registry and encode in prometheus format\n  let metric_families = prometheus::gather();\n  encoder.encode(&metric_families, &mut buffer)?;\n  let output = String::from_utf8(buffer)?;\n\n  Ok(output)\n}\n\n// create lemmy_db_pool_* metrics and register them with the default registry\nfn create_db_pool_metrics() -> LemmyResult<DbPoolMetrics> {\n  let metrics = DbPoolMetrics {\n    max_size: Gauge::with_opts(Opts::new(\n      \"lemmy_db_pool_max_connections\",\n      \"Maximum number of connections in the pool\",\n    ))?,\n    size: Gauge::with_opts(Opts::new(\n      \"lemmy_db_pool_connections\",\n      \"Current number of connections in the pool\",\n    ))?,\n    available: Gauge::with_opts(Opts::new(\n      \"lemmy_db_pool_available_connections\",\n      \"Number of available connections in the pool\",\n    ))?,\n  };\n\n  default_registry().register(Box::new(metrics.max_size.clone()))?;\n  default_registry().register(Box::new(metrics.size.clone()))?;\n  default_registry().register(Box::new(metrics.available.clone()))?;\n\n  Ok(metrics)\n}\n\n/// try_from does not support conversion from usize to f64\n/// https://stackoverflow.com/q/35974890\n#[expect(clippy::as_conversions)]\nfn collect_db_pool_metrics(context: &PromContext) {\n  let pool_status = context.lemmy.inner_pool().status();\n  context\n    .db_pool_metrics\n    .max_size\n    .set(pool_status.max_size as f64);\n  context.db_pool_metrics.size.set(pool_status.size as f64);\n  context\n    .db_pool_metrics\n    .available\n    .set(pool_status.available as f64);\n}\n"
  },
  {
    "path": "crates/routes/src/utils/scheduled_tasks.rs",
    "content": "use crate::nodeinfo::{NodeInfo, NodeInfoWellKnown};\nuse activitypub_federation::config::Data;\nuse chrono::{DateTime, TimeZone, Utc};\nuse clokwerk::{AsyncScheduler, TimeUnits as CTimeUnits};\nuse diesel::{\n  BoolExpressionMethods,\n  ExpressionMethods,\n  NullableExpressionMethods,\n  QueryDsl,\n  QueryableByName,\n  SelectableHelper,\n  dsl::{IntervalDsl, count, exists, not, update},\n  query_builder::AsQuery,\n  sql_query,\n  sql_types::{BigInt, Integer, Timestamptz},\n};\nuse diesel_async::{AsyncPgConnection, RunQueryDsl};\nuse diesel_uplete::uplete;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  send_activity::{ActivityChannel, SendActivityData},\n  utils::send_webmention,\n};\nuse lemmy_db_schema::{\n  source::{\n    community::Community,\n    instance::{Instance, InstanceForm},\n    local_user::LocalUser,\n    post::{Post, PostUpdateForm},\n  },\n  utils::DELETED_REPLACEMENT_TEXT,\n};\nuse lemmy_db_schema_file::schema::{\n  comment,\n  community,\n  community_actions,\n  federation_blocklist,\n  instance,\n  instance_actions,\n  local_site,\n  local_user,\n  person,\n  post,\n  received_activity,\n  sent_activity,\n  site,\n};\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  traits::Crud,\n  utils::{functions::coalesce, now},\n};\nuse lemmy_utils::{\n  DB_BATCH_SIZE,\n  error::{LemmyErrorType, LemmyResult},\n};\nuse reqwest_middleware::ClientWithMiddleware;\nuse std::time::Duration;\nuse tracing::{info, warn};\n\n/// Schedules various cleanup tasks for lemmy in a background thread\npub async fn setup(context: Data<LemmyContext>) -> LemmyResult<()> {\n  // https://github.com/mdsherry/clokwerk/issues/38\n  let mut scheduler = AsyncScheduler::with_tz(Utc);\n\n  let context_1 = context.clone();\n  // Every 10 minutes update hot ranks, delete expired captchas and publish scheduled posts\n  scheduler.every(CTimeUnits::minutes(10)).run(move || {\n    let context = context_1.clone();\n\n    async move {\n      update_hot_ranks(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to update hot ranks: {e}\"))\n        .ok();\n      publish_scheduled_posts(&context)\n        .await\n        .inspect_err(|e| warn!(\"Failed to publish scheduled posts: {e}\"))\n        .ok();\n    }\n  });\n\n  let context_1 = context.clone();\n  // Hourly tasks:\n  // - Update active daily counts\n  // - Expired bans\n  // - Expired instance blocks\n  scheduler.every(CTimeUnits::hour(1)).run(move || {\n    let context = context_1.clone();\n\n    async move {\n      active_counts(&mut context.pool(), ONE_DAY)\n        .await\n        .inspect_err(|e| warn!(\"Failed to update active counts: {e}\"))\n        .ok();\n      update_banned_when_expired(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to update expired bans: {e}\"))\n        .ok();\n      delete_instance_block_when_expired(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to delete expired instance bans: {e}\"))\n        .ok();\n    }\n  });\n\n  let context_1 = context.reset_request_count();\n  // Daily tasks:\n  // - Update site and community activity counts\n  // - Update local user count\n  // - Overwrite deleted & removed posts and comments every day\n  // - Delete old denied users\n  // - Update instance software\n  // - Delete old outgoing activities\n  scheduler.every(CTimeUnits::days(1)).run(move || {\n    let context = context_1.reset_request_count();\n\n    async move {\n      all_active_counts(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to update active counts: {e}\"))\n        .ok();\n      update_local_user_count(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to update local user count: {e}\"))\n        .ok();\n      overwrite_deleted_posts_and_comments(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to overwrite deleted posts/comments: {e}\"))\n        .ok();\n      delete_old_denied_users(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to delete old denied users: {e}\"))\n        .ok();\n      update_instance_software(&mut context.pool(), context.client())\n        .await\n        .inspect_err(|e| warn!(\"Failed to update instance software: {e}\"))\n        .ok();\n      clear_old_activities(&mut context.pool())\n        .await\n        .inspect_err(|e| warn!(\"Failed to clear old activities: {e}\"))\n        .ok();\n    }\n  });\n\n  // Manually run the scheduler in an event loop\n  loop {\n    scheduler.run_pending().await;\n    tokio::time::sleep(Duration::from_millis(1000)).await;\n  }\n}\n\n/// Update the hot_rank columns for the aggregates tables\n/// Runs in batches until all necessary rows are updated once\nasync fn update_hot_ranks(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Updating hot ranks for all history...\");\n\n  let conn = &mut get_conn(pool).await?;\n\n  process_post_aggregates_ranks_in_batches(conn).await?;\n\n  process_ranks_in_batches(\n    conn,\n    \"comment\",\n    \"a.hot_rank != 0\",\n    \"SET hot_rank = r.hot_rank(a.score, a.published_at)\",\n  )\n  .await?;\n\n  process_ranks_in_batches(\n    conn,\n    \"community\",\n    \"a.hot_rank != 0\",\n    \"SET hot_rank = r.hot_rank(a.subscribers, a.published_at)\",\n  )\n  .await?;\n\n  info!(\"Finished hot ranks update!\");\n  Ok(())\n}\n\n#[derive(QueryableByName)]\nstruct HotRanksUpdateResult {\n  #[diesel(sql_type = Timestamptz)]\n  published_at: DateTime<Utc>,\n}\n\n/// Runs the hot rank update query in batches until all rows have been processed.\n/// In `where_clause` and `set_clause`, \"a\" will refer to the current aggregates table.\n/// Locked rows are skipped in order to prevent deadlocks (they will likely get updated on the next\n/// run)\nasync fn process_ranks_in_batches(\n  conn: &mut AsyncPgConnection,\n  table_name: &str,\n  where_clause: &str,\n  set_clause: &str,\n) -> LemmyResult<()> {\n  let process_start_time: DateTime<Utc> = Utc.timestamp_opt(0, 0).single().unwrap_or_default();\n\n  let mut processed_rows_count = 0;\n  let mut previous_batch_result = Some(process_start_time);\n  while let Some(previous_batch_last_published) = previous_batch_result {\n    // Raw `sql_query` is used as a performance optimization - Diesel does not support doing this\n    // in a single query (neither as a CTE, nor using a subquery)\n    let updated_rows = sql_query(format!(\n      r#\"WITH batch AS (SELECT a.id\n               FROM {table_name} a\n               WHERE a.published_at > $1 AND ({where_clause})\n               ORDER BY a.published_at\n               LIMIT $2\n               FOR UPDATE SKIP LOCKED)\n         UPDATE {table_name} a {set_clause}\n             FROM batch WHERE a.id = batch.id RETURNING a.published_at;\n    \"#,\n    ))\n    .bind::<Timestamptz, _>(previous_batch_last_published)\n    .bind::<BigInt, _>(DB_BATCH_SIZE)\n    .get_results::<HotRanksUpdateResult>(conn)\n    .await\n    .map_err(|e| {\n      LemmyErrorType::Unknown(format!(\"Failed to update {} hot_ranks: {}\", table_name, e))\n    })?;\n\n    processed_rows_count += updated_rows.len();\n    previous_batch_result = updated_rows.last().map(|row| row.published_at);\n  }\n  info!(\n    \"Finished process_hot_ranks_in_batches execution for {} (processed {} rows)\",\n    table_name, processed_rows_count\n  );\n  Ok(())\n}\n\n/// Post aggregates is a special case, since it needs to join to the community_aggregates\n/// table, to get the active monthly user counts.\nasync fn process_post_aggregates_ranks_in_batches(conn: &mut AsyncPgConnection) -> LemmyResult<()> {\n  let process_start_time: DateTime<Utc> = Utc.timestamp_opt(0, 0).single().unwrap_or_default();\n\n  let mut processed_rows_count = 0;\n  let mut previous_batch_result = Some(process_start_time);\n  while let Some(previous_batch_last_published) = previous_batch_result {\n    let updated_rows = sql_query(\n      r#\"WITH batch AS (SELECT pa.id\n           FROM post pa\n           WHERE pa.published_at > $1\n           AND (pa.hot_rank != 0 OR pa.hot_rank_active != 0)\n           ORDER BY pa.published_at\n           LIMIT $2\n           FOR UPDATE SKIP LOCKED)\n      UPDATE post pa\n      SET hot_rank = r.hot_rank(pa.score, pa.published_at),\n          hot_rank_active = r.hot_rank(pa.score, coalesce(pa.newest_comment_time_necro_at, pa.published_at)),\n          scaled_rank = r.scaled_rank(pa.score, pa.published_at, ca.interactions_month)\n      FROM batch, community ca\n      WHERE pa.id = batch.id\n      AND pa.community_id = ca.id\n      RETURNING pa.published_at;\n\"#,\n    )\n    .bind::<Timestamptz, _>(previous_batch_last_published)\n    .bind::<BigInt, _>(DB_BATCH_SIZE)\n    .get_results::<HotRanksUpdateResult>(conn)\n    .await\n    .map_err(|e| {\n      LemmyErrorType::Unknown(format!(\"Failed to update post_aggregates hot_ranks: {}\", e))\n    })?;\n\n    processed_rows_count += updated_rows.len();\n    previous_batch_result = updated_rows.last().map(|row| row.published_at);\n  }\n  info!(\n    \"Finished process_hot_ranks_in_batches execution for {} (processed {} rows)\",\n    \"post_aggregates\", processed_rows_count\n  );\n  Ok(())\n}\n\n/// Clear old activities (this table gets very large)\nasync fn clear_old_activities(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Clearing old activities...\");\n  let conn = &mut get_conn(pool).await?;\n\n  diesel::delete(\n    sent_activity::table.filter(sent_activity::published_at.lt(now() - IntervalDsl::days(7))),\n  )\n  .execute(conn)\n  .await?;\n\n  diesel::delete(\n    received_activity::table\n      .filter(received_activity::published_at.lt(now() - IntervalDsl::days(7))),\n  )\n  .execute(conn)\n  .await?;\n  info!(\"Done.\");\n  Ok(())\n}\n\nasync fn delete_old_denied_users(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  LocalUser::delete_old_denied_local_users(pool).await?;\n  info!(\"Done.\");\n  Ok(())\n}\n\n/// overwrite posts and comments 30d after deletion\nasync fn overwrite_deleted_posts_and_comments(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Overwriting deleted posts...\");\n  let conn = &mut get_conn(pool).await?;\n\n  diesel::update(\n    post::table\n      .filter(post::deleted.eq(true))\n      .filter(post::updated_at.lt(now().nullable() - 1.months()))\n      .filter(post::body.ne(DELETED_REPLACEMENT_TEXT)),\n  )\n  .set((\n    post::body.eq(DELETED_REPLACEMENT_TEXT),\n    post::name.eq(DELETED_REPLACEMENT_TEXT),\n  ))\n  .execute(conn)\n  .await?;\n\n  info!(\"Overwriting deleted comments...\");\n  diesel::update(\n    comment::table\n      .filter(comment::deleted.eq(true))\n      .filter(comment::updated_at.lt(now().nullable() - 1.months()))\n      .filter(comment::content.ne(DELETED_REPLACEMENT_TEXT)),\n  )\n  .set(comment::content.eq(DELETED_REPLACEMENT_TEXT))\n  .execute(conn)\n  .await?;\n  info!(\"Done.\");\n  Ok(())\n}\n\nconst ONE_DAY: (&str, &str) = (\"1 day\", \"day\");\nconst ONE_WEEK: (&str, &str) = (\"1 week\", \"week\");\nconst ONE_MONTH: (&str, &str) = (\"1 month\", \"month\");\nconst SIX_MONTHS: (&str, &str) = (\"6 months\", \"half_year\");\n\nconst ALL_ACTIVE_INTERVALS: [(&str, &str); 4] = [ONE_DAY, ONE_WEEK, ONE_MONTH, SIX_MONTHS];\n\n#[derive(QueryableByName)]\nstruct SiteActivitySelectResult {\n  #[diesel(sql_type = Integer)]\n  site_aggregates_activity: i32,\n}\n\n#[derive(QueryableByName)]\nstruct CommunityAggregatesUpdateResult {\n  #[diesel(sql_type = Integer)]\n  community_id: i32,\n}\n\n/// Re-calculate the site and community active counts for a given interval\nasync fn active_counts(pool: &mut DbPool<'_>, interval: (&str, &str)) -> LemmyResult<()> {\n  info!(\n    \"Updating active site and community aggregates for {}...\",\n    interval.0\n  );\n\n  let conn = &mut get_conn(pool).await?;\n  process_site_aggregates(conn, interval).await?;\n  process_community_aggregates(\n    conn,\n    interval,\n    \"users_active\",\n    \"community_aggregates_activity\",\n  )\n  .await?;\n\n  Ok(())\n}\n\n/// Re-calculate all the active counts\nasync fn all_active_counts(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  for i in ALL_ACTIVE_INTERVALS {\n    active_counts(pool, i).await?;\n  }\n  let conn = &mut get_conn(pool).await?;\n  process_community_aggregates(\n    conn,\n    ONE_MONTH,\n    \"interactions\",\n    \"community_aggregates_interactions\",\n  )\n  .await?;\n  Ok(())\n}\n\nasync fn process_site_aggregates(\n  conn: &mut AsyncPgConnection,\n  interval: (&str, &str),\n) -> LemmyResult<()> {\n  // Select the site count result first\n  let site_activity = sql_query(format!(\n    \"select * from r.site_aggregates_activity('{}')\",\n    interval.0\n  ))\n  .get_result::<SiteActivitySelectResult>(conn)\n  .await\n  .inspect_err(|e| warn!(\"Failed to fetch site activity: {e}\"))?;\n\n  let processed_rows = site_activity.site_aggregates_activity;\n\n  // Update the site count\n  sql_query(format!(\n    \"update local_site set users_active_{} = $1\",\n    interval.1,\n  ))\n  .bind::<Integer, _>(processed_rows)\n  .execute(conn)\n  .await\n  .inspect_err(|e| warn!(\"Failed to update site stats: {e}\"))\n  .ok();\n\n  info!(\n    \"Finished site_aggregates active_{} (processed {} rows)\",\n    interval.1, processed_rows\n  );\n\n  Ok(())\n}\n\nasync fn process_community_aggregates(\n  conn: &mut AsyncPgConnection,\n  interval: (&str, &str),\n  field_name_prefix: &str,\n  function_name: &str,\n) -> LemmyResult<()> {\n  // Select the community count results into a temp table.\n  let caggs_temp_table = &format!(\"community_aggregates_temp_table_{}\", interval.1);\n\n  // Drop temp table before and after, just in case\n  let drop_caggs_temp_table = &format!(\"DROP TABLE IF EXISTS {caggs_temp_table}\");\n  sql_query(drop_caggs_temp_table).execute(conn).await.ok();\n\n  sql_query(format!(\n    \"CREATE TEMP TABLE {caggs_temp_table} AS SELECT * FROM r.{function_name}('{}')\",\n    interval.0\n  ))\n  .execute(conn)\n  .await\n  .inspect_err(|e| warn!(\"Failed to create temp community_aggregates table: {e}\"))?;\n\n  // Split up into 1000 community transaction batches\n  let update_batch_size = 1000;\n  let mut processed_rows_count = 0;\n  let mut prev_community_id_res = Some(0);\n\n  while let Some(prev_community_id) = prev_community_id_res {\n    let updated_rows = sql_query(format!(\n      \"UPDATE community a\n            SET {field_name_prefix}_{} = b.count_\n            FROM (\n              SELECT count_, community_id_\n              FROM {caggs_temp_table}\n              WHERE community_id_ > $1\n              ORDER BY community_id_\n              LIMIT $2\n            ) AS b\n            WHERE a.id = b.community_id_\n            RETURNING a.id AS community_id\n            \",\n      interval.1\n    ))\n    .bind::<Integer, _>(prev_community_id)\n    .bind::<Integer, _>(update_batch_size)\n    .get_results::<CommunityAggregatesUpdateResult>(conn)\n    .await\n    .inspect_err(|e| warn!(\"Failed to update community stats: {e}\"))?;\n\n    processed_rows_count += updated_rows.len();\n    prev_community_id_res = updated_rows.last().map(|row| row.community_id);\n  }\n\n  // Drop the temp table just in case\n  sql_query(drop_caggs_temp_table).execute(conn).await.ok();\n\n  info!(\n    \"Finished community_aggregates {field_name_prefix}_{} (processed {} rows)\",\n    interval.1, processed_rows_count\n  );\n\n  info!(\"Done.\");\n  Ok(())\n}\n\nasync fn update_local_user_count(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Updating the local user count...\");\n\n  let conn = &mut get_conn(pool).await?;\n  let user_count = local_user::table\n    .inner_join(\n      person::table.left_join(\n        instance_actions::table\n          .inner_join(instance::table.inner_join(site::table.inner_join(local_site::table))),\n      ),\n    )\n    // only count approved users\n    .filter(local_user::accepted_application)\n    // ignore banned and deleted accounts\n    .filter(instance_actions::received_ban_at.is_null())\n    .filter(not(person::deleted))\n    .select(count(local_user::id))\n    .first::<i64>(conn)\n    .await\n    .map(i32::try_from)??;\n\n  update(local_site::table)\n    .set(local_site::users.eq(user_count))\n    .execute(conn)\n    .await?;\n\n  info!(\"Done.\");\n  Ok(())\n}\n\n/// Set banned to false after ban expires\nasync fn update_banned_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Updating banned column if it expires ...\");\n  let conn = &mut get_conn(pool).await?;\n\n  uplete(community_actions::table.filter(community_actions::ban_expires_at.lt(now().nullable())))\n    .set_null(community_actions::received_ban_at)\n    .set_null(community_actions::ban_expires_at)\n    .as_query()\n    .execute(conn)\n    .await?;\n\n  uplete(instance_actions::table.filter(instance_actions::ban_expires_at.lt(now().nullable())))\n    .set_null(instance_actions::received_ban_at)\n    .set_null(instance_actions::ban_expires_at)\n    .as_query()\n    .execute(conn)\n    .await?;\n  Ok(())\n}\n\n/// Set banned to false after ban expires\nasync fn delete_instance_block_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> {\n  info!(\"Delete instance blocks when expired ...\");\n  let conn = &mut get_conn(pool).await?;\n\n  diesel::delete(\n    federation_blocklist::table.filter(federation_blocklist::expires_at.lt(now().nullable())),\n  )\n  .execute(conn)\n  .await?;\n  Ok(())\n}\n\n/// Find all unpublished posts with scheduled date in the future, and publish them.\nasync fn publish_scheduled_posts(context: &Data<LemmyContext>) -> LemmyResult<()> {\n  let pool = &mut context.pool();\n  let local_instance_id = SiteView::read_local(pool).await?.instance.id;\n  let conn = &mut get_conn(pool).await?;\n\n  let not_community_banned_action = community_actions::table\n    .find((person::id, community::id))\n    .filter(community_actions::received_ban_at.is_not_null());\n\n  let not_local_banned_action = instance_actions::table\n    .find((person::id, local_instance_id))\n    .filter(instance_actions::received_ban_at.is_not_null());\n\n  let scheduled_posts: Vec<_> = post::table\n    .inner_join(community::table)\n    .inner_join(person::table)\n    // find all posts which have scheduled_publish_time that is in the  past\n    .filter(post::scheduled_publish_time_at.is_not_null())\n    .filter(coalesce(post::scheduled_publish_time_at, now()).lt(now()))\n    // make sure the post, person and community are still around\n    .filter(not(post::deleted.or(post::removed)))\n    .filter(not(person::deleted))\n    .filter(not(community::removed.or(community::deleted)))\n    // ensure that user isnt banned from community\n    .filter(not(exists(not_community_banned_action)))\n    // ensure that user isnt banned from local\n    .filter(not(exists(not_local_banned_action)))\n    .select((Post::as_select(), Community::as_select()))\n    .get_results::<(Post, Community)>(conn)\n    .await?;\n\n  for (post, community) in scheduled_posts {\n    // mark post as published in db\n    let form = PostUpdateForm {\n      scheduled_publish_time_at: Some(None),\n      ..Default::default()\n    };\n    Post::update(&mut context.pool(), post.id, &form).await?;\n\n    // send out post via federation and webmention\n    let send_activity = SendActivityData::CreatePost(post.clone());\n    ActivityChannel::submit_activity(send_activity, context)?;\n    send_webmention(post, &community);\n  }\n  Ok(())\n}\n\n/// Updates the instance software and version.\n///\n/// Does so using the /.well-known/nodeinfo protocol described here:\n/// https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md\n///\n/// TODO: if instance has been dead for a long time, it should be checked less frequently\nasync fn update_instance_software(\n  pool: &mut DbPool<'_>,\n  client: &ClientWithMiddleware,\n) -> LemmyResult<()> {\n  info!(\"Updating instances software and versions...\");\n  let conn = &mut get_conn(pool).await?;\n\n  let instances = instance::table.get_results::<Instance>(conn).await?;\n\n  for instance in instances {\n    if let Some(form) = build_update_instance_form(&instance.domain, client).await {\n      Instance::update(pool, instance.id, form).await?;\n    }\n  }\n  info!(\"Finished updating instances software and versions...\");\n  Ok(())\n}\n\n/// This builds an instance update form, for a given domain.\n/// If the instance sends a response, but doesn't have a well-known or nodeinfo,\n/// Then return a default form with only the updated field.\nasync fn build_update_instance_form(\n  domain: &str,\n  client: &ClientWithMiddleware,\n) -> Option<InstanceForm> {\n  // The `updated` column is used to check if instances are alive. If it is more than three\n  // days in the past, no outgoing activities will be sent to that instance. However\n  // not every Fediverse instance has a valid Nodeinfo endpoint (its not required for\n  // Activitypub). That's why we always need to mark instances as updated if they are\n  // alive.\n  let mut instance_form = InstanceForm {\n    updated_at: Some(Utc::now()),\n    ..InstanceForm::new(domain.to_string())\n  };\n\n  // First, fetch their /.well-known/nodeinfo, then extract the correct nodeinfo link from it\n  let well_known_url = format!(\"https://{}/.well-known/nodeinfo\", domain);\n\n  let Ok(res) = client.get(&well_known_url).send().await else {\n    // This is the only kind of error that means the instance is dead\n    return None;\n  };\n  let status = res.status();\n  if status.is_client_error() || status.is_server_error() {\n    return None;\n  }\n\n  // In this block, returning `None` is ignored, and only means not writing nodeinfo to db\n  async {\n    let node_info_url = res\n      .json::<NodeInfoWellKnown>()\n      .await\n      .ok()?\n      .links\n      .into_iter()\n      .find(|links| {\n        links\n          .rel\n          .as_str()\n          .starts_with(\"http://nodeinfo.diaspora.software/ns/schema/2.\")\n      })?\n      .href;\n\n    let software = client\n      .get(node_info_url)\n      .send()\n      .await\n      .ok()?\n      .json::<NodeInfo>()\n      .await\n      .ok()?\n      .software?;\n\n    instance_form.software = software.name;\n    instance_form.version = software.version;\n\n    Some(())\n  }\n  .await;\n\n  Some(instance_form)\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use lemmy_api_utils::request::client_builder;\n  use lemmy_db_schema::{\n    source::{\n      community::{Community, CommunityInsertForm},\n      person::{Person, PersonInsertForm},\n      post::{Post, PostActions, PostInsertForm, PostLikeForm},\n    },\n    test_data::TestData,\n    traits::Likeable,\n  };\n  use lemmy_diesel_utils::traits::Crud;\n  use lemmy_utils::{\n    error::{LemmyErrorType, LemmyResult},\n    settings::structs::Settings,\n  };\n  use pretty_assertions::assert_eq;\n  use reqwest_middleware::ClientBuilder;\n  use serial_test::serial;\n\n  #[tokio::test]\n  async fn test_nodeinfo_lemmy_ml() -> LemmyResult<()> {\n    let client = ClientBuilder::new(client_builder(&Settings::default()).build()?).build();\n    let form = build_update_instance_form(\"lemmy.ml\", &client)\n      .await\n      .ok_or(LemmyErrorType::NotFound)?;\n    assert_eq!(form.software.ok_or(LemmyErrorType::NotFound)?, \"lemmy\");\n    Ok(())\n  }\n\n  #[tokio::test]\n  async fn test_nodeinfo_mastodon_social() -> LemmyResult<()> {\n    let client = ClientBuilder::new(client_builder(&Settings::default()).build()?).build();\n    let form = build_update_instance_form(\"mastodon.social\", &client)\n      .await\n      .ok_or(LemmyErrorType::NotFound)?;\n    assert_eq!(form.software.ok_or(LemmyErrorType::NotFound)?, \"mastodon\");\n    Ok(())\n  }\n\n  #[tokio::test]\n  #[serial]\n  async fn test_scheduled_tasks() -> LemmyResult<()> {\n    let context = LemmyContext::init_test_context().await;\n    let pool = &mut context.pool();\n\n    let data = TestData::create(pool).await?;\n    let community = Community::create(\n      pool,\n      &CommunityInsertForm::new(\n        data.instance.id,\n        \"name\".to_owned(),\n        \"title\".to_owned(),\n        \"pubkey\".to_owned(),\n      ),\n    )\n    .await?;\n    let person = Person::create(\n      pool,\n      &PersonInsertForm::new(\"felicity\".to_owned(), \"pubkey\".to_owned(), data.instance.id),\n    )\n    .await?;\n    let post = Post::create(\n      pool,\n      &PostInsertForm::new(\"i am grrreat\".to_owned(), person.id, community.id),\n    )\n    .await?;\n    PostActions::like(pool, &PostLikeForm::new(post.id, person.id, Some(true))).await?;\n\n    active_counts(pool, ONE_DAY).await?;\n    all_active_counts(pool).await?;\n    update_local_user_count(pool).await?;\n    update_hot_ranks(pool).await?;\n    update_banned_when_expired(pool).await?;\n    delete_instance_block_when_expired(pool).await?;\n    clear_old_activities(pool).await?;\n    overwrite_deleted_posts_and_comments(pool).await?;\n    delete_old_denied_users(pool).await?;\n    update_instance_software(pool, context.client()).await?;\n    publish_scheduled_posts(&context).await?;\n\n    let community_after = Community::read(pool, community.id).await?;\n    assert_eq!(\n      community_after,\n      Community {\n        posts: 1,\n        users_active_day: 1,\n        users_active_week: 1,\n        users_active_month: 1,\n        users_active_half_year: 1,\n        interactions_month: 1,\n        ..community_after.clone()\n      }\n    );\n\n    data.delete(pool).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/routes/src/utils/setup_local_site.rs",
    "content": "use activitypub_federation::http_signatures::generate_actor_keypair;\nuse chrono::Utc;\nuse diesel::{\n  dsl::{exists, not, select},\n  query_builder::AsQuery,\n};\nuse diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt};\nuse lemmy_api_utils::utils::generate_inbox_url;\nuse lemmy_db_schema::{\n  source::{\n    instance::Instance,\n    local_site::{LocalSite, LocalSiteInsertForm},\n    local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitInsertForm},\n    local_user::{LocalUser, LocalUserInsertForm},\n    person::{Person, PersonInsertForm},\n    site::{Site, SiteInsertForm},\n  },\n  traits::ApubActor,\n};\nuse lemmy_db_schema_file::schema::local_site;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::{\n  connection::{DbPool, get_conn},\n  sensitive::SensitiveString,\n  traits::Crud,\n};\nuse lemmy_utils::{\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n  settings::structs::Settings,\n};\nuse rand::{RngExt, distr::Alphanumeric};\nuse tracing::info;\nuse url::Url;\n\npub async fn setup_local_site(pool: &mut DbPool<'_>, settings: &Settings) -> LemmyResult<SiteView> {\n  let conn = &mut get_conn(pool).await?;\n  // Check to see if local_site exists, without the cache wrapper\n  if select(not(exists(local_site::table.as_query())))\n    .get_result(conn)\n    .await?\n  {\n    info!(\"No Local Site found, creating it.\");\n\n    let domain = settings\n      .get_hostname_without_port()\n      .with_lemmy_type(LemmyErrorType::Unknown(\"must have domain\".into()))?;\n\n    conn\n      .run_transaction(|conn| {\n        async move {\n          // Upsert this to the instance table\n          let instance = Instance::read_or_create(&mut conn.into(), &domain).await?;\n\n          if let Some(setup) = &settings.setup {\n            let person_keypair = generate_actor_keypair()?;\n            let person_ap_id = Person::generate_local_actor_url(&setup.admin_username, settings)?;\n\n            // Register the user if there's a site setup\n            let person_form = PersonInsertForm {\n              ap_id: Some(person_ap_id.clone()),\n              inbox_url: Some(generate_inbox_url()?),\n              private_key: Some(person_keypair.private_key),\n              ..PersonInsertForm::new(\n                setup.admin_username.clone(),\n                person_keypair.public_key,\n                instance.id,\n              )\n            };\n            let person_inserted = Person::create(&mut conn.into(), &person_form).await?;\n\n            let local_user_form = LocalUserInsertForm {\n              email: setup.admin_email.clone(),\n              admin: Some(true),\n              ..LocalUserInsertForm::new(person_inserted.id, Some(setup.admin_password.clone()))\n            };\n            LocalUser::create(&mut conn.into(), &local_user_form, vec![]).await?;\n          };\n\n          // Add an entry for the site table\n          let site_key_pair = generate_actor_keypair()?;\n          let site_ap_id = Url::parse(&settings.get_protocol_and_hostname())?;\n\n          let name = settings\n            .setup\n            .clone()\n            .map(|s| s.site_name)\n            .unwrap_or_else(|| \"New Site\".to_string());\n          let site_form = SiteInsertForm {\n            ap_id: Some(site_ap_id.clone().into()),\n            last_refreshed_at: Some(Utc::now()),\n            inbox_url: Some(generate_inbox_url()?),\n            private_key: Some(site_key_pair.private_key),\n            public_key: Some(site_key_pair.public_key),\n            ..SiteInsertForm::new(name, instance.id)\n          };\n          let site = Site::create(&mut conn.into(), &site_form).await?;\n          // create multi-comm follower account\n          let r: String = rand::rng()\n            .sample_iter(&Alphanumeric)\n            .take(14)\n            .map(char::from)\n            .collect();\n          let name = format!(\"lemmy_{}\", r);\n          let form = PersonInsertForm {\n            private_key: site.private_key.map(SensitiveString::into_inner),\n            inbox_url: Some(site.inbox_url),\n            bot_account: Some(true),\n            ..PersonInsertForm::new(name, site.public_key, instance.id)\n          };\n          let system_account = Person::create(&mut conn.into(), &form).await?;\n\n          // Finally create the local_site row\n          let local_site_form = LocalSiteInsertForm {\n            site_setup: Some(settings.setup.is_some()),\n            system_account: Some(system_account.id),\n            ..LocalSiteInsertForm::new(site.id)\n          };\n          let local_site = LocalSite::create(&mut conn.into(), &local_site_form).await?;\n\n          // Create the rate limit table\n          let local_site_rate_limit_form = LocalSiteRateLimitInsertForm::new(local_site.id);\n          LocalSiteRateLimit::create(&mut conn.into(), &local_site_rate_limit_form).await?;\n          Ok(())\n        }\n        .scope_boxed()\n      })\n      .await?;\n  }\n\n  SiteView::read_local(pool).await\n}\n"
  },
  {
    "path": "crates/routes/src/webfinger.rs",
    "content": "use activitypub_federation::{\n  config::Data,\n  fetch::webfinger::{WEBFINGER_CONTENT_TYPE, Webfinger, WebfingerLink, extract_webfinger_name},\n};\nuse actix_web::{HttpResponse, web, web::Query};\nuse lemmy_api_utils::context::LemmyContext;\nuse lemmy_db_schema::{\n  source::{community::Community, person::Person},\n  traits::ApubActor,\n};\nuse lemmy_utils::{\n  cache_header::cache_3days,\n  error::{LemmyErrorExt, LemmyErrorType, LemmyResult},\n};\nuse serde::Deserialize;\nuse std::collections::HashMap;\nuse url::Url;\n\n#[derive(Deserialize)]\nstruct Params {\n  resource: String,\n}\n\npub fn config(cfg: &mut web::ServiceConfig) {\n  cfg.route(\n    \".well-known/webfinger\",\n    web::get().to(get_webfinger_response).wrap(cache_3days()),\n  );\n}\n\n/// Responds to webfinger requests of the following format. There isn't any real documentation for\n/// this, but it described in this blog post:\n/// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social\n///\n/// You can also view the webfinger response that Mastodon sends:\n/// https://radical.town/.well-known/webfinger?resource=acct:felix@radical.town\nasync fn get_webfinger_response(\n  info: Query<Params>,\n  context: Data<LemmyContext>,\n) -> LemmyResult<HttpResponse> {\n  let name = extract_webfinger_name(&info.resource, &context)?;\n\n  let links = if name == context.settings().hostname {\n    // webfinger response for instance actor (required for mastodon authorized fetch)\n    let url = Url::parse(&context.settings().get_protocol_and_hostname())?;\n    vec![webfinger_link_for_actor(Some(url), \"none\", &context)?]\n  } else {\n    // webfinger response for user/community\n    let user_id: Option<Url> = Person::read_from_name(&mut context.pool(), name, None, false)\n      .await\n      .ok()\n      .flatten()\n      .map(|c| c.ap_id.into());\n    let community_id: Option<Url> =\n      Community::read_from_name(&mut context.pool(), name, None, false)\n        .await\n        .ok()\n        .flatten()\n        .and_then(|c| {\n          c.visibility.can_federate().then(|| {\n            let id: Url = c.ap_id.into();\n            id\n          })\n        });\n\n    // NOTE: Do not change the order of these items!\n    // Mastodon seems to prioritize the last webfinger item in case of duplicates. Put\n    // community last so that it gets prioritized.\n    // Lemmy also relies on this specific order, so in case a resolve for `reddit@lemmy.world`\n    // gives both user and community, the community is returned (also necessary for remote follow).\n    vec![\n      webfinger_link_for_actor(user_id, \"Person\", &context)?,\n      webfinger_link_for_actor(community_id, \"Group\", &context)?,\n    ]\n  }\n  .into_iter()\n  .flatten()\n  .collect::<Vec<_>>();\n\n  if links.is_empty() {\n    Ok(HttpResponse::NotFound().finish())\n  } else {\n    let json = Webfinger {\n      subject: info.resource.clone(),\n      links,\n      ..Default::default()\n    };\n\n    Ok(\n      HttpResponse::Ok()\n        .content_type(WEBFINGER_CONTENT_TYPE.as_bytes())\n        .json(json),\n    )\n  }\n}\n\nfn webfinger_link_for_actor(\n  url: Option<Url>,\n  kind: &str,\n  context: &LemmyContext,\n) -> LemmyResult<Vec<WebfingerLink>> {\n  if let Some(url) = url {\n    let type_key = \"https://www.w3.org/ns/activitystreams#type\"\n      .parse()\n      .with_lemmy_type(LemmyErrorType::InvalidUrl)?;\n\n    let mut vec = vec![\n      WebfingerLink {\n        rel: Some(\"http://webfinger.net/rel/profile-page\".into()),\n        kind: Some(\"text/html\".into()),\n        href: Some(url.clone()),\n        ..Default::default()\n      },\n      WebfingerLink {\n        rel: Some(\"self\".into()),\n        kind: Some(\"application/activity+json\".into()),\n        href: Some(url),\n        properties: HashMap::from([(type_key, kind.into())]),\n        ..Default::default()\n      },\n    ];\n\n    // insert remote follow link\n    if kind == \"Person\" {\n      let template = format!(\n        \"{}/activitypub/externalInteraction?uri={{uri}}\",\n        context.settings().get_protocol_and_hostname()\n      );\n      vec.push(WebfingerLink {\n        rel: Some(\"http://ostatus.org/schema/1.0/subscribe\".into()),\n        template: Some(template),\n        ..Default::default()\n      });\n    }\n    Ok(vec)\n  } else {\n    Ok(vec![])\n  }\n}\n"
  },
  {
    "path": "crates/server/Cargo.toml",
    "content": "[package]\nname = \"lemmy_server\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\npublish = false\ndefault-run = \"lemmy_server\"\n\n[lib]\ntest = false\ndoctest = false\n\n[lints]\nworkspace = true\n\n[features]\ndefault = []\n\n[dependencies]\nlemmy_api = { workspace = true }\nlemmy_api_routes = { workspace = true }\nlemmy_api_routes_v3 = { workspace = true }\nlemmy_apub = { workspace = true }\nlemmy_apub_activities = { workspace = true }\nlemmy_apub_objects = { workspace = true }\nlemmy_utils = { workspace = true }\nlemmy_db_schema = { workspace = true }\nlemmy_diesel_utils = { workspace = true }\nlemmy_api_utils = { workspace = true }\nlemmy_routes = { workspace = true }\nlemmy_apub_send = { workspace = true }\nlemmy_db_views_site = { workspace = true }\nactivitypub_federation = { workspace = true }\nactix-web = { workspace = true }\ntracing = { workspace = true }\ntracing-actix-web = { workspace = true }\ntracing-subscriber = { workspace = true }\nreqwest-middleware = { workspace = true }\nreqwest-tracing = { workspace = true }\nserde_json = { workspace = true }\ntokio.workspace = true\nclap = { workspace = true }\n\n[target.'cfg(target_arch = \"x86_64\")'.dependencies]\nmimalloc = \"0.1.48\"\n"
  },
  {
    "path": "crates/server/src/lib.rs",
    "content": "use activitypub_federation::config::{FederationConfig, FederationMiddleware};\nuse actix_web::{\n  App,\n  HttpResponse,\n  HttpServer,\n  dev::{ServerHandle, ServiceResponse},\n  middleware::{self, Condition, ErrorHandlerResponse, ErrorHandlers},\n  web::{Data, get, scope},\n};\nuse clap::{Parser, Subcommand};\nuse lemmy_api::sitemap::get_sitemap;\nuse lemmy_api_utils::{\n  context::LemmyContext,\n  request::client_builder,\n  send_activity::ActivityChannel,\n  utils::local_site_rate_limit_to_rate_limit_config,\n};\nuse lemmy_apub::{\n  FEDERATION_HTTP_FETCH_LIMIT,\n  VerifyUrlData,\n  collections::fetch_community_collections,\n};\nuse lemmy_apub_activities::handle_outgoing_activities;\nuse lemmy_apub_objects::objects::{community::FETCH_COMMUNITY_COLLECTIONS, instance::ApubSite};\nuse lemmy_apub_send::{Opts, SendManager};\nuse lemmy_db_schema::source::secret::Secret;\nuse lemmy_db_views_site::SiteView;\nuse lemmy_diesel_utils::connection::build_db_pool;\nuse lemmy_routes::{\n  feeds,\n  middleware::{\n    idempotency::{IdempotencyMiddleware, IdempotencySet},\n    session::SessionMiddleware,\n  },\n  nodeinfo,\n  utils::{\n    cors_config,\n    prometheus_metrics::{new_prometheus_metrics, serve_prometheus},\n    scheduled_tasks,\n    setup_local_site::setup_local_site,\n  },\n  webfinger,\n};\nuse lemmy_utils::{\n  VERSION,\n  error::{LemmyErrorType, LemmyResult},\n  rate_limit::RateLimit,\n  response::jsonify_plain_text_errors,\n  settings::{SETTINGS, structs::Settings},\n};\nuse reqwest_middleware::ClientBuilder;\nuse reqwest_tracing::TracingMiddleware;\nuse serde_json::json;\nuse std::{ops::Deref, time::Duration};\nuse tokio::signal::unix::SignalKind;\nuse tracing_actix_web::{DefaultRootSpanBuilder, TracingLogger};\n\n#[cfg_attr(target_arch = \"x86_64\", global_allocator)]\n#[cfg(target_arch = \"x86_64\")]\nstatic GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;\n\n/// Timeout for HTTP requests while sending activities. A longer timeout provides better\n/// compatibility with other ActivityPub software that might allocate more time for synchronous\n/// processing of incoming activities. This timeout should be slightly longer than the time we\n/// expect a remote server to wait before aborting processing on its own to account for delays from\n/// establishing the HTTP connection and sending the request itself.\nconst ACTIVITY_SENDING_TIMEOUT: Duration = Duration::from_secs(125);\n\n#[derive(Parser, Debug)]\n#[command(\n  version,\n  about = \"A link aggregator for the fediverse\",\n  long_about = \"A link aggregator for the fediverse.\\n\\nThis is the Lemmy backend API server. This will connect to a PostgreSQL database, run any pending migrations and start accepting API requests.\"\n)]\n// TODO: Instead of defining individual env vars, only specify prefix once supported by clap.\n//       https://github.com/clap-rs/clap/issues/3221\npub struct CmdArgs {\n  /// Don't run scheduled tasks.\n  ///\n  /// If you are running multiple Lemmy server processes, you probably want to disable scheduled\n  /// tasks on all but one of the processes, to avoid running the tasks more often than intended.\n  #[arg(long, default_value_t = false, env = \"LEMMY_DISABLE_SCHEDULED_TASKS\")]\n  disable_scheduled_tasks: bool,\n  /// Disables the HTTP server.\n  ///\n  /// This can be used to run a Lemmy server process that only performs scheduled tasks or activity\n  /// sending.\n  #[arg(long, default_value_t = false, env = \"LEMMY_DISABLE_HTTP_SERVER\")]\n  disable_http_server: bool,\n  /// Disable sending outgoing ActivityPub messages.\n  ///\n  /// Only pass this for horizontally scaled setups.\n  /// See https://join-lemmy.org/docs/administration/horizontal_scaling.html for details.\n  #[arg(long, default_value_t = false, env = \"LEMMY_DISABLE_ACTIVITY_SENDING\")]\n  disable_activity_sending: bool,\n  /// The index of this outgoing federation process.\n  ///\n  /// Defaults to 1/1. If you want to split the federation workload onto n servers, run each server\n  /// 1≤i≤n with these args: --federate-process-index i --federate-process-count n\n  ///\n  /// Make you have exactly one server with each `i` running, otherwise federation will randomly\n  /// send duplicates or nothing.\n  ///\n  /// See https://join-lemmy.org/docs/administration/horizontal_scaling.html for more detail.\n  #[arg(long, default_value_t = 1, env = \"LEMMY_FEDERATE_PROCESS_INDEX\")]\n  federate_process_index: i32,\n  /// How many outgoing federation processes you are starting in total.\n  ///\n  /// If set, make sure to set --federate-process-index differently for each.\n  #[arg(long, default_value_t = 1, env = \"LEMMY_FEDERATE_PROCESS_COUNT\")]\n  federate_process_count: i32,\n  #[command(subcommand)]\n  subcommand: Option<CmdSubcommand>,\n}\n\n#[derive(Subcommand, Debug)]\nenum CmdSubcommand {\n  /// Do something with migrations, then exit.\n  Migration {\n    #[command(subcommand)]\n    subcommand: MigrationSubcommand,\n    /// Stop after there's no remaining migrations.\n    #[arg(long, default_value_t = false)]\n    all: bool,\n    /// Stop after the given number of migrations.\n    #[arg(long, default_value_t = 1)]\n    number: u64,\n  },\n}\n\n#[derive(Subcommand, Debug, PartialEq, Eq)]\nenum MigrationSubcommand {\n  /// Run up.sql for pending migrations, oldest to newest.\n  Run,\n  /// Run down.sql for non-pending migrations, newest to oldest.\n  Revert,\n}\n\n/// Placing the main function in lib.rs allows other crates to import it and embed Lemmy\npub async fn start_lemmy_server(args: CmdArgs) -> LemmyResult<()> {\n  if let Some(CmdSubcommand::Migration {\n    subcommand,\n    all,\n    number,\n  }) = args.subcommand\n  {\n    let mut options = match subcommand {\n      MigrationSubcommand::Run => lemmy_diesel_utils::schema_setup::Options::default().run(),\n      MigrationSubcommand::Revert => lemmy_diesel_utils::schema_setup::Options::default().revert(),\n    }\n    .print_output();\n\n    if !all {\n      options = options.limit(number);\n    }\n\n    lemmy_diesel_utils::schema_setup::run(options, &SETTINGS.get_database_url_with_options()?)?;\n\n    #[cfg(debug_assertions)]\n    if all && subcommand == MigrationSubcommand::Run {\n      println!(\n        \"Warning: you probably want this command instead, which requires less crates to be compiled: cargo run --package lemmy_diesel_utils\"\n      );\n    }\n\n    return Ok(());\n  }\n\n  // Print version number to log\n  println!(\"Starting Lemmy v{}\", *VERSION);\n\n  // return error 503 while running db migrations and startup tasks\n  let mut startup_server_handle = None;\n  if !args.disable_http_server {\n    startup_server_handle = Some(create_startup_server()?);\n  }\n\n  // Set up the connection pool\n  let pool = build_db_pool()?;\n\n  // Initialize the secrets\n  let secret = Secret::init(&mut (&pool).into()).await?;\n\n  // Make sure the local site is set up.\n  let site_view = setup_local_site(&mut (&pool).into(), &SETTINGS).await?;\n  let federation_enabled = site_view.local_site.federation_enabled;\n\n  if federation_enabled {\n    println!(\"Federation enabled, host is {}\", &SETTINGS.hostname);\n  }\n\n  // Set up the rate limiter\n  let rate_limit_config =\n    local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit);\n  let rate_limit_cell = RateLimit::new(rate_limit_config);\n\n  println!(\n    \"Starting HTTP server at {}:{}\",\n    SETTINGS.bind, SETTINGS.port\n  );\n\n  let client = ClientBuilder::new(client_builder(&SETTINGS).build()?)\n    .with(TracingMiddleware::default())\n    .build();\n  let pictrs_client = ClientBuilder::new(client_builder(&SETTINGS).no_proxy().build()?)\n    .with(TracingMiddleware::default())\n    .build();\n  let context = LemmyContext::create(\n    pool.clone(),\n    client.clone(),\n    pictrs_client,\n    secret.clone(),\n    rate_limit_cell,\n  );\n\n  if let Some(prometheus) = SETTINGS.prometheus.clone() {\n    serve_prometheus(prometheus, context.clone())?;\n  }\n\n  let mut federation_config_builder = FederationConfig::builder();\n  federation_config_builder\n    .domain(SETTINGS.hostname.clone())\n    .app_data(context.clone())\n    .client(client.clone())\n    .http_fetch_limit(FEDERATION_HTTP_FETCH_LIMIT)\n    .debug(cfg!(debug_assertions))\n    .http_signature_compat(true)\n    .url_verifier(Box::new(VerifyUrlData(context.inner_pool().clone())));\n  if site_view.local_site.federation_signed_fetch {\n    let site: ApubSite = site_view.site.clone().into();\n    federation_config_builder.signed_fetch_actor(&site);\n  }\n  let federation_config = federation_config_builder.build().await?;\n\n  FETCH_COMMUNITY_COLLECTIONS\n    .set(fetch_community_collections)\n    .map_err(|_e| LemmyErrorType::Unknown(\"couldnt set function pointer\".into()))?;\n\n  let request_data = federation_config.to_request_data();\n  let outgoing_activities_task =\n    tokio::task::spawn(handle_outgoing_activities(request_data.clone()));\n\n  if !args.disable_scheduled_tasks {\n    // Schedules various cleanup tasks for the DB\n    let _scheduled_tasks = tokio::task::spawn(scheduled_tasks::setup(request_data.clone()));\n  }\n\n  let server = if !args.disable_http_server {\n    if let Some(startup_server_handle) = startup_server_handle {\n      startup_server_handle.stop(true).await;\n    }\n\n    Some(create_http_server(\n      federation_config.clone(),\n      SETTINGS.clone(),\n      site_view,\n    )?)\n  } else {\n    None\n  };\n\n  // This FederationConfig instance is exclusively used to send activities, so we can safely\n  // increase the timeout without affecting timeouts for resolving objects anywhere.\n  let federation_sender_config = if !args.disable_activity_sending {\n    let mut federation_sender_config = federation_config_builder.clone();\n    federation_sender_config.request_timeout(ACTIVITY_SENDING_TIMEOUT);\n    Some(federation_sender_config.build().await?)\n  } else {\n    None\n  };\n  let federate = federation_sender_config.map(|cfg| {\n    SendManager::run(\n      Opts {\n        process_index: args.federate_process_index,\n        process_count: args.federate_process_count,\n      },\n      cfg,\n      SETTINGS.federation.clone(),\n    )\n  });\n  let mut interrupt = tokio::signal::unix::signal(SignalKind::interrupt())?;\n  let mut terminate = tokio::signal::unix::signal(SignalKind::terminate())?;\n\n  tokio::select! {\n    _ = tokio::signal::ctrl_c() => {\n      tracing::warn!(\"Received ctrl-c, shutting down gracefully...\");\n    }\n    _ = interrupt.recv() => {\n      tracing::warn!(\"Received interrupt, shutting down gracefully...\");\n    }\n    _ = terminate.recv() => {\n      tracing::warn!(\"Received terminate, shutting down gracefully...\");\n    }\n  }\n  if let Some(server) = server {\n    server.stop(true).await;\n  }\n  if let Some(federate) = federate {\n    federate.cancel().await?;\n  }\n\n  // Wait for outgoing apub sends to complete\n  ActivityChannel::close(outgoing_activities_task).await?;\n\n  Ok(())\n}\n\n/// Creates temporary HTTP server which returns status 503 for all requests.\nfn create_startup_server() -> LemmyResult<ServerHandle> {\n  let startup_server = HttpServer::new(move || {\n    App::new().wrap(ErrorHandlers::new().default_handler(move |req| {\n      let (req, _) = req.into_parts();\n      let response =\n        HttpResponse::ServiceUnavailable().json(json!({\"error\": \"Lemmy is currently starting\"}));\n      let service_response = ServiceResponse::new(req, response);\n      Ok(ErrorHandlerResponse::Response(\n        service_response.map_into_right_body(),\n      ))\n    }))\n  })\n  .bind((SETTINGS.bind, SETTINGS.port))?\n  .run();\n  let startup_server_handle = startup_server.handle();\n  tokio::task::spawn(startup_server);\n  Ok(startup_server_handle)\n}\n\nfn create_http_server(\n  federation_config: FederationConfig<LemmyContext>,\n  settings: Settings,\n  site_view: SiteView,\n) -> LemmyResult<ServerHandle> {\n  // These must come before HttpServer creation so they can collect data across threads.\n  let prom_api_metrics = new_prometheus_metrics()?;\n  let idempotency_set = IdempotencySet::default();\n\n  // Create Http server\n  let bind = (settings.bind, settings.port);\n  let server = HttpServer::new(move || {\n    let context: LemmyContext = federation_config.deref().clone();\n    let rate_limit = federation_config.rate_limit_cell().clone();\n\n    let cors_config = cors_config(&settings);\n    let app = App::new()\n      .wrap(ErrorHandlers::new().default_handler(jsonify_plain_text_errors))\n      .wrap(middleware::Logger::new(\n        // This is the default log format save for the usage of %{r}a over %a to guarantee to\n        // record the client's (forwarded) IP and not the last peer address, since the latter is\n        // frequently just a reverse proxy\n        \"%{r}a '%r' %s %b '%{Referer}i' '%{User-Agent}i' %T\",\n      ))\n      .wrap(middleware::Compress::default())\n      .wrap(cors_config)\n      .wrap(TracingLogger::<DefaultRootSpanBuilder>::new())\n      .app_data(Data::new(context.clone()))\n      .wrap(FederationMiddleware::new(federation_config.clone()))\n      .wrap(IdempotencyMiddleware::new(idempotency_set.clone()))\n      .wrap(SessionMiddleware::new(context.clone()))\n      .wrap(Condition::new(\n        SETTINGS.prometheus.is_some(),\n        prom_api_metrics.clone(),\n      ));\n\n    // The routes\n    app\n      .configure(|cfg| lemmy_api_routes::config(cfg, &rate_limit))\n      .configure(|cfg| lemmy_api_routes_v3::config(cfg, &rate_limit))\n      .configure(|cfg| {\n        if site_view.local_site.federation_enabled {\n          lemmy_apub::http::routes::config(cfg);\n          webfinger::config(cfg);\n        }\n      })\n      .configure(feeds::config)\n      .configure(nodeinfo::config)\n      .service(\n        scope(\"/sitemap.xml\")\n          .wrap(rate_limit.message())\n          .route(\"\", get().to(get_sitemap)),\n      )\n  })\n  .disable_signals()\n  .bind(bind)?\n  .run();\n  let handle = server.handle();\n  tokio::task::spawn(server);\n  Ok(handle)\n}\n"
  },
  {
    "path": "crates/server/src/main.rs",
    "content": "use clap::Parser;\nuse lemmy_server::{CmdArgs, start_lemmy_server};\nuse lemmy_utils::{error::LemmyResult, settings::SETTINGS};\nuse tracing::level_filters::LevelFilter;\nuse tracing_subscriber::EnvFilter;\n\n#[tokio::main]\npub async fn main() -> LemmyResult<()> {\n  let filter = EnvFilter::builder()\n    .with_default_directive(LevelFilter::INFO.into())\n    .from_env_lossy();\n  if SETTINGS.json_logging {\n    tracing_subscriber::fmt()\n      .with_env_filter(filter)\n      .json()\n      .init();\n  } else {\n    tracing_subscriber::fmt().with_env_filter(filter).init();\n  }\n\n  let args = CmdArgs::parse();\n\n  start_lemmy_server(args).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "crates/utils/Cargo.toml",
    "content": "[package]\nname = \"lemmy_utils\"\nversion.workspace = true\nedition.workspace = true\ndescription.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\ndocumentation.workspace = true\nrepository.workspace = true\nrust-version.workspace = true\n\n[lib]\nname = \"lemmy_utils\"\npath = \"src/lib.rs\"\ndoctest = false\n\n[[bin]]\nname = \"lemmy_util_bin\"\npath = \"src/main.rs\"\nrequired-features = [\"full\"]\n\n[lints]\nworkspace = true\n\n[features]\nfull = [\n  \"diesel\",\n  \"actix-web\",\n  \"tracing\",\n  \"actix-web\",\n  \"serde_json\",\n  \"anyhow\",\n  \"http\",\n  \"deser-hjson\",\n  \"regex\",\n  \"urlencoding\",\n  \"doku\",\n  \"url\",\n  \"smart-default\",\n  \"enum-map\",\n  \"futures\",\n  \"tokio\",\n  \"itertools\",\n  \"markdown-it\",\n  \"moka\",\n  \"actix-extensible-rate-limit\",\n  \"dashmap\",\n]\nts-rs = [\"dep:ts-rs\"]\n\n[package.metadata.cargo-shear]\nignored = [\"http\"]\n\n[dependencies]\nregex = { workspace = true, optional = true }\ntracing = { workspace = true, optional = true }\nitertools = { workspace = true, optional = true }\nserde = { workspace = true }\nserde_json = { workspace = true, optional = true }\nurl = { workspace = true, optional = true }\nactix-web = { workspace = true, optional = true }\nanyhow = { workspace = true, optional = true }\nstrum = { workspace = true }\nfutures = { workspace = true, optional = true }\ndiesel = { workspace = true, optional = true }\nhttp = { workspace = true, optional = true }\ndoku = { workspace = true, features = [\"url-2\"], optional = true }\ntokio = { workspace = true, optional = true }\nurlencoding = { workspace = true, optional = true }\ndeser-hjson = { version = \"2.2.5\", optional = true }\nsmart-default = { version = \"0.7.1\", optional = true }\nmarkdown-it = { version = \"0.6.1\", optional = true }\nts-rs = { workspace = true, optional = true }\nenum-map = { version = \"2.7\", optional = true }\nchrono = { workspace = true }\ncfg-if = { workspace = true }\nclearurls = { version = \"0.0.4\", features = [\"linkify\"] }\nmarkdown-it-block-spoiler = \"1.0.3\"\nmarkdown-it-sub = \"1.0.2\"\nmarkdown-it-sup = \"1.0.2\"\nmarkdown-it-ruby = \"1.0.2\"\nmarkdown-it-footnote = \"0.2.0\"\nmoka = { workspace = true, optional = true }\ngit-version = \"0.3.9\"\nunicode-segmentation = \"1.12.0\"\ninvisible-characters = \"0.1.5\"\nactix-extensible-rate-limit = { version = \"0.4.0\", optional = true }\ndashmap = { version = \"6.1.0\", optional = true }\nserde_with = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\nunified-diff = { workspace = true }\ntokio = { workspace = true, features = [\"test-util\"] }\n"
  },
  {
    "path": "crates/utils/src/cache_header.rs",
    "content": "use actix_web::middleware::DefaultHeaders;\n\n/// Adds a cache header to requests\n///\n/// Common cache amounts are:\n///   * 1 hour = 60s * 60m = `3600` seconds\n///   * 3 days = 60s * 60m * 24h * 3d = `259200` seconds\n///\n/// Mastodon & other activitypub server defaults to 3d\nfn cache_header(seconds: usize) -> DefaultHeaders {\n  DefaultHeaders::new().add((\"Cache-Control\", format!(\"public, max-age={seconds}\")))\n}\n\n/// Set a 1 hour cache time\npub fn cache_1hour() -> DefaultHeaders {\n  cache_header(3600)\n}\n\n/// Set a 3 day cache time\npub fn cache_3days() -> DefaultHeaders {\n  cache_header(259200)\n}\n"
  },
  {
    "path": "crates/utils/src/error.rs",
    "content": "use cfg_if::cfg_if;\nuse serde::{Deserialize, Serialize};\nuse std::{fmt::Debug, panic::Location};\nuse strum::{Display, EnumIter};\n\n/// Errors used in the API, all of these are translated in lemmy-ui.\n#[derive(Display, Debug, Serialize, Deserialize, Clone, PartialEq, EnumIter, Eq, Hash)]\n#[cfg_attr(feature = \"ts-rs\", derive(ts_rs::TS))]\n#[cfg_attr(feature = \"ts-rs\", ts(export))]\n#[serde(tag = \"error\", content = \"message\", rename_all = \"snake_case\")]\n#[non_exhaustive]\npub enum LemmyErrorType {\n  BlockKeywordTooShort,\n  BlockKeywordTooLong,\n  CouldntUpdate,\n  CouldntCreate,\n  ReportReasonRequired,\n  ReportTooLong,\n  NotAModerator,\n  NotAnAdmin,\n  CantBlockYourself,\n  CantNoteYourself,\n  CantBlockAdmin,\n  PasswordsDoNotMatch,\n  EmailNotVerified,\n  EmailRequired,\n  CannotLeaveAdmin,\n  CannotLeaveMod,\n  PictrsResponseError(String),\n  PictrsPurgeResponseError(String),\n  PictrsApiKeyNotProvided,\n  PictrsInvalidImageUpload(String),\n  NoContentTypeHeader,\n  NotAnImageType,\n  ImageUploadDisabled,\n  NotAModOrAdmin,\n  NotTopMod,\n  NotLoggedIn,\n  NotHigherMod,\n  NotHigherAdmin,\n  SiteBan,\n  Deleted,\n  PersonIsBlocked,\n  CommunityIsBlocked,\n  InstanceIsBlocked,\n  InstanceIsPrivate,\n  /// Password must be between 10 and 60 characters\n  InvalidPassword,\n  SiteDescriptionLengthOverflow,\n  HoneypotFailed,\n  RegistrationApplicationIsPending,\n  Locked,\n  MaxCommentDepthReached,\n  NoCommentEditAllowed,\n  OnlyAdminsCanCreateCommunities,\n  AlreadyExists,\n  LanguageNotAllowed,\n  NoPostEditAllowed,\n  NsfwNotAllowed,\n  EditPrivateMessageNotAllowed,\n  ApplicationQuestionRequired,\n  InvalidDefaultPostListingType,\n  RegistrationClosed,\n  RegistrationApplicationAnswerRequired,\n  RegistrationUsernameRequired,\n  EmailAlreadyTaken,\n  UsernameAlreadyTaken,\n  PersonIsBannedFromCommunity,\n  NoIdGiven,\n  IncorrectLogin,\n  NoEmailSetup,\n  LocalSiteNotSetup,\n  InvalidEmailAddress(String),\n  InvalidName,\n  InvalidCodeVerifier,\n  InvalidDisplayName,\n  InvalidMatrixId,\n  InvalidPostTitle,\n  InvalidBodyField,\n  BioLengthOverflow,\n  AltTextLengthOverflow,\n  CouldntParseTotpSecret,\n  CouldntGenerateTotp,\n  MissingTotpToken,\n  MissingTotpSecret,\n  IncorrectTotpToken,\n  TotpAlreadyEnabled,\n  BlockedUrl,\n  InvalidUrl,\n  EmailSendFailed,\n  Slurs,\n  RegistrationDenied(String),\n  SiteNameRequired,\n  SiteNameLengthOverflow,\n  PermissiveRegex,\n  InvalidRegex,\n  InvalidUrlScheme,\n  ContradictingFilters,\n  /// Thrown when an API call is submitted with more than 1000 array elements, see\n  /// [[MAX_API_PARAM_ELEMENTS]]\n  TooManyItems,\n  BanExpirationInPast,\n  InvalidUnixTime,\n  InvalidBotAction,\n  TagNotInCommunity,\n  CantBlockLocalInstance,\n  Unknown(String),\n  UrlLengthOverflow,\n  OauthAuthorizationInvalid,\n  OauthLoginFailed,\n  OauthRegistrationClosed,\n  NotFound,\n  PostScheduleTimeMustBeInFuture,\n  TooManyScheduledPosts,\n  CannotCombineFederationBlocklistAndAllowlist,\n  CouldntParsePaginationToken,\n  PluginError(String),\n  InvalidFetchLimit,\n  EmailNotificationsDisabled,\n  MultiCommunityUpdateWrongUser,\n  CannotCombineCommunityIdAndMultiCommunityId,\n  MultiCommunityEntryLimitReached,\n  TooManyRequests,\n  ResolveObjectFailed(String),\n  #[serde(untagged)]\n  #[cfg_attr(feature = \"ts-rs\", ts(skip))]\n  UntranslatedError(Option<UntranslatedError>),\n}\n\n/// These errors are only used for federation or internally and dont need to be translated.\n#[derive(Display, Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]\n#[serde(tag = \"error\", content = \"message\", rename_all = \"snake_case\")]\n#[non_exhaustive]\npub enum UntranslatedError {\n  InvalidCommunity,\n  CannotCreatePostOrCommentInDeletedOrRemovedCommunity,\n  CannotReceivePage,\n  OnlyLocalAdminCanRemoveCommunity,\n  OnlyLocalAdminCanRestoreCommunity,\n  PostIsLocked,\n  PersonIsBannedFromSite(String),\n  InvalidVoteValue,\n  PageDoesNotSpecifyCreator,\n  FederationDisabled,\n  DomainBlocked(String),\n  DomainNotInAllowList(String),\n  FederationDisabledByStrictAllowList,\n  ContradictingFilters,\n  UrlWithoutDomain,\n  InboxTimeout,\n  CantDeleteSite,\n  ObjectIsNotPublic,\n  ObjectIsNotPrivate,\n  InvalidFollow(String),\n  PurgeInvalidImageUrl,\n  Unreachable,\n  CouldntSendWebmention,\n  /// A remote community sent an activity to us, but actually no local user follows the community\n  /// so the activity was rejected.\n  CommunityHasNoFollowers(String),\n}\n\ncfg_if! {\n  if #[cfg(feature = \"full\")] {\n\n    use std::fmt;\n    use serde_with::serde_as;\n    use serde_with::DisplayFromStr;\n\n    pub type LemmyResult<T> = Result<T, LemmyError>;\n\n    #[serde_as]\n    #[derive(Serialize)]\n    pub struct LemmyError {\n      #[serde(flatten)]\n      pub error_type: LemmyErrorType,\n      #[serde_as(as = \"DisplayFromStr\")]\n      pub cause: anyhow::Error,\n      #[serde(skip)]\n      pub caller: Location<'static>,\n    }\n\n    /// Maximum number of items in an array passed as API parameter. See [[LemmyErrorType::TooManyItems]]\n    pub(crate) const MAX_API_PARAM_ELEMENTS: usize = 10_000;\n\n    impl<T> From<T> for LemmyError\n    where\n      T: Into<anyhow::Error>,\n    {\n    #[track_caller]\n      fn from(t: T) -> Self {\n        let cause = t.into();\n        let error_type = match cause.downcast_ref::<diesel::result::Error>() {\n          Some(&diesel::NotFound) => LemmyErrorType::NotFound,\n          _ => LemmyErrorType::Unknown(format!(\"{}\", &cause))\n      };\n        LemmyError {\n          error_type,\n          cause,\n          caller: *Location::caller(),\n        }\n      }\n    }\n\n    impl Debug for LemmyError {\n      fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_struct(\"LemmyError\")\n         .field(\"message\", &self.error_type)\n         .field(\"caller\", &format_args!(\"{}\", self.caller))\n         .field(\"inner\", &self.cause)\n         .finish()\n      }\n    }\n\n    impl fmt::Display for LemmyError {\n      fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        write!(f, \"{}: \", &self.error_type)?;\n        write!(f, \"{}\", self.caller)?;\n        write!(f, \"{}\", self.cause)?;\n        Ok(())\n      }\n    }\n\n    impl actix_web::error::ResponseError for LemmyError {\n      fn status_code(&self) -> actix_web::http::StatusCode {\n        match self.error_type {\n          LemmyErrorType::IncorrectLogin => actix_web::http::StatusCode::UNAUTHORIZED,\n          LemmyErrorType::NotFound => actix_web::http::StatusCode::NOT_FOUND,\n          _ => actix_web::http::StatusCode::BAD_REQUEST,\n        }\n      }\n\n      fn error_response(&self) -> actix_web::HttpResponse {\n        actix_web::HttpResponse::build(self.status_code()).json(self)\n      }\n    }\n\n    impl From<LemmyErrorType> for LemmyError {\n    #[track_caller]\n      fn from(error_type: LemmyErrorType) -> Self {\n\n        let cause = anyhow::anyhow!(\"{}\", error_type);\n        LemmyError {\n          error_type,\n          cause,\n          caller: *Location::caller(),\n        }\n      }\n    }\n\n    impl From<UntranslatedError> for LemmyError {\n    #[track_caller]\n      fn from(error_type: UntranslatedError) -> Self {\n        let cause = anyhow::anyhow!(\"{}\", error_type);\n        LemmyError {\n          error_type: LemmyErrorType::UntranslatedError( Some(error_type) ),\n          cause,\n          caller: *Location::caller(),\n        }\n      }\n    }\n\n    impl From<UntranslatedError> for LemmyErrorType {\n      fn from(error: UntranslatedError) -> Self {\n        LemmyErrorType::UntranslatedError (Some(error) )\n      }\n    }\n\n    pub trait LemmyErrorExt<T, E: Into<anyhow::Error>> {\n      fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult<T>;\n    }\n\n    impl<T, E: Into<anyhow::Error>> LemmyErrorExt<T, E> for Result<T, E> {\n    #[track_caller]\n      fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult<T> {\n        self.map_err(|error| LemmyError {\n          error_type,\n          cause: error.into(),\n          caller: *Location::caller(),\n        })\n      }\n    }\n    pub trait LemmyErrorExt2<T> {\n      fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult<T>;\n      fn into_anyhow(self) -> Result<T, anyhow::Error>;\n    }\n\n    impl<T> LemmyErrorExt2<T> for LemmyResult<T> {\n      fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult<T> {\n        self.map_err(|mut e| {\n          e.error_type = error_type;\n          e\n        })\n      }\n      // this function can't be an impl From or similar because it would conflict with one of the other broad Into<> implementations\n      fn into_anyhow(self) -> Result<T, anyhow::Error> {\n        self.map_err(|e| e.cause)\n      }\n    }\n\n    #[cfg(test)]\n    mod tests {\n      #![allow(clippy::indexing_slicing)]\n      use super::*;\n      use actix_web::{body::MessageBody, ResponseError};\n      use pretty_assertions::assert_eq;\n\n      #[test]\n      fn untranslated_error_format() -> LemmyResult<()> {\n        let err = LemmyError::from(UntranslatedError::DomainBlocked(\"test\".to_string())).error_response();\n        let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?;\n        assert_eq!(&json, r#\"{\"error\":\"domain_blocked\",\"message\":\"test\",\"cause\":\"DomainBlocked\"}\"#);\n\n        Ok(())\n      }\n\n      #[test]\n      fn deserializes_no_message() -> LemmyResult<()> {\n        let err = LemmyError::from(LemmyErrorType::BlockedUrl).error_response();\n        let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?;\n        assert_eq!(&json, r#\"{\"error\":\"blocked_url\",\"cause\":\"BlockedUrl\"}\"#);\n\n        Ok(())\n      }\n\n      #[test]\n      fn deserializes_with_message() -> LemmyResult<()> {\n        let reg_banned = LemmyErrorType::PictrsResponseError(String::from(\"reason\"));\n        let err = LemmyError::from(reg_banned).error_response();\n        let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?;\n        assert_eq!(\n          &json,\n          r#\"{\"error\":\"pictrs_response_error\",\"message\":\"reason\",\"cause\":\"PictrsResponseError\"}\"#\n        );\n\n        Ok(())\n      }\n\n      #[test]\n      fn test_convert_diesel_errors() {\n        let not_found_error = LemmyError::from(diesel::NotFound);\n        assert_eq!(LemmyErrorType::NotFound, not_found_error.error_type);\n        assert_eq!(404, not_found_error.status_code());\n\n        let other_error = LemmyError::from(diesel::result::Error::NotInTransaction);\n        assert!(matches!(other_error.error_type, LemmyErrorType::Unknown{..}));\n        assert_eq!(400, other_error.status_code());\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/lib.rs",
    "content": "use cfg_if::cfg_if;\nuse chrono::Utc;\nuse std::{cmp::min, sync::LazyLock};\n\ncfg_if! {\n  if #[cfg(feature = \"full\")] {\n    pub mod cache_header;\n    pub mod rate_limit;\n    pub mod response;\n    pub mod settings;\n    pub mod utils;\n  }\n}\n\npub mod error;\nuse std::time::Duration;\n\npub type ConnectionId = usize;\n\npub static VERSION: LazyLock<String> = LazyLock::new(version);\n\npub const REQWEST_TIMEOUT: Duration = Duration::from_secs(10);\n\n// TODO: use from_days once stabilized\n// https://github.com/rust-lang/rust/issues/120301\nconst DAY: Duration = Duration::from_secs(24 * 60 * 60);\n\n#[cfg(debug_assertions)]\npub const CACHE_DURATION_FEDERATION: Duration = Duration::from_secs(0);\n#[cfg(not(debug_assertions))]\npub const CACHE_DURATION_FEDERATION: Duration = Duration::from_secs(60);\n\n#[cfg(debug_assertions)]\npub const CACHE_DURATION_API: Duration = Duration::from_secs(0);\n#[cfg(not(debug_assertions))]\npub const CACHE_DURATION_API: Duration = Duration::from_secs(1);\n\n#[cfg(debug_assertions)]\npub const CACHE_DURATION_LARGEST_COMMUNITY: Duration = Duration::from_secs(0);\n#[cfg(not(debug_assertions))]\npub const CACHE_DURATION_LARGEST_COMMUNITY: Duration = DAY;\n\npub const MAX_COMMENT_DEPTH_LIMIT: usize = 50;\n\n/// Doing DB transactions of bigger batches than this tend to cause seq scans.\npub const DB_BATCH_SIZE: i64 = 1000;\n\nfn version() -> String {\n  if cfg!(debug_assertions) {\n    // For debug simply use the version from Cargo.toml. We can't use git_version here\n    // because it would cause a rebuild if any file in the repo is changed.\n    env!(\"CARGO_PKG_VERSION\").to_string()\n  } else {\n    // Event cron means its a nightly build\n    // https://woodpecker-ci.org/docs/usage/environment\n    if option_env!(\"CI_PIPELINE_EVENT\") == Some(\"cron\") {\n      format!(\"nightly-{}\", Utc::now().date_naive())\n    } else {\n      // For actual release builds use git binary for detailed version information.\n      git_version::git_version!(\n        args = [\"--tags\", \"--dirty=-modified\"],\n        fallback = env!(\"CARGO_PKG_VERSION\")\n      )\n      .to_string()\n    }\n  }\n}\n\n#[macro_export]\nmacro_rules! location_info {\n  () => {\n    format!(\n      \"None value at {}:{}, column {}\",\n      file!(),\n      line!(),\n      column!()\n    )\n  };\n}\n\ncfg_if! {\n  if #[cfg(feature = \"full\")] {\nuse moka::future::Cache;use std::fmt::Debug;use std::hash::Hash;\nuse serde_json::Value;\n\n/// Only include a basic context to save space and bandwidth. The main context is hosted statically\n/// on join-lemmy.org. Include activitystreams explicitly for better compat, but this could\n/// theoretically also be moved.\npub static FEDERATION_CONTEXT: LazyLock<Value> = LazyLock::new(|| {\n  Value::Array(vec![\n    Value::String(\"https://join-lemmy.org/context.json\".to_string()),\n    Value::String(\"https://www.w3.org/ns/activitystreams\".to_string()),\n  ])\n});\n\n/// tokio::spawn, but accepts a future that may fail and also\n/// * logs errors\n/// * attaches the spawned task to the tracing span of the caller for better logging\npub fn spawn_try_task(\n  task: impl futures::Future<Output = Result<(), error::LemmyError>> + Send + 'static,\n) {\n  use tracing::Instrument;\n  tokio::spawn(\n    async {\n      if let Err(e) = task.await {\n        tracing::warn!(\"error in spawn: {e}\");\n      }\n    }\n    .in_current_span(), /* this makes sure the inner tracing gets the same context as where\n                         * spawn was called */\n  );\n}\n\npub fn build_cache<K, V>() -> Cache<K, V>\nwhere\n  K: Debug + Eq + Hash + Send + Sync + 'static,\n  V: Debug + Clone + Send + Sync + 'static,\n{\n  Cache::<K, V>::builder()\n    .max_capacity(1)\n    .time_to_live(CACHE_DURATION_API)\n    .build()\n}\n\n#[cfg(feature = \"full\")]\npub type CacheLock<T> = std::sync::LazyLock<Cache<(), T>>;\n\n  }\n}\n\n/// Calculate how long to sleep until next federation send based on how many\n/// retries have already happened. Uses exponential backoff with maximum of one day. The first\n/// error is ignored.\npub fn federate_retry_sleep_duration(retry_count: i32) -> Duration {\n  debug_assert!(retry_count != 0);\n  if retry_count == 1 {\n    return Duration::from_secs(0);\n  }\n  let retry_count = retry_count - 1;\n  let pow = 1.25_f64.powf(retry_count.into());\n  let pow = Duration::try_from_secs_f64(pow).unwrap_or(DAY);\n  min(DAY, pow)\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use super::*;\n\n  #[test]\n  fn test_federate_retry_sleep_duration() {\n    assert_eq!(Duration::from_secs(0), federate_retry_sleep_duration(1));\n    assert_eq!(\n      Duration::new(1, 250000000),\n      federate_retry_sleep_duration(2)\n    );\n    assert_eq!(\n      Duration::new(2, 441406250),\n      federate_retry_sleep_duration(5)\n    );\n    assert_eq!(DAY, federate_retry_sleep_duration(100));\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/main.rs",
    "content": "use cfg_if::cfg_if;\n\nfn main() {\n  cfg_if! {\n    if #[cfg(feature = \"full\")] {\n      println!(\"{}\", config_to_string())\n    } else {\n    }\n  }\n}\n\n#[cfg(feature = \"full\")]\nfn config_to_string() -> String {\n  use doku::json::{AutoComments, CommentsStyle, Formatting, ObjectsStyle};\n  use lemmy_utils::settings::structs::Settings;\n  let fmt = Formatting {\n    auto_comments: AutoComments::none(),\n    comments_style: CommentsStyle {\n      separator: \"#\".to_owned(),\n    },\n    objects_style: ObjectsStyle {\n      surround_keys_with_quotes: false,\n      use_comma_as_separator: false,\n    },\n    ..Default::default()\n  };\n  doku::to_json_fmt_val(&fmt, &Settings::default())\n}\n\n#[cfg(test)]\nmod test {\n  use crate::config_to_string;\n\n  #[test]\n  fn test_config_defaults_updated() -> lemmy_utils::error::LemmyResult<()> {\n    let current_config = std::fs::read_to_string(\"../../config/defaults.hjson\")?;\n    let mut updated_config = config_to_string();\n    updated_config.push('\\n');\n    if current_config != updated_config {\n      let diff = unified_diff::diff(\n        current_config.as_bytes(),\n        \"current\",\n        updated_config.as_bytes(),\n        \"expected\",\n        3,\n      );\n      panic!(\"{}\", String::from_utf8_lossy(&diff));\n    }\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/rate_limit/backend.rs",
    "content": "//! The content in this file is mostly copy-pasted from library code:\n//! https://github.com/jacob-pro/actix-extensible-rate-limit/blob/master/src/backend/memory.rs\n\nuse crate::rate_limit::{ActionType, BucketConfig, input::LemmyInput};\nuse actix_extensible_rate_limit::backend::{\n  Backend,\n  Decision,\n  SimpleOutput,\n  memory::DEFAULT_GC_INTERVAL_SECONDS,\n};\nuse actix_web::rt::{task::JoinHandle, time::Instant};\nuse dashmap::DashMap;\nuse enum_map::EnumMap;\nuse std::{\n  convert::Infallible,\n  sync::{Arc, RwLock},\n  time::Duration,\n};\n\n/// A Fixed Window rate limiter [Backend] that uses [Dashmap](dashmap::DashMap) to store keys\n/// in memory.\n#[derive(Clone)]\npub struct LemmyBackend {\n  map: Arc<DashMap<LemmyInput, Value>>,\n  gc_handle: Option<Arc<JoinHandle<()>>>,\n  pub(super) configs: Arc<RwLock<EnumMap<ActionType, BucketConfig>>>,\n}\n\nstruct Value {\n  ttl: Instant,\n  count: u64,\n}\n\nimpl LemmyBackend {\n  pub(crate) fn new(configs: EnumMap<ActionType, BucketConfig>, enable_gc: bool) -> Self {\n    let map = Arc::new(DashMap::<LemmyInput, Value>::new());\n    let gc_handle = enable_gc.then(|| {\n      Arc::new(LemmyBackend::garbage_collector(\n        map.clone(),\n        Duration::from_secs(DEFAULT_GC_INTERVAL_SECONDS),\n      ))\n    });\n    LemmyBackend {\n      map,\n      gc_handle,\n      configs: Arc::new(RwLock::new(configs)),\n    }\n  }\n\n  fn garbage_collector(map: Arc<DashMap<LemmyInput, Value>>, interval: Duration) -> JoinHandle<()> {\n    assert!(\n      interval.as_secs_f64() > 0f64,\n      \"GC interval must be non-zero\"\n    );\n    tokio::spawn(async move {\n      loop {\n        let now = Instant::now();\n        map.retain(|_k, v| v.ttl > now);\n        tokio::time::sleep_until(now + interval).await;\n      }\n    })\n  }\n}\n\nimpl Backend<LemmyInput> for LemmyBackend {\n  type Output = SimpleOutput;\n  type RollbackToken = LemmyInput;\n  type Error = Infallible;\n\n  #[expect(clippy::expect_used)]\n  async fn request(\n    &self,\n    input: LemmyInput,\n  ) -> Result<(Decision, Self::Output, Self::RollbackToken), Self::Error> {\n    #[expect(clippy::expect_used)]\n    let config = self.configs.read().expect(\"read rwlock\")[input.1];\n\n    let max_requests: u64 = config.max_requests.into();\n    let interval = Duration::from_secs(config.interval.into());\n\n    let now = Instant::now();\n    let mut count = 1;\n    let mut expiry = now\n      .checked_add(interval)\n      .expect(\"Interval unexpectedly large\");\n    self\n      .map\n      .entry(input)\n      .and_modify(|v| {\n        // If this bucket hasn't yet expired, increment and extract the count/expiry\n        if v.ttl > now {\n          v.count += 1;\n          count = v.count;\n          expiry = v.ttl;\n        } else {\n          // If this bucket has expired we will reset the count to 1 and set a new TTL.\n          v.ttl = expiry;\n          v.count = count;\n        }\n      })\n      .or_insert_with(|| Value {\n        // If the bucket doesn't exist, create it with a count of 1, and set the TTL.\n        ttl: expiry,\n        count,\n      });\n    let allow = count <= max_requests;\n    let output = SimpleOutput {\n      limit: max_requests,\n      remaining: max_requests.saturating_sub(count),\n      reset: expiry,\n    };\n    Ok((Decision::from_allowed(allow), output, input))\n  }\n\n  async fn rollback(&self, token: Self::RollbackToken) -> Result<(), Self::Error> {\n    self.map.entry(token).and_modify(|v| {\n      v.count = v.count.saturating_sub(1);\n    });\n    Ok(())\n  }\n}\n\nimpl Drop for LemmyBackend {\n  fn drop(&mut self) {\n    if let Some(handle) = &self.gc_handle {\n      handle.abort();\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::{\n    error::LemmyResult,\n    rate_limit::{ActionType, input::raw_ip_key},\n  };\n  use enum_map::enum_map;\n\n  const MINUTE_SECS: u32 = 60;\n  const MINUTE: Duration = Duration::from_secs(60);\n\n  fn test_config(interval: u32, max_requests: u32) -> EnumMap<ActionType, BucketConfig> {\n    enum_map! {\n        ActionType::Message => BucketConfig {\n          max_requests,\n          interval\n        },\n        ActionType::Post => BucketConfig {\n          max_requests: 1,\n          interval: 120,\n        },\n        ActionType::Register => BucketConfig {\n          max_requests: 0,\n          interval: 0,\n        },\n        ActionType::Image => BucketConfig {\n          max_requests: 0,\n          interval: 0,\n        },\n        ActionType::Comment => BucketConfig {\n          max_requests: 0,\n          interval: 0,\n        },\n        ActionType::Search => BucketConfig {\n          max_requests: 0,\n          interval: 0,\n        },\n        ActionType::ImportUserSettings => BucketConfig {\n          max_requests: 0,\n          interval: 0,\n        },\n    }\n  }\n\n  #[actix_web::test]\n  async fn test_allow_deny() -> LemmyResult<()> {\n    tokio::time::pause();\n    let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true);\n    let key = raw_ip_key(Some(\"127.0.0.2\"));\n    let input = LemmyInput(key, ActionType::Message);\n    for _ in 0..5 {\n      // First 5 should be allowed\n      let (allow, _, _) = backend.request(input).await?;\n      assert!(allow.is_allowed());\n    }\n    // Sixth should be denied\n    let (allow, _, _) = backend.request(input).await?;\n    assert!(!allow.is_allowed());\n    Ok(())\n  }\n\n  #[actix_web::test]\n  async fn test_reset() -> LemmyResult<()> {\n    tokio::time::pause();\n    let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), false);\n    let input = LemmyInput(raw_ip_key(Some(\"127.0.0.3\")), ActionType::Message);\n    // Make first request, should be allowed\n    let (decision, _, _) = backend.request(input).await?;\n    assert!(decision.is_allowed());\n    // Request again, should be denied\n    let (decision, _, _) = backend.request(input).await?;\n    assert!(decision.is_denied());\n    // Advance time and try again, should now be allowed\n    tokio::time::advance(MINUTE).await;\n    // We want to be sure the key hasn't been garbage collected, and we are testing the expiry logic\n    assert!(backend.map.contains_key(&input));\n    let (decision, _, _) = backend.request(input).await?;\n    assert!(decision.is_allowed());\n    Ok(())\n  }\n\n  #[actix_web::test]\n  async fn test_garbage_collection() -> LemmyResult<()> {\n    tokio::time::pause();\n    let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), true);\n    let key1 = LemmyInput(raw_ip_key(Some(\"127.0.0.4\")), ActionType::Message);\n    let key2 = LemmyInput(raw_ip_key(Some(\"127.0.0.5\")), ActionType::Post);\n    backend.request(key1).await?;\n    backend.request(key2).await?;\n    assert!(backend.map.contains_key(&key1));\n    assert!(backend.map.contains_key(&key2));\n    // Advance time such that the garbage collector runs,\n    // expired KEY1 should be cleaned, but KEY2 should remain.\n    tokio::time::advance(MINUTE).await;\n    assert!(!backend.map.contains_key(&key1));\n    assert!(backend.map.contains_key(&key2));\n    Ok(())\n  }\n\n  #[actix_web::test]\n  async fn test_output() -> LemmyResult<()> {\n    tokio::time::pause();\n    let backend = LemmyBackend::new(test_config(MINUTE_SECS, 2), true);\n    let key = raw_ip_key(Some(\"127.0.0.6\"));\n    let input = LemmyInput(key, ActionType::Message);\n    // First of 2 should be allowed.\n    let (decision, output, _) = backend.request(input).await?;\n    assert!(decision.is_allowed());\n    assert_eq!(output.remaining, 1);\n    assert_eq!(output.limit, 2);\n    assert_eq!(output.reset, Instant::now() + MINUTE);\n    // Second of 2 should be allowed.\n    let (decision, output, _) = backend.request(input).await?;\n    assert!(decision.is_allowed());\n    assert_eq!(output.remaining, 0);\n    assert_eq!(output.limit, 2);\n    assert_eq!(output.reset, Instant::now() + MINUTE);\n    // Should be denied\n    let (decision, output, _) = backend.request(input).await?;\n    assert!(decision.is_denied());\n    assert_eq!(output.remaining, 0);\n    assert_eq!(output.limit, 2);\n    assert_eq!(output.reset, Instant::now() + MINUTE);\n    Ok(())\n  }\n\n  #[actix_web::test]\n  async fn test_rollback() -> LemmyResult<()> {\n    tokio::time::pause();\n    let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true);\n    let key = raw_ip_key(Some(\"127.0.0.7\"));\n    let input = LemmyInput(key, ActionType::Message);\n    let (_, output, rollback) = backend.request(input).await?;\n    assert_eq!(output.remaining, 4);\n    backend.rollback(rollback).await?;\n    // Remaining requests should still be the same, since the previous call was excluded\n    let (_, output, _) = backend.request(input).await?;\n    assert_eq!(output.remaining, 4);\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/rate_limit/input.rs",
    "content": "use crate::rate_limit::ActionType;\nuse std::{\n  future::Ready,\n  net::{IpAddr, Ipv4Addr, SocketAddr},\n  str::FromStr,\n};\n\n#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]\npub struct LemmyInput(pub(crate) RateLimitIpAddr, pub(crate) ActionType);\n\npub(crate) type LemmyInputFuture = Ready<Result<LemmyInput, actix_web::Error>>;\n\n#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]\npub(crate) enum RateLimitIpAddr {\n  V4(Ipv4Addr),\n  V6([u16; 4]),\n}\n\n#[expect(clippy::expect_used)]\nimpl From<IpAddr> for RateLimitIpAddr {\n  fn from(value: IpAddr) -> Self {\n    match value {\n      IpAddr::V4(addr) => RateLimitIpAddr::V4(addr),\n      IpAddr::V6(addr) => RateLimitIpAddr::V6(\n        addr.segments()[..4]\n          .try_into()\n          .expect(\"byte array is correct length\"),\n      ),\n    }\n  }\n}\n\n/// Generate a raw byte key for backend which uses less memory.\npub(crate) fn raw_ip_key(ip_str: Option<&str>) -> RateLimitIpAddr {\n  parse_ip(ip_str).into()\n}\n\nfn parse_ip(addr: Option<&str>) -> IpAddr {\n  if let Some(addr) = addr {\n    if let Ok(ip) = IpAddr::from_str(addr) {\n      return ip;\n    } else if let Ok(socket) = SocketAddr::from_str(addr) {\n      return socket.ip();\n    }\n  }\n  Ipv4Addr::new(127, 0, 0, 1).into()\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::error::LemmyResult;\n\n  #[test]\n  fn test_get_ip() -> LemmyResult<()> {\n    // Check that IPv4 addresses are preserved\n    assert_eq!(\n      raw_ip_key(Some(\"142.250.187.206\")),\n      \"142.250.187.206\".parse::<IpAddr>()?.into()\n    );\n    // Check that IPv6 addresses are grouped into /64 subnets\n    assert_eq!(\n      raw_ip_key(Some(\"2a00:1450:4009:81f::200e\")),\n      RateLimitIpAddr::V6([0x2a00, 0x1450, 0x4009, 0x81f])\n    );\n    assert_eq!(\n      raw_ip_key(Some(\"[2a00:1450:4009:81f::200e]:123\")),\n      RateLimitIpAddr::V6([0x2a00, 0x1450, 0x4009, 0x81f])\n    );\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/rate_limit/mod.rs",
    "content": "use crate::rate_limit::{\n  backend::LemmyBackend,\n  input::{LemmyInput, LemmyInputFuture, raw_ip_key},\n};\nuse actix_extensible_rate_limit::{RateLimiter, backend::SimpleOutput};\nuse actix_web::dev::ServiceRequest;\nuse enum_map::{EnumMap, enum_map};\nuse std::future::ready;\nuse strum::{AsRefStr, Display};\n\nmod backend;\nmod input;\n\n#[derive(Debug, enum_map::Enum, Copy, Clone, Display, AsRefStr, Eq, PartialEq, Hash)]\npub enum ActionType {\n  Message,\n  Register,\n  Post,\n  Image,\n  Comment,\n  Search,\n  ImportUserSettings,\n}\n\n#[derive(PartialEq, Debug, Copy, Clone)]\npub struct BucketConfig {\n  pub max_requests: u32,\n  pub interval: u32,\n}\n\n#[derive(Clone)]\npub struct RateLimit {\n  backend: LemmyBackend,\n}\n\nimpl RateLimit {\n  pub fn new(configs: EnumMap<ActionType, BucketConfig>) -> Self {\n    Self {\n      backend: LemmyBackend::new(configs, true),\n    }\n  }\n\n  pub fn with_debug_config() -> Self {\n    Self::new(enum_map! {\n      ActionType::Message => BucketConfig {\n        max_requests: 180,\n        interval: 60,\n      },\n      ActionType::Post => BucketConfig {\n        max_requests: 6,\n        interval: 300,\n      },\n      ActionType::Register => BucketConfig {\n        max_requests: 3,\n        interval: 3600,\n      },\n      ActionType::Image => BucketConfig {\n        max_requests: 6,\n        interval: 3600,\n      },\n      ActionType::Comment => BucketConfig {\n        max_requests: 6,\n        interval: 600,\n      },\n      ActionType::Search => BucketConfig {\n        max_requests: 60,\n        interval: 600,\n      },\n      ActionType::ImportUserSettings => BucketConfig {\n        max_requests: 1,\n        interval: 24 * 60 * 60,\n      },\n    })\n  }\n\n  #[expect(clippy::expect_used)]\n  pub fn set_config(&self, configs: EnumMap<ActionType, BucketConfig>) {\n    *self.backend.configs.write().expect(\"write rwlock\") = configs;\n  }\n\n  fn build_rate_limiter(\n    &self,\n    action_type: ActionType,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    let input = new_input(action_type);\n\n    RateLimiter::builder(self.backend.clone(), input)\n      .add_headers()\n      // rollback rate limit on any error 500\n      .rollback_server_errors()\n      .build()\n  }\n\n  pub fn message(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Message)\n  }\n\n  pub fn search(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Search)\n  }\n  pub fn register(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Register)\n  }\n  pub fn post(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Post)\n  }\n  pub fn image(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Image)\n  }\n  pub fn comment(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::Comment)\n  }\n  pub fn import_user_settings(\n    &self,\n  ) -> RateLimiter<LemmyBackend, SimpleOutput, impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static>\n  {\n    self.build_rate_limiter(ActionType::ImportUserSettings)\n  }\n}\n\nfn new_input(action_type: ActionType) -> impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static {\n  move |req| {\n    ready({\n      let info = req.connection_info();\n      let key = raw_ip_key(info.realip_remote_addr());\n\n      Ok(LemmyInput(key, action_type))\n    })\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/response.rs",
    "content": "use crate::error::{LemmyError, LemmyErrorType};\nuse actix_web::{\n  HttpRequest,\n  HttpResponse,\n  dev::ServiceResponse,\n  middleware::ErrorHandlerResponse,\n};\n\npub fn jsonify_plain_text_errors<BODY>(\n  res: ServiceResponse<BODY>,\n) -> actix_web::Result<ErrorHandlerResponse<BODY>> {\n  let maybe_error = res.response().error();\n  let is_rate_limit_error = res.status() == 429;\n\n  // This function is only expected to be called for errors, so if there is no error, return\n  if maybe_error.is_none() && !is_rate_limit_error {\n    return Ok(ErrorHandlerResponse::Response(res.map_into_left_body()));\n  }\n  // We're assuming that any LemmyError is already in JSON format, so we don't need to do anything\n  if let Some(maybe_error) = maybe_error\n    && maybe_error.as_error::<LemmyError>().is_some()\n  {\n    return Ok(ErrorHandlerResponse::Response(res.map_into_left_body()));\n  }\n\n  // convert other errors to json format\n  let (req, res_parts) = res.into_parts();\n  let lemmy_err_type = if let Some(error) = res_parts.error() {\n    LemmyErrorType::Unknown(error.to_string())\n  } else if is_rate_limit_error {\n    LemmyErrorType::TooManyRequests\n  } else {\n    LemmyErrorType::Unknown(\"couldnt build json\".into())\n  };\n  build_error_response(req, res_parts, lemmy_err_type)\n}\n\nfn build_error_response<BODY>(\n  req: HttpRequest,\n  res_parts: HttpResponse<BODY>,\n  err: LemmyErrorType,\n) -> actix_web::Result<ErrorHandlerResponse<BODY>> {\n  let response = HttpResponse::build(res_parts.status()).json(err);\n\n  let service_response = ServiceResponse::new(req, response);\n  Ok(ErrorHandlerResponse::Response(\n    service_response.map_into_right_body(),\n  ))\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::error::{LemmyError, LemmyErrorType};\n  use actix_web::{\n    App,\n    Error,\n    Handler,\n    Responder,\n    error::ErrorInternalServerError,\n    http::StatusCode,\n    middleware::ErrorHandlers,\n    test,\n    web,\n  };\n  use pretty_assertions::assert_eq;\n\n  #[actix_web::test]\n  async fn test_non_error_responses_are_not_modified() {\n    async fn ok_service() -> actix_web::Result<String, Error> {\n      Ok(\"Oll Korrect\".to_string())\n    }\n\n    check_for_jsonification(ok_service, StatusCode::OK, \"Oll Korrect\").await;\n  }\n\n  #[actix_web::test]\n  async fn test_lemmy_errors_are_not_modified() {\n    async fn lemmy_error_service() -> actix_web::Result<String, LemmyError> {\n      Err(LemmyError::from(LemmyErrorType::AlreadyExists))\n    }\n\n    check_for_jsonification(\n      lemmy_error_service,\n      StatusCode::BAD_REQUEST,\n      \"{\\\"error\\\":\\\"already_exists\\\",\\\"cause\\\":\\\"AlreadyExists\\\"}\",\n    )\n    .await;\n  }\n\n  #[actix_web::test]\n  async fn test_generic_errors_are_jsonified_as_unknown_errors() {\n    async fn generic_error_service() -> actix_web::Result<String, Error> {\n      Err(ErrorInternalServerError(\"This is not a LemmyError\"))\n    }\n\n    check_for_jsonification(\n      generic_error_service,\n      StatusCode::INTERNAL_SERVER_ERROR,\n      \"{\\\"error\\\":\\\"unknown\\\",\\\"message\\\":\\\"This is not a LemmyError\\\"}\",\n    )\n    .await;\n  }\n\n  #[actix_web::test]\n  async fn test_anyhow_errors_wrapped_in_lemmy_errors_are_jsonified_correctly() {\n    async fn anyhow_error_service() -> actix_web::Result<String, LemmyError> {\n      Err(LemmyError::from(anyhow::anyhow!(\"This is the inner error\")))\n    }\n\n    check_for_jsonification(\n      anyhow_error_service,\n      StatusCode::BAD_REQUEST,\n      \"{\\\"error\\\":\\\"unknown\\\",\\\"message\\\":\\\"This is the inner error\\\",\\\"cause\\\":\\\"This is the inner error\\\"}\",\n    )\n    .await;\n  }\n\n  #[actix_web::test]\n  async fn test_rate_limit_error() {\n    async fn lemmy_error_service() -> actix_web::Result<HttpResponse> {\n      Ok(HttpResponse::TooManyRequests().finish())\n    }\n\n    check_for_jsonification(\n      lemmy_error_service,\n      StatusCode::TOO_MANY_REQUESTS,\n      \"{\\\"error\\\":\\\"too_many_requests\\\"}\",\n    )\n    .await;\n  }\n\n  async fn check_for_jsonification(\n    service: impl Handler<(), Output = impl Responder + 'static>,\n    expected_status_code: StatusCode,\n    expected_body: &str,\n  ) {\n    let app = test::init_service(\n      App::new()\n        .wrap(ErrorHandlers::new().default_handler(jsonify_plain_text_errors))\n        .route(\"/\", web::get().to(service)),\n    )\n    .await;\n    let req = test::TestRequest::default().to_request();\n    let res = test::call_service(&app, req).await;\n\n    assert_eq!(res.status(), expected_status_code);\n\n    let body = test::read_body(res).await;\n    assert_eq!(body, expected_body);\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/settings/mod.rs",
    "content": "use crate::{error::LemmyResult, location_info};\nuse anyhow::{Context, anyhow};\nuse deser_hjson::from_str;\nuse std::{env, fs, sync::LazyLock};\nuse structs::{PictrsConfig, Settings};\nuse url::Url;\nuse urlencoding::encode;\n\npub mod structs;\n\nstatic DEFAULT_CONFIG_FILE: &str = \"config/config.hjson\";\n\n/// Some connection options to speed up queries\nconst CONNECTION_OPTIONS: [&str; 1] = [\"geqo_threshold=12\"];\n\n#[expect(clippy::expect_used)]\npub static SETTINGS: LazyLock<Settings> = LazyLock::new(|| {\n  if env::var(\"LEMMY_INITIALIZE_WITH_DEFAULT_SETTINGS\").is_ok() {\n    println!(\n      \"LEMMY_INITIALIZE_WITH_DEFAULT_SETTINGS was set, any configuration file has been ignored.\"\n    );\n    println!(\n      \"Use with other environment variables to configure this instance further; e.g. LEMMY_DATABASE_URL.\"\n    );\n    Settings::default()\n  } else {\n    Settings::init().expect(\"Failed to load settings file, see documentation (https://join-lemmy.org/docs/en/administration/configuration.html).\")\n  }\n});\n\nimpl Settings {\n  /// Reads config from configuration file.\n  ///\n  /// Note: The env var `LEMMY_DATABASE_URL` is parsed in\n  /// `lemmy_db_schema/src/lib.rs::get_database_url_from_env()`\n  /// Warning: Only call this once.\n  pub(crate) fn init() -> LemmyResult<Self> {\n    let path =\n      env::var(\"LEMMY_CONFIG_LOCATION\").unwrap_or_else(|_| DEFAULT_CONFIG_FILE.to_string());\n    let plain = fs::read_to_string(path)?;\n    let config = from_str::<Settings>(&plain)?;\n    if config.hostname == \"unset\" {\n      Err(anyhow!(\"Hostname variable is not set!\").into())\n    } else {\n      Ok(config)\n    }\n  }\n\n  pub fn get_database_url(&self) -> String {\n    if let Ok(url) = env::var(\"LEMMY_DATABASE_URL\") {\n      url\n    } else {\n      self.database.connection.clone()\n    }\n  }\n\n  /// Returns either \"http\" or \"https\", depending on tls_enabled setting\n  fn get_protocol_string(&self) -> &'static str {\n    if self.tls_enabled { \"https\" } else { \"http\" }\n  }\n\n  /// Returns something like `http://localhost` or `https://lemmy.ml`,\n  /// with the correct protocol and hostname.\n  pub fn get_protocol_and_hostname(&self) -> String {\n    format!(\"{}://{}\", self.get_protocol_string(), self.hostname)\n  }\n\n  /// When running the federation test setup in `api_tests/` or `docker/federation`, the `hostname`\n  /// variable will be like `lemmy-alpha:8541`. This method removes the port and returns\n  /// `lemmy-alpha` instead. It has no effect in production.\n  pub fn get_hostname_without_port(&self) -> Result<String, anyhow::Error> {\n    Ok(\n      (*self\n        .hostname\n        .split(':')\n        .collect::<Vec<&str>>()\n        .first()\n        .context(location_info!())?)\n      .to_string(),\n    )\n  }\n\n  pub fn pictrs(&self) -> LemmyResult<PictrsConfig> {\n    self\n      .pictrs\n      .clone()\n      .ok_or_else(|| anyhow!(\"images_disabled\").into())\n  }\n\n  /// Sets a few additional config options necessary for starting lemmy\n  pub fn get_database_url_with_options(&self) -> LemmyResult<String> {\n    let mut url = Url::parse(&self.get_database_url())?;\n\n    // Set `lemmy.protocol_and_hostname` so triggers can use it\n    let lemmy_protocol_and_hostname_option =\n      \"lemmy.protocol_and_hostname=\".to_owned() + &self.get_protocol_and_hostname();\n    let mut options = CONNECTION_OPTIONS.to_vec();\n    options.push(&lemmy_protocol_and_hostname_option);\n\n    // Create the connection uri portion\n    let options_segments = options\n      .iter()\n      // The equal signs need to be encoded, since the url set_query doesn't do them,\n      // and postgres requires them to be %3D\n      // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING\n      .map(|o| format!(\"-c {}\", encode(o)))\n      .collect::<Vec<String>>()\n      .join(\" \");\n\n    url.set_query(Some(&format!(\"options={options_segments}\")));\n    Ok(url.into())\n  }\n}\n#[expect(clippy::expect_used)]\n/// Necessary to avoid URL expect failures\nfn pictrs_placeholder_url() -> Url {\n  Url::parse(\"http://localhost:8080\").expect(\"parse pictrs url\")\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n\n  #[test]\n  fn test_load_config() -> LemmyResult<()> {\n    Settings::init()?;\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/settings/structs.rs",
    "content": "use super::pictrs_placeholder_url;\nuse doku::Document;\nuse serde::{Deserialize, Serialize};\nuse smart_default::SmartDefault;\nuse std::{\n  collections::BTreeMap,\n  env,\n  net::{IpAddr, Ipv4Addr},\n};\nuse url::Url;\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct Settings {\n  /// settings related to the postgresql database\n  pub database: DatabaseConfig,\n  /// Pictrs image server configuration.\n  #[default(Some(Default::default()))]\n  pub(crate) pictrs: Option<PictrsConfig>,\n  /// Email sending configuration. All options except login/password are mandatory\n  #[doku(example = \"Some(Default::default())\")]\n  pub email: Option<EmailConfig>,\n  /// Parameters for automatic configuration of new instance (only used at first start)\n  #[doku(example = \"Some(Default::default())\")]\n  pub setup: Option<SetupConfig>,\n  /// the domain name of your instance (mandatory)\n  #[default(\"unset\")]\n  #[doku(example = \"example.com\")]\n  pub hostname: String,\n  /// Address where lemmy should listen for incoming requests\n  #[default(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)))]\n  #[doku(as = \"String\")]\n  pub bind: IpAddr,\n  /// Port where lemmy should listen for incoming requests\n  #[default(8536)]\n  pub port: u16,\n  /// Whether the site is available over TLS. Needs to be true for federation to work.\n  #[default(true)]\n  pub tls_enabled: bool,\n  /// Set the URL for opentelemetry exports. If you do not have an opentelemetry collector, do not\n  /// set this option\n  #[doku(skip)]\n  pub opentelemetry_url: Option<Url>,\n  pub federation: FederationWorkerConfig,\n  // Prometheus configuration.\n  #[doku(example = \"Some(Default::default())\")]\n  pub prometheus: Option<PrometheusConfig>,\n  /// Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment:\n  /// `LEMMY_CORS_ORIGIN=example.org,site.com`\n  /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin\n  #[doku(example = \"lemmy.tld\")]\n  cors_origin: Vec<String>,\n  /// Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`.\n  pub json_logging: bool,\n  /// Data for loading Lemmy plugins\n  pub plugins: Vec<PluginSettings>,\n}\n\nimpl Settings {\n  pub fn cors_origin(&self) -> Vec<String> {\n    env::var(\"LEMMY_CORS_ORIGIN\")\n      .ok()\n      .map(|e| e.split(',').map(ToString::to_string).collect())\n      .unwrap_or(self.cors_origin.clone())\n  }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct PictrsConfig {\n  /// Address where pictrs is available (for image hosting)\n  #[default(pictrs_placeholder_url())]\n  #[doku(example = \"http://localhost:8080\")]\n  pub url: Url,\n\n  /// Set a custom pictrs API key. ( Required for deleting images )\n  pub api_key: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct DatabaseConfig {\n  /// Configure the database by specifying URI pointing to a postgres instance. This parameter can\n  /// also be set by environment variable `LEMMY_DATABASE_URL`.\n  ///\n  /// For an explanation of how to use connection URIs, see PostgreSQL's documentation:\n  /// https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6\n  #[default(\"postgres://lemmy:password@localhost:5432/lemmy\")]\n  #[doku(example = \"postgresql:///lemmy?user=lemmy&host=/var/run/postgresql\")]\n  pub(crate) connection: String,\n\n  /// Maximum number of active sql connections\n  ///\n  /// A high value here can result in errors \"could not resize shared memory segment\". In this case\n  /// it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077\n  #[default(30)]\n  pub pool_size: usize,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Document, SmartDefault)]\n#[serde(default, deny_unknown_fields)]\npub struct EmailConfig {\n  /// https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url\n  #[default(\"smtp://localhost:25\")]\n  #[doku(example = \"smtps://user:pass@hostname:port\")]\n  pub connection: String,\n  /// Address to send emails from, eg \"noreply@your-instance.com\"\n  #[doku(example = \"noreply@example.com\")]\n  pub smtp_from_address: String,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct SetupConfig {\n  /// Username for the admin user\n  #[doku(example = \"admin\")]\n  pub admin_username: String,\n  /// Password for the admin user. It must be between 10 and 60 characters.\n  #[doku(example = \"tf6HHDS4RolWfFhk4Rq9\")]\n  pub admin_password: String,\n  /// Name of the site, can be changed later. Maximum 20 characters.\n  #[doku(example = \"My Lemmy Instance\")]\n  pub site_name: String,\n  /// Email for the admin user (optional, can be omitted and set later through the website)\n  #[doku(example = \"user@example.com\")]\n  pub admin_email: Option<String>,\n  /// On first start Lemmy fetches the 50 most active communities from one of these instances,\n  /// to provide some initial data. It tries the first list entry, and if it fails uses subsequent\n  /// instances as fallback.\n  /// Leave this empty to disable community bootstrap.\n  /// TODO: remove voyager.lemmy.ml from defaults once Lemmy 1.0 is deployed to production\n  /// instances.\n  #[default(vec![\"lemmy.ml\".to_string(),\"lemmy.world\".to_string(),\"lemmy.zip\".to_string(),\"voyager.lemmy.ml\".to_string()])]\n  pub bootstrap_instances: Vec<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct PrometheusConfig {\n  // Address that the Prometheus metrics will be served on.\n  #[default(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))]\n  #[doku(example = \"127.0.0.1\")]\n  pub bind: IpAddr,\n  // Port that the Prometheus metrics will be served on.\n  #[default(10002)]\n  #[doku(example = \"10002\")]\n  pub port: u16,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\n// named federation\"worker\"config to disambiguate from the activitypub library configuration\npub struct FederationWorkerConfig {\n  /// Limit to the number of concurrent outgoing federation requests per target instance.\n  /// Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities\n  /// per second) and if a receiving instance is not keeping up.\n  #[default(1)]\n  pub concurrent_sends_per_instance: i8,\n}\n\n/// See the extism docs for more details: https://extism.org/docs/concepts/manifest\n#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]\n#[serde(default, deny_unknown_fields)]\npub struct PluginSettings {\n  /// Where to load the .wasm file from, can be a local file path or URL\n  #[doku(\n    example = \"https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm\"\n  )]\n  pub file: String,\n  /// SHA256 hash of the .wasm file\n  #[doku(example = \"37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12\")]\n  pub hash: Option<String>,\n  /// Which websites the plugin may connect to\n  #[serde(default)]\n  #[doku(example = \"lemmy.ml\")]\n  pub allowed_hosts: Option<Vec<String>>,\n  /// Configuration options for the plugin\n  #[serde(default)]\n  pub config: BTreeMap<String, String>,\n}\n"
  },
  {
    "path": "crates/utils/src/utils/markdown/identifier_rule.rs",
    "content": "use crate::utils::markdown::link_rule::Link;\nuse markdown_it::{\n  MarkdownIt,\n  Node,\n  NodeValue,\n  Renderer,\n  parser::inline::{InlineRule, InlineState},\n};\n\n#[derive(Debug)]\npub struct Identifier {\n  pub is_community: bool,\n  pub name: String,\n  pub domain: String,\n}\n\nimpl NodeValue for Identifier {\n  fn render(&self, node: &Node, fmt: &mut dyn Renderer) {\n    let mut attrs = node.attrs.clone();\n    let path = if self.is_community { 'c' } else { 'u' };\n    attrs.push((\"href\", format!(\"/{path}/{}@{}\", &self.name, &self.domain)));\n    attrs.push((\"rel\", \"nofollow\".to_string()));\n    attrs.push((\"class\", \"u-url\".to_string()));\n    attrs.push((\"class\", \"mention\".to_string()));\n\n    fmt.open(\"a\", &attrs);\n    let marker = if self.is_community { '!' } else { '@' };\n    fmt.text(&format!(\"{marker}{}@{}\", self.name, self.domain));\n    fmt.close(\"a\");\n  }\n}\n\nstruct CommunityIdentifierScanner;\nstruct PersonIdentifierScanner;\n\nimpl InlineRule for CommunityIdentifierScanner {\n  const MARKER: char = '!';\n\n  fn run(state: &mut InlineState) -> Option<(Node, usize)> {\n    scan_for_identifier(true, Self::MARKER, state)\n  }\n}\n\nimpl InlineRule for PersonIdentifierScanner {\n  const MARKER: char = '@';\n\n  fn run(state: &mut InlineState) -> Option<(Node, usize)> {\n    scan_for_identifier(false, Self::MARKER, state)\n  }\n}\n\nfn scan_for_identifier(\n  is_community: bool,\n  marker: char,\n  state: &mut InlineState,\n) -> Option<(Node, usize)> {\n  // Dont allow identifier inside link, otherwise it outputs nested `<a>` tags.\n  if state.node.is::<Link>() {\n    return None;\n  }\n\n  let Some(input) = &state.src.get(state.pos..state.pos_max) else {\n    return None;\n  };\n  // wrong start character\n  if !input.starts_with(marker) {\n    return None;\n  }\n\n  let mut found_at = false;\n  let mut name = String::new();\n  let mut domain = String::new();\n  for c in input.chars().skip(1) {\n    // whitespace means we reached the end\n    if c.is_whitespace() {\n      break;\n    }\n\n    // we are inside a markdown link, ignore\n    if c == ']' {\n      return None;\n    }\n\n    // found the @ character between name and domain\n    if c == '@' {\n      found_at = true;\n      continue;\n    }\n    if !found_at {\n      name.push(c);\n    } else {\n      domain.push(c);\n    }\n  }\n\n  // check if we found a valid, nonempty identifier\n  (!name.is_empty() && !domain.is_empty()).then(|| {\n    let len = name.len() + domain.len() + 2;\n    let identifier = Identifier {\n      is_community,\n      name,\n      domain,\n    };\n    (Node::new(identifier), len)\n  })\n}\npub fn add(md: &mut MarkdownIt) {\n  md.inline.add_rule::<CommunityIdentifierScanner>();\n  md.inline.add_rule::<PersonIdentifierScanner>();\n}\n"
  },
  {
    "path": "crates/utils/src/utils/markdown/image_links.rs",
    "content": "use super::link_rule::Link;\nuse crate::{settings::SETTINGS, utils::markdown::link_rule};\nuse markdown_it::{\n  MarkdownIt,\n  NodeValue,\n  parser::linkfmt::LinkFormatter,\n  plugins::cmark::{\n    block::fence,\n    inline::{image, image::Image},\n  },\n};\nuse std::sync::LazyLock;\nuse url::Url;\nuse urlencoding::encode;\n\n/// Rewrites all links to remote domains in markdown, so they go through `/api/v4/image_proxy`.\npub fn markdown_rewrite_image_links(mut src: String) -> (String, Vec<Url>) {\n  let links_offsets = find_urls::<Image>(&src);\n\n  let mut links = vec![];\n  // Go through the collected links in reverse order\n  for (start, end) in links_offsets.into_iter().rev() {\n    let (url, extra) = markdown_handle_title(&src, start, end);\n    match Url::parse(url) {\n      Ok(parsed) => {\n        links.push(parsed.clone());\n        // If link points to remote domain, replace with proxied link\n        if parsed.domain() != Some(&SETTINGS.hostname) {\n          let mut proxied = format!(\n            \"{}/api/v4/image/proxy?url={}\",\n            SETTINGS.get_protocol_and_hostname(),\n            encode(url),\n          );\n          // restore custom emoji format\n          if let Some(extra) = extra {\n            proxied.push(' ');\n            proxied.push_str(extra);\n          }\n          src.replace_range(start..end, &proxied);\n        }\n      }\n      Err(_) => {\n        // If its not a valid url, replace with empty text\n        src.replace_range(start..end, \"\");\n      }\n    }\n  }\n\n  (src, links)\n}\n\npub fn markdown_handle_title(src: &str, start: usize, end: usize) -> (&str, Option<&str>) {\n  let content = src.get(start..end).unwrap_or_default();\n  // necessary for custom emojis which look like `![name](url \"title\")`\n  match content.split_once(' ') {\n    Some((a, b)) => (a, Some(b)),\n    _ => (content, None),\n  }\n}\n\npub fn markdown_find_links(src: &str) -> Vec<(usize, usize)> {\n  find_urls::<Link>(src)\n}\n\n// Walk the syntax tree to find positions of image or link urls\nfn find_urls<T: NodeValue + UrlAndTitle>(src: &str) -> Vec<(usize, usize)> {\n  // Use separate markdown parser here, with most features disabled for faster parsing,\n  // and a dummy link formatter which doesnt normalize links.\n  static PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {\n    let mut p = MarkdownIt::new();\n    p.link_formatter = Box::new(NoopLinkFormatter {});\n    image::add(&mut p);\n    fence::add(&mut p);\n    link_rule::add(&mut p);\n    p\n  });\n\n  let ast = PARSER.parse(src);\n  let mut links_offsets = vec![];\n  ast.walk(|node, _depth| {\n    if let Some(image) = node.cast::<T>()\n      && let Some(srcmap) = node.srcmap\n    {\n      let (_, node_offset) = srcmap.get_byte_offsets();\n      let start_offset = node_offset - image.url_len() - 1 - image.title_len();\n      let end_offset = node_offset - 1;\n\n      links_offsets.push((start_offset, end_offset));\n    }\n  });\n  links_offsets\n}\n\ntrait UrlAndTitle {\n  fn url_len(&self) -> usize;\n  fn title_len(&self) -> usize;\n}\n\nimpl UrlAndTitle for Image {\n  fn url_len(&self) -> usize {\n    self.url.len()\n  }\n\n  fn title_len(&self) -> usize {\n    self.title.as_ref().map(|t| t.len() + 3).unwrap_or_default()\n  }\n}\nimpl UrlAndTitle for Link {\n  fn url_len(&self) -> usize {\n    self.url.len()\n  }\n  fn title_len(&self) -> usize {\n    self.title.as_ref().map(|t| t.len() + 3).unwrap_or_default()\n  }\n}\n\n/// markdown-it normalizes links by default, which breaks the link rewriting. So we use a dummy\n/// formatter here which does nothing. Note this isnt actually used to render the markdown.\n#[derive(Debug)]\nstruct NoopLinkFormatter;\n\nimpl LinkFormatter for NoopLinkFormatter {\n  fn validate_link(&self, _url: &str) -> Option<()> {\n    Some(())\n  }\n\n  fn normalize_link(&self, url: &str) -> String {\n    url.to_owned()\n  }\n\n  fn normalize_link_text(&self, url: &str) -> String {\n    url.to_owned()\n  }\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use pretty_assertions::assert_eq;\n\n  #[test]\n  fn test_find_links() {\n    let links = markdown_find_links(\"[test](https://example.com)\");\n    assert_eq!(vec![(7, 26)], links);\n\n    let links = find_urls::<Image>(\"![test](https://example.com)\");\n    assert_eq!(vec![(8, 27)], links);\n\n    let links = find_urls::<Image>(\"![ითხოვს](https://example.com/ითხოვს)\");\n    assert_eq!(vec![(22, 60)], links);\n\n    let links = find_urls::<Image>(\"![test](https://example.com/%C3%A4%C3%B6%C3%BC.jpg)\");\n    assert_eq!(vec![(8, 50)], links);\n  }\n\n  #[test]\n  fn test_markdown_proxy_images() {\n    let tests: Vec<_> = vec![\n      (\n        \"remote image proxied\",\n        \"![link](http://example.com/image.jpg)\",\n        \"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)\",\n      ),\n      (\n        \"local image unproxied\",\n        \"![link](http://lemmy-alpha/image.jpg)\",\n        \"![link](http://lemmy-alpha/image.jpg)\",\n      ),\n      (\n        \"multiple image links\",\n        \"![link](http://example.com/image1.jpg) ![link](http://example.com/image2.jpg)\",\n        \"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)\",\n      ),\n      (\"empty link handled\", \"![image]()\", \"![image]()\"),\n      (\n        \"empty label handled\",\n        \"![](http://example.com/image.jpg)\",\n        \"![](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)\",\n      ),\n      (\n        \"invalid image link removed\",\n        \"![image](http-not-a-link)\",\n        \"![image]()\",\n      ),\n      (\n        \"label with nested markdown handled\",\n        \"![a *b* c](http://example.com/image.jpg)\",\n        \"![a *b* c](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)\",\n      ),\n      (\n        \"custom emoji support\",\n        r#\"![party-blob](https://www.hexbear.net/pictrs/image/83405746-0620-4728-9358-5f51b040ffee.gif \"emoji party-blob\")\"#,\n        r#\"![party-blob](https://lemmy-alpha/api/v4/image/proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif \"emoji party-blob\")\"#,\n      ),\n      (\n        \"image with special chars\",\n        \"ითხოვს ![ითხოვს](http://example.com/ითხოვს%C3%A4.jpg)\",\n        \"ითხოვს ![ითხოვს](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2F%E1%83%98%E1%83%97%E1%83%AE%E1%83%9D%E1%83%95%E1%83%A1%25C3%25A4.jpg)\",\n      ),\n    ];\n\n    tests.iter().for_each(|&(msg, input, expected)| {\n      let result = markdown_rewrite_image_links(input.to_string());\n\n      assert_eq!(\n        result.0, expected,\n        \"Testing {}, with original input '{}'\",\n        msg, input\n      );\n    });\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/utils/markdown/link_rule.rs",
    "content": "use crate::utils::mention::MENTIONS_REGEX;\nuse markdown_it::{\n  MarkdownIt,\n  Node,\n  NodeValue,\n  Renderer,\n  generics::inline::full_link,\n  parser::inline::Text,\n};\n\n/// Renders markdown links. Copied directly from markdown-it source, unlike original code it also\n/// sets `rel=nofollow` attribute.\n///\n/// TODO: We can set nofollow only if post was not made by mod/admin, but then we have to construct\n///       new parser for every invocation which might have performance implications.\n/// https://github.com/markdown-it-rust/markdown-it/blob/master/src/plugins/cmark/inline/link.rs\n#[derive(Debug)]\npub struct Link {\n  pub url: String,\n  pub title: Option<String>,\n}\n\nimpl NodeValue for Link {\n  fn render(&self, node: &Node, fmt: &mut dyn Renderer) {\n    let mut attrs = node.attrs.clone();\n    attrs.push((\"href\", self.url.clone()));\n    attrs.push((\"rel\", \"nofollow\".to_string()));\n\n    if let Some(title) = &self.title {\n      attrs.push((\"title\", title.clone()));\n    }\n\n    let text = node.children.first().and_then(|n| n.cast::<Text>());\n    if let Some(text) = text\n      && MENTIONS_REGEX.is_match(&text.content)\n    {\n      attrs.push((\"class\", \"u-url\".to_string()));\n      attrs.push((\"class\", \"mention\".to_string()));\n    }\n\n    fmt.open(\"a\", &attrs);\n    fmt.contents(&node.children);\n    fmt.close(\"a\");\n  }\n}\n\npub fn add(md: &mut MarkdownIt) {\n  full_link::add::<false>(md, |href, title| {\n    Node::new(Link {\n      url: href.unwrap_or_default(),\n      title,\n    })\n  });\n}\n"
  },
  {
    "path": "crates/utils/src/utils/markdown/mod.rs",
    "content": "use crate::error::{LemmyErrorType, LemmyResult};\nuse markdown_it::MarkdownIt;\nuse regex::RegexSet;\nuse std::sync::LazyLock;\n\nmod identifier_rule;\npub mod image_links;\nmod link_rule;\n\nstatic MARKDOWN_PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {\n  let mut parser = MarkdownIt::new();\n  markdown_it::plugins::cmark::add(&mut parser);\n  markdown_it::plugins::extra::add(&mut parser);\n  markdown_it_block_spoiler::add(&mut parser);\n  markdown_it_sub::add(&mut parser);\n  markdown_it_sup::add(&mut parser);\n  markdown_it_ruby::add(&mut parser);\n  markdown_it_footnote::add(&mut parser);\n  link_rule::add(&mut parser);\n  identifier_rule::add(&mut parser);\n\n  parser\n});\n\npub fn markdown_to_html(text: &str) -> String {\n  MARKDOWN_PARSER.parse(text).xrender()\n}\n\npub fn markdown_check_for_blocked_urls(text: &str, blocklist: &RegexSet) -> LemmyResult<()> {\n  if blocklist.is_match(text) {\n    return Err(LemmyErrorType::BlockedUrl.into());\n  }\n  Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n\n  use super::*;\n  use crate::utils::validation::check_urls_are_valid;\n  use pretty_assertions::assert_eq;\n  use regex::escape;\n\n  #[test]\n  fn test_basic_markdown() {\n    let tests: Vec<_> = vec![\n      (\n        \"rewrite community identifier\",\n        \"!test@lemmy-alpha\",\n        \"<p><a href=\\\"/c/test@lemmy-alpha\\\" rel=\\\"nofollow\\\" class=\\\"u-url mention\\\">!test@lemmy-alpha</a></p>\\n\",\n      ),\n      (\n        \"rewrite user identifier\",\n        \"@garda@lemmy-alpha\",\n        \"<p><a href=\\\"/u/garda@lemmy-alpha\\\" rel=\\\"nofollow\\\" class=\\\"u-url mention\\\">@garda@lemmy-alpha</a></p>\\n\",\n      ),\n      (\n        \"headings\",\n        \"# h1\\n## h2\\n### h3\\n#### h4\\n##### h5\\n###### h6\",\n        \"<h1>h1</h1>\\n<h2>h2</h2>\\n<h3>h3</h3>\\n<h4>h4</h4>\\n<h5>h5</h5>\\n<h6>h6</h6>\\n\",\n      ),\n      (\"line breaks\", \"First\\rSecond\", \"<p>First\\nSecond</p>\\n\"),\n      (\n        \"emphasis\",\n        \"__bold__ **bold** *italic* ***bold+italic***\",\n        \"<p><strong>bold</strong> <strong>bold</strong> <em>italic</em> <em><strong>bold+italic</strong></em></p>\\n\",\n      ),\n      (\n        \"blockquotes\",\n        \"> #### Hello\\n > \\n > - Hola\\n > - 안영 \\n>> Goodbye\\n\",\n        \"<blockquote>\\n<h4>Hello</h4>\\n<ul>\\n<li>Hola</li>\\n<li>안영</li>\\n</ul>\\n<blockquote>\\n<p>Goodbye</p>\\n</blockquote>\\n</blockquote>\\n\",\n      ),\n      (\n        \"lists (ordered, unordered)\",\n        \"1. pen\\n2. apple\\n3. apple pen\\n- pen\\n- pineapple\\n- pineapple pen\",\n        \"<ol>\\n<li>pen</li>\\n<li>apple</li>\\n<li>apple pen</li>\\n</ol>\\n<ul>\\n<li>pen</li>\\n<li>pineapple</li>\\n<li>pineapple pen</li>\\n</ul>\\n\",\n      ),\n      (\n        \"code and code blocks\",\n        \"this is my amazing `code snippet` and my amazing ```code block```\",\n        \"<p>this is my amazing <code>code snippet</code> and my amazing <code>code block</code></p>\\n\",\n      ),\n      // Links with added nofollow attribute\n      (\n        \"links\",\n        \"[Lemmy](https://join-lemmy.org/ \\\"Join Lemmy!\\\")\",\n        \"<p><a href=\\\"https://join-lemmy.org/\\\" rel=\\\"nofollow\\\" title=\\\"Join Lemmy!\\\">Lemmy</a></p>\\n\",\n      ),\n      // Remote images with proxy\n      (\n        \"images\",\n        \"![My linked image](https://example.com/image.png \\\"image alt text\\\")\",\n        \"<p><img src=\\\"https://example.com/image.png\\\" alt=\\\"My linked image\\\" title=\\\"image alt text\\\" /></p>\\n\",\n      ),\n      // Local images without proxy\n      (\n        \"images\",\n        \"![My linked image](https://lemmy-alpha/image.png \\\"image alt text\\\")\",\n        \"<p><img src=\\\"https://lemmy-alpha/image.png\\\" alt=\\\"My linked image\\\" title=\\\"image alt text\\\" /></p>\\n\",\n      ),\n      // Ensure spoiler plugin is added\n      (\n        \"basic spoiler\",\n        \"::: spoiler click to see more\\nhow spicy!\\n:::\\n\",\n        \"<details>\\n<summary>\\nclick to see more\\n</summary>\\n<p>how spicy!</p>\\n</details>\\n\",\n      ),\n      (\n        \"escape html special chars\",\n        \"<script>alert('xss');</script> hello &\\\"\",\n        \"<p>&lt;script&gt;alert(‘xss’);&lt;/script&gt; hello &amp;&quot;</p>\\n\",\n      ),\n      (\"subscript\", \"log~2~(a)\", \"<p>log<sub>2</sub>(a)</p>\\n\"),\n      (\n        \"superscript\",\n        \"Markdown^TM^\",\n        \"<p>Markdown<sup>TM</sup></p>\\n\",\n      ),\n      (\n        \"ruby text\",\n        \"{漢|Kan}{字|ji}\",\n        \"<p><ruby>漢<rp>(</rp><rt>Kan</rt><rp>)</rp></ruby><ruby>字<rp>(</rp><rt>ji</rt><rp>)</rp></ruby></p>\\n\",\n      ),\n      (\n        \"footnotes\",\n        \"Bold claim.[^1]\\n\\n[^1]: example.com\",\n        \"<p>Bold claim.<sup class=\\\"footnote-ref\\\"><a href=\\\"#fn1\\\" id=\\\"fnref1\\\">[1]</a></sup></p>\\n\\\n\t <hr class=\\\"footnotes-sep\\\" />\\n\\\n\t <section class=\\\"footnotes\\\">\\n\\\n\t <ol class=\\\"footnotes-list\\\">\\n\\\n\t <li id=\\\"fn1\\\" class=\\\"footnote-item\\\">\\n\\\n\t <p>example.com <a href=\\\"#fnref1\\\" class=\\\"footnote-backref\\\">↩︎</a></p>\\n\\\n\t </li>\\n</ol>\\n</section>\\n\",\n      ),\n      (\n        \"mention links\",\n        \"[@example@example.com](https://example.com/u/example)\",\n        \"<p><a href=\\\"https://example.com/u/example\\\" rel=\\\"nofollow\\\" class=\\\"u-url mention\\\">@example@example.com</a></p>\\n\",\n      ),\n      (\n        \"dont add backslash escapes in urls\",\n        \"[markdown link](https://en.wikipedia.org/wiki/Dragnet_(franchise))\",\n        \"<p><a href=\\\"https://en.wikipedia.org/wiki/Dragnet_(franchise)\\\" rel=\\\"nofollow\\\">markdown link</a></p>\\n\",\n      ),\n    ];\n\n    tests.iter().for_each(|&(msg, input, expected)| {\n      let result = markdown_to_html(input);\n\n      assert_eq!(\n        result, expected,\n        \"Testing {}, with original input '{}'\",\n        msg, input\n      );\n    });\n  }\n\n  // This replicates the logic when saving url blocklist patterns and querying them.\n  // Refer to lemmy_api_crud::site::update::update_site and\n  // lemmy_api_common::utils::get_url_blocklist().\n  fn create_url_blocklist_test_regex_set(patterns: Vec<&str>) -> LemmyResult<RegexSet> {\n    let url_blocklist = patterns.iter().map(|&s| s.to_string()).collect();\n    let valid_urls = check_urls_are_valid(&url_blocklist)?;\n    let regexes = valid_urls.iter().map(|p| format!(r\"\\b{}\\b\", escape(p)));\n    let set = RegexSet::new(regexes)?;\n    Ok(set)\n  }\n\n  #[test]\n  fn test_url_blocking() -> LemmyResult<()> {\n    let set = create_url_blocklist_test_regex_set(vec![\"example.com/\"])?;\n\n    assert!(\n      markdown_check_for_blocked_urls(&String::from(\"[](https://example.com)\"), &set).is_err()\n    );\n\n    assert!(\n      markdown_check_for_blocked_urls(\n        &String::from(\"Go to https://example.com to get free Robux\"),\n        &set\n      )\n      .is_err()\n    );\n\n    assert!(\n      markdown_check_for_blocked_urls(&String::from(\"[](https://example.blog)\"), &set).is_ok()\n    );\n\n    assert!(markdown_check_for_blocked_urls(&String::from(\"example.com\"), &set).is_err());\n\n    assert!(\n      markdown_check_for_blocked_urls(\n        \"Odio exercitationem culpa sed sunt\n      et. Sit et similique tempora deserunt doloremque. Cupiditate iusto\n      repellat et quis qui. Cum veritatis facere quasi repellendus sunt\n      eveniet nemo sint. Cumque sit unde est. https://example.com Alias\n      repellendus at quos.\",\n        &set\n      )\n      .is_err()\n    );\n\n    let set = create_url_blocklist_test_regex_set(vec![\"example.com/spam.jpg\"])?;\n    assert!(markdown_check_for_blocked_urls(\"![](https://example.com/spam.jpg)\", &set).is_err());\n    assert!(markdown_check_for_blocked_urls(\"![](https://example.com/spam.jpg1)\", &set).is_ok());\n    // TODO: the following should not be matched, scunthorpe problem.\n    assert!(\n      markdown_check_for_blocked_urls(\"![](https://example.com/spam.jpg.html)\", &set).is_err()\n    );\n\n    let set = create_url_blocklist_test_regex_set(vec![\n      r\"quo.example.com/\",\n      r\"foo.example.com/\",\n      r\"bar.example.com/\",\n    ])?;\n\n    assert!(markdown_check_for_blocked_urls(\"https://baz.example.com\", &set).is_ok());\n\n    assert!(markdown_check_for_blocked_urls(\"https://bar.example.com\", &set).is_err());\n\n    let set = create_url_blocklist_test_regex_set(vec![\"example.com/banned_page\"])?;\n\n    assert!(markdown_check_for_blocked_urls(\"https://example.com/page\", &set).is_ok());\n\n    let set = create_url_blocklist_test_regex_set(vec![\"ex.mple.com/\"])?;\n\n    assert!(markdown_check_for_blocked_urls(\"example.com\", &set).is_ok());\n\n    let set = create_url_blocklist_test_regex_set(vec![\"rt.com/\"])?;\n\n    assert!(markdown_check_for_blocked_urls(\"deviantart.com\", &set).is_ok());\n    assert!(markdown_check_for_blocked_urls(\"art.com.example.com\", &set).is_ok());\n    assert!(markdown_check_for_blocked_urls(\"https://rt.com/abc\", &set).is_err());\n    assert!(markdown_check_for_blocked_urls(\"go to rt.com.\", &set).is_err());\n    assert!(markdown_check_for_blocked_urls(\"check out rt.computer\", &set).is_ok());\n    // TODO: the following should not be matched, scunthorpe problem.\n    assert!(markdown_check_for_blocked_urls(\"rt.com.example.com\", &set).is_err());\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/utils/mention.rs",
    "content": "use itertools::Itertools;\nuse regex::Regex;\nuse std::sync::LazyLock;\n\n#[expect(clippy::expect_used)]\npub(crate) static MENTIONS_REGEX: LazyLock<Regex> = LazyLock::new(|| {\n  Regex::new(r\"@(?P<name>[\\w.]+)@(?P<domain>[a-zA-Z0-9._:-]+)\").expect(\"compile regex\")\n});\n// TODO nothing is done with community / group webfingers yet, so just ignore those for now\n#[derive(Clone, PartialEq, Eq, Hash)]\npub struct MentionData {\n  pub name: String,\n  pub domain: String,\n}\n\nimpl MentionData {\n  pub fn is_local(&self, hostname: &str) -> bool {\n    hostname.eq(&self.domain)\n  }\n  pub fn full_name(&self) -> String {\n    format!(\"@{}@{}\", &self.name, &self.domain)\n  }\n}\n\npub fn scrape_text_for_mentions(text: &str) -> Vec<MentionData> {\n  let mut out: Vec<MentionData> = Vec::new();\n  for caps in MENTIONS_REGEX.captures_iter(text) {\n    if let Some(name) = caps.name(\"name\").map(|c| c.as_str().to_string())\n      && let Some(domain) = caps.name(\"domain\").map(|c| c.as_str().to_string())\n    {\n      out.push(MentionData { name, domain });\n    }\n  }\n  out.into_iter().unique().collect()\n}\n\n#[cfg(test)]\n#[expect(clippy::indexing_slicing)]\nmod test {\n\n  use crate::utils::mention::scrape_text_for_mentions;\n  use pretty_assertions::assert_eq;\n\n  #[test]\n  fn test_mentions_regex() {\n    let text = \"Just read a great blog post by [@tedu@honk.teduangst.com](/u/test). And another by !test_community@fish.teduangst.com . Another [@lemmy@lemmy-alpha:8540](/u/fish)\";\n    let mentions = scrape_text_for_mentions(text);\n\n    assert_eq!(mentions[0].name, \"tedu\".to_string());\n    assert_eq!(mentions[0].domain, \"honk.teduangst.com\".to_string());\n    assert_eq!(mentions[1].domain, \"lemmy-alpha:8540\".to_string());\n  }\n}\n"
  },
  {
    "path": "crates/utils/src/utils/mod.rs",
    "content": "pub mod markdown;\npub mod mention;\npub mod slurs;\npub mod validation;\n"
  },
  {
    "path": "crates/utils/src/utils/slurs.rs",
    "content": "use crate::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};\nuse regex::Regex;\n\npub fn remove_slurs(test: &str, slur_regex: &Regex) -> String {\n  slur_regex.replace_all(test, \"*removed*\").to_string()\n}\n\npub(crate) fn slur_check<'a>(test: &'a str, slur_regex: &'a Regex) -> Result<(), Vec<&'a str>> {\n  let mut matches: Vec<&str> = slur_regex.find_iter(test).map(|mat| mat.as_str()).collect();\n\n  // Unique\n  matches.sort_unstable();\n  matches.dedup();\n\n  if matches.is_empty() {\n    Ok(())\n  } else {\n    Err(matches)\n  }\n}\n\npub fn check_slurs(text: &str, slur_regex: &Regex) -> LemmyResult<()> {\n  if let Err(slurs) = slur_check(text, slur_regex) {\n    Err(anyhow::anyhow!(\"{}\", slurs_vec_to_str(&slurs))).with_lemmy_type(LemmyErrorType::Slurs)\n  } else {\n    Ok(())\n  }\n}\n\npub fn check_slurs_opt(text: &Option<String>, slur_regex: &Regex) -> LemmyResult<()> {\n  match text {\n    Some(t) => check_slurs(t, slur_regex),\n    None => Ok(()),\n  }\n}\n\npub(crate) fn slurs_vec_to_str(slurs: &[&str]) -> String {\n  let start = \"No slurs - \";\n  let combined = &slurs.join(\", \");\n  [start, combined].concat()\n}\n\n#[cfg(test)]\nmod test {\n\n  use crate::{\n    error::LemmyResult,\n    utils::slurs::{remove_slurs, slur_check, slurs_vec_to_str},\n  };\n  use pretty_assertions::assert_eq;\n  use regex::RegexBuilder;\n\n  #[test]\n  fn test_slur_filter() -> LemmyResult<()> {\n    let slur_regex = RegexBuilder::new(r\"(fag(g|got|tard)?\\b|cock\\s?sucker(s|ing)?|ni[gq]{2}[e3]?r[sz]?|mudslime?s?|kikes?|\\bspi(c|k)s?\\b|\\bchinks?|gooks?|bitch(es|ing|y)?|whor(es?|ing)|\\btr(a|@)nn?(y|ies?)|\\b(b|re|r)tard(ed)?s?)\").case_insensitive(true).build()?;\n    let test = \"faggot test kike tranny cocksucker retardeds. Capitalized Niggerz. This is a bunch of other safe text.\";\n    let slur_free = \"No slurs here\";\n    assert_eq!(\n      remove_slurs(test, &slur_regex),\n      \"*removed* test *removed* *removed* *removed* *removed*. Capitalized *removed*. This is a bunch of other safe text.\"\n        .to_string()\n    );\n\n    let has_slurs_vec = vec![\n      \"Niggerz\",\n      \"cocksucker\",\n      \"faggot\",\n      \"kike\",\n      \"retardeds\",\n      \"tranny\",\n    ];\n    let has_slurs_err_str = \"No slurs - Niggerz, cocksucker, faggot, kike, retardeds, tranny\";\n\n    assert_eq!(slur_check(test, &slur_regex), Err(has_slurs_vec));\n    assert_eq!(slur_check(slur_free, &slur_regex), Ok(()));\n    if let Err(slur_vec) = slur_check(test, &slur_regex) {\n      assert_eq!(&slurs_vec_to_str(&slur_vec), has_slurs_err_str);\n    }\n\n    Ok(())\n  }\n\n  // These helped with testing\n  // #[test]\n  // fn test_send_email() {\n  //  let result =  send_email(\"not a subject\", \"test_email@gmail.com\", \"ur user\", \"<h1>HI\n  // there</h1>\");   assert!(result.is_ok());\n  // }\n}\n"
  },
  {
    "path": "crates/utils/src/utils/validation.rs",
    "content": "use crate::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};\nuse clearurls::UrlCleaner;\nuse invisible_characters::INVISIBLE_CHARS;\nuse itertools::Itertools;\nuse regex::{Regex, RegexBuilder, RegexSet};\nuse std::sync::LazyLock;\nuse unicode_segmentation::UnicodeSegmentation;\nuse url::{ParseError, Url};\n\n// From here: https://github.com/vector-im/element-android/blob/develop/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt#L35\n#[expect(clippy::expect_used)]\nstatic VALID_MATRIX_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| {\n  Regex::new(r\"^@[A-Za-z0-9\\x21-\\x39\\x3B-\\x7F]+:[A-Za-z0-9.-]+(:[0-9]{2,5})?$\")\n    .expect(\"compile regex\")\n});\n// taken from https://en.wikipedia.org/wiki/UTM_parameters\n#[expect(clippy::expect_used)]\nstatic URL_CLEANER: LazyLock<UrlCleaner> =\n  LazyLock::new(|| UrlCleaner::from_embedded_rules().expect(\"compile clearurls\"));\nconst ALLOWED_POST_URL_SCHEMES: [&str; 3] = [\"http\", \"https\", \"magnet\"];\n\nconst BODY_MAX_LENGTH: usize = 10000;\nconst POST_BODY_MAX_LENGTH: usize = 50000;\nconst BIO_MAX_LENGTH: usize = 1000;\nconst URL_MAX_LENGTH: usize = 2000;\nconst ALT_TEXT_MAX_LENGTH: usize = 1500;\nconst SITE_NAME_MAX_LENGTH: usize = 20;\nconst SITE_NAME_MIN_LENGTH: usize = 1;\nconst SITE_SUMMARY_MAX_LENGTH: usize = 150;\nconst MIN_LENGTH_BLOCKING_KEYWORD: usize = 3;\nconst MAX_LENGTH_BLOCKING_KEYWORD: usize = 50;\nconst ACTOR_NAME_MAX_LENGTH: usize = 20;\nconst DISPLAY_NAME_MAX_LENGTH: usize = 50;\n\nfn has_newline(name: &str) -> bool {\n  name.contains('\\n')\n}\n\npub fn is_valid_actor_name(name: &str) -> LemmyResult<()> {\n  // Only allow characters from a single alphabet per username. This avoids problems with lookalike\n  // characters like `o` which looks identical in Latin and Cyrillic, and can be used to imitate\n  // other users. Checks for additional alphabets can be added in the same way.\n  #[expect(clippy::expect_used)]\n  static VALID_ACTOR_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r\"^(?:[a-zA-Z0-9_]+|[0-9_\\p{Arabic}]+|[0-9_\\p{Cyrillic}]+)$\").expect(\"compile regex\")\n  });\n\n  min_length_check(name, 3, LemmyErrorType::InvalidName)?;\n  max_length_check(name, ACTOR_NAME_MAX_LENGTH, LemmyErrorType::InvalidName)?;\n  if VALID_ACTOR_NAME_REGEX.is_match(name) {\n    Ok(())\n  } else {\n    Err(LemmyErrorType::InvalidName.into())\n  }\n}\n\nfn has_3_permitted_display_chars(name: &str) -> bool {\n  let mut num_non_fdc: i8 = 0;\n  for c in name.chars() {\n    if !INVISIBLE_CHARS.contains(&c) {\n      num_non_fdc += 1;\n      if num_non_fdc >= 3 {\n        break;\n      }\n    }\n  }\n  if num_non_fdc >= 3 {\n    return true;\n  }\n  false\n}\n\n// Can't do a regex here, reverse lookarounds not supported\npub fn is_valid_display_name(name: &str) -> LemmyResult<()> {\n  let check = !name.starts_with('@')\n    && !name.starts_with(INVISIBLE_CHARS)\n    && name.chars().count() <= DISPLAY_NAME_MAX_LENGTH\n    && !has_newline(name)\n    && has_3_permitted_display_chars(name);\n  if !check {\n    Err(LemmyErrorType::InvalidDisplayName.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn is_valid_matrix_id(matrix_id: &str) -> LemmyResult<()> {\n  let check = VALID_MATRIX_ID_REGEX.is_match(matrix_id) && !has_newline(matrix_id);\n  if !check {\n    Err(LemmyErrorType::InvalidMatrixId.into())\n  } else {\n    Ok(())\n  }\n}\n\npub fn is_valid_post_title(title: &str) -> LemmyResult<()> {\n  let length = title.trim().chars().count();\n  let check =\n    (3..=200).contains(&length) && !has_newline(title) && has_3_permitted_display_chars(title);\n  if !check {\n    Err(LemmyErrorType::InvalidPostTitle.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// This could be post bodies, comments, notes, or any description field\npub fn is_valid_body_field(body: &str, post: bool) -> LemmyResult<()> {\n  if post {\n    max_length_check(body, POST_BODY_MAX_LENGTH, LemmyErrorType::InvalidBodyField)?;\n  } else {\n    max_length_check(body, BODY_MAX_LENGTH, LemmyErrorType::InvalidBodyField)?;\n  };\n  Ok(())\n}\n\npub fn is_valid_bio_field(bio: &str) -> LemmyResult<()> {\n  max_length_check(bio, BIO_MAX_LENGTH, LemmyErrorType::BioLengthOverflow)\n}\n\npub fn is_valid_alt_text_field(alt_text: &str) -> LemmyResult<()> {\n  max_length_check(\n    alt_text,\n    ALT_TEXT_MAX_LENGTH,\n    LemmyErrorType::AltTextLengthOverflow,\n  )?;\n\n  Ok(())\n}\n\n/// Checks the site name length, the limit as defined in the DB.\npub fn site_name_length_check(name: &str) -> LemmyResult<()> {\n  min_length_check(name, SITE_NAME_MIN_LENGTH, LemmyErrorType::SiteNameRequired)?;\n  max_length_check(\n    name,\n    SITE_NAME_MAX_LENGTH,\n    LemmyErrorType::SiteNameLengthOverflow,\n  )\n}\n\n/// Checks the site / community description length, the limit as defined in the DB.\npub fn summary_length_check(description: &str) -> LemmyResult<()> {\n  max_length_check(\n    description,\n    SITE_SUMMARY_MAX_LENGTH,\n    LemmyErrorType::SiteDescriptionLengthOverflow,\n  )\n}\n\n/// Check minimum and maximum length of input string. If the string is too short or too long, the\n/// corresponding error is returned.\n///\n/// HTML frontends specify maximum input length using `maxlength` attribute.\n/// For consistency we use the same counting method (UTF-16 code units).\n/// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength\nfn max_length_check(item: &str, max_length: usize, max_msg: LemmyErrorType) -> LemmyResult<()> {\n  let len = item.encode_utf16().count();\n  if len > max_length {\n    Err(max_msg.into())\n  } else {\n    Ok(())\n  }\n}\n\nfn min_length_check(item: &str, min_length: usize, min_msg: LemmyErrorType) -> LemmyResult<()> {\n  let len = item.encode_utf16().count();\n  if len < min_length {\n    Err(min_msg.into())\n  } else {\n    Ok(())\n  }\n}\n\n/// Attempts to build a regex and check it for common errors before inserting into the DB.\npub fn build_and_check_regex(regex_str_opt: Option<&str>) -> LemmyResult<Regex> {\n  // Placeholder regex which doesnt match anything\n  // https://stackoverflow.com/a/940840\n  let match_nothing = RegexBuilder::new(\"a^\")\n    .build()\n    .with_lemmy_type(LemmyErrorType::InvalidRegex);\n  if let Some(regex) = regex_str_opt {\n    if regex.is_empty() {\n      match_nothing\n    } else {\n      let regex = RegexBuilder::new(regex)\n        .case_insensitive(true)\n        .build()\n        .with_lemmy_type(LemmyErrorType::InvalidRegex)?;\n      if regex.is_match(\"1\") {\n        Err(LemmyErrorType::PermissiveRegex.into())\n      } else {\n        Ok(regex)\n      }\n    }\n  } else {\n    match_nothing\n  }\n}\n\n/// Cleans a url of tracking parameters.\npub fn clean_url(url: &Url) -> Url {\n  match URL_CLEANER.clear_single_url(url) {\n    Ok(res) => res.into_owned(),\n    // If there are any errors, just return the original url\n    Err(_) => url.clone(),\n  }\n}\n\n/// Cleans all the links in a string of tracking parameters.\npub fn clean_urls_in_text(text: &str) -> String {\n  match URL_CLEANER.clear_text(text) {\n    Ok(res) => res.into_owned(),\n    // If there are any errors, just return the original text\n    Err(_) => text.to_owned(),\n  }\n}\n\npub fn is_valid_url(url: &Url) -> LemmyResult<()> {\n  if !ALLOWED_POST_URL_SCHEMES.contains(&url.scheme()) {\n    return Err(LemmyErrorType::InvalidUrlScheme.into());\n  }\n\n  max_length_check(\n    url.as_str(),\n    URL_MAX_LENGTH,\n    LemmyErrorType::UrlLengthOverflow,\n  )?;\n\n  Ok(())\n}\n\npub fn is_url_blocked(url: &Url, blocklist: &RegexSet) -> LemmyResult<()> {\n  if blocklist.is_match(url.as_str()) {\n    return Err(LemmyErrorType::BlockedUrl.into());\n  }\n\n  Ok(())\n}\n\n/// Check that urls are valid, and also remove the scheme, and uniques\npub fn check_urls_are_valid(urls: &Vec<String>) -> LemmyResult<Vec<String>> {\n  let mut parsed_urls = vec![];\n  for url in urls {\n    parsed_urls.push(build_url_str_without_scheme(url)?);\n  }\n\n  let unique_urls = parsed_urls.into_iter().unique().collect();\n  Ok(unique_urls)\n}\n\npub fn check_blocking_keywords_are_valid(blocking_keywords: &Vec<String>) -> LemmyResult<()> {\n  for keyword in blocking_keywords {\n    min_length_check(\n      keyword,\n      MIN_LENGTH_BLOCKING_KEYWORD,\n      LemmyErrorType::BlockKeywordTooShort,\n    )?;\n    max_length_check(\n      keyword,\n      MAX_LENGTH_BLOCKING_KEYWORD,\n      LemmyErrorType::BlockKeywordTooLong,\n    )?;\n  }\n  check_api_elements_count(blocking_keywords.len())?;\n  Ok(())\n}\n\nfn build_url_str_without_scheme(url_str: &str) -> LemmyResult<String> {\n  // Parse and check for errors\n  let mut url = Url::parse(url_str).or_else(|e| {\n    if e == ParseError::RelativeUrlWithoutBase {\n      Url::parse(&format!(\"http://{url_str}\"))\n    } else {\n      Err(e)\n    }\n  })?;\n\n  // Set the scheme to http, then remove the http:// part\n  url\n    .set_scheme(\"http\")\n    .map_err(|_e| LemmyErrorType::InvalidUrl)?;\n\n  let mut out = url\n    .to_string()\n    .get(7..)\n    .ok_or(LemmyErrorType::InvalidUrl)?\n    .to_string();\n\n  // Remove trailing / if necessary\n  if out.ends_with('/') {\n    out.pop();\n  }\n\n  Ok(out)\n}\n\n// Shorten a string to n chars, being mindful of unicode grapheme\n// boundaries\n// To understand the difference between chars and graphemes see:\n// https://hsivonen.fi/string-length/\nfn truncate_for_db(text: &str, len: usize) -> String {\n  if text.chars().count() <= len {\n    text.to_string()\n  } else {\n    // Get the char at the desired `len`\n    let char_at_len = text\n      .char_indices()\n      .nth(len)\n      .unwrap_or(text.char_indices().last().unwrap_or_default());\n    let graphemes: Vec<(usize, _)> = text.grapheme_indices(true).collect();\n    let mut index = 0;\n\n    // Walk the string backwards and find the first char within our length\n    for idx in (0..graphemes.len()).rev() {\n      if let Some(grapheme) = graphemes.get(idx)\n        && grapheme.0 < char_at_len.0\n      {\n        index = idx;\n        break;\n      }\n    }\n\n    let grapheme_at_index = graphemes.get(index).unwrap_or(&(0, \"\"));\n    // The char count of the grapheme at the very end of the range\n    let grapheme_at_index_count = grapheme_at_index.1.chars().count();\n    // Count the total chars within the selected grapheme range\n    let char_sum = graphemes\n      .get(0..index)\n      .unwrap_or_default()\n      .iter()\n      .map(|(_, g)| g.chars().count())\n      .sum();\n\n    // Get the actual count of chars we need to take from `text`.\n    // `take` isn't inclusive, so if the last grapheme can fit we add its char\n    // length\n    let char_total = if char_sum + grapheme_at_index_count <= len {\n      char_sum + grapheme_at_index_count\n    } else {\n      char_sum\n    };\n\n    text.chars().take(char_total).collect::<String>()\n  }\n}\n\npub fn truncate_summary(text: &str) -> String {\n  truncate_for_db(text, SITE_SUMMARY_MAX_LENGTH)\n}\n\npub fn check_api_elements_count(len: usize) -> LemmyResult<()> {\n  if len >= MAX_API_PARAM_ELEMENTS {\n    return Err(LemmyErrorType::TooManyItems.into());\n  }\n  Ok(())\n}\n#[cfg(test)]\nmod tests {\n\n  use crate::{\n    error::{LemmyErrorType, LemmyResult},\n    utils::validation::{\n      BIO_MAX_LENGTH,\n      SITE_NAME_MAX_LENGTH,\n      SITE_SUMMARY_MAX_LENGTH,\n      URL_MAX_LENGTH,\n      build_and_check_regex,\n      check_urls_are_valid,\n      clean_url,\n      clean_urls_in_text,\n      is_url_blocked,\n      is_valid_actor_name,\n      is_valid_bio_field,\n      is_valid_display_name,\n      is_valid_matrix_id,\n      is_valid_post_title,\n      is_valid_url,\n      site_name_length_check,\n      summary_length_check,\n      truncate_for_db,\n    },\n  };\n  use pretty_assertions::assert_eq;\n  use url::Url;\n\n  const URL_WITH_TRACKING: &str = \"https://example.com/path/123?utm_content=buffercf3b2&utm_medium=social&user+name=random+user&id=123\";\n  const URL_TRACKING_REMOVED: &str = \"https://example.com/path/123?user+name=random+user&id=123\";\n\n  #[test]\n  fn test_clean_url_params() -> LemmyResult<()> {\n    let url = Url::parse(URL_WITH_TRACKING)?;\n    let cleaned = clean_url(&url);\n    let expected = Url::parse(URL_TRACKING_REMOVED)?;\n    assert_eq!(expected.to_string(), cleaned.to_string());\n\n    let url = Url::parse(\"https://example.com/path/123\")?;\n    let cleaned = clean_url(&url);\n    assert_eq!(url.to_string(), cleaned.to_string());\n\n    Ok(())\n  }\n\n  #[test]\n  fn test_clean_body() -> LemmyResult<()> {\n    let text = format!(\"[a link]({URL_WITH_TRACKING})\");\n    let cleaned = clean_urls_in_text(&text);\n    let expected = format!(\"[a link]({URL_TRACKING_REMOVED})\");\n    assert_eq!(expected.clone(), cleaned.clone());\n\n    let text = \"[a link](https://example.com/path/123)\";\n    let cleaned = clean_urls_in_text(text);\n    assert_eq!(text.to_string(), cleaned);\n\n    Ok(())\n  }\n\n  #[test]\n  fn regex_checks() {\n    assert!(is_valid_post_title(\"hi\").is_err());\n    assert!(is_valid_post_title(\"him\").is_ok());\n    assert!(is_valid_post_title(\"  him  \").is_ok());\n    assert!(is_valid_post_title(\"n\\n\\n\\n\\nanother\").is_err());\n    assert!(is_valid_post_title(\"hello there!\\n this is a test.\").is_err());\n    assert!(is_valid_post_title(\"hello there! this is a test.\").is_ok());\n    assert!(is_valid_post_title((\"12345\".repeat(40) + \"x\").as_str()).is_err());\n    assert!(is_valid_post_title(\"12345\".repeat(40).as_str()).is_ok());\n    assert!(is_valid_post_title(((\"12345\".repeat(40)) + \"  \").as_str()).is_ok());\n  }\n\n  #[test]\n  fn test_valid_actor_name() {\n    assert!(is_valid_actor_name(\"Hello_98\",).is_ok());\n    assert!(is_valid_actor_name(\"ten\",).is_ok());\n    assert!(is_valid_actor_name(\"تجريب\",).is_ok());\n    assert!(is_valid_actor_name(\"تجريب_123\",).is_ok());\n    assert!(is_valid_actor_name(\"Владимир\",).is_ok());\n\n    // mixed scripts\n    assert!(is_valid_actor_name(\"تجريب_abc\",).is_err());\n    assert!(is_valid_actor_name(\"Влад_abc\",).is_err());\n    // dash\n    assert!(is_valid_actor_name(\"Hello-98\",).is_err());\n    // too short\n    assert!(is_valid_actor_name(\"a\",).is_err());\n    // empty\n    assert!(is_valid_actor_name(\"\",).is_err());\n    // newline\n    assert!(\n      is_valid_actor_name(\n        r\"Line1\n\nLine3\",\n      )\n      .is_err()\n    );\n    assert!(is_valid_actor_name(\"Line1\\nLine3\",).is_err());\n  }\n\n  #[test]\n  fn test_valid_display_name() {\n    assert!(is_valid_display_name(\"hello @there\").is_ok());\n    assert!(is_valid_display_name(\"@hello there\").is_err());\n    assert!(is_valid_display_name(\"\\u{200d}hello\").is_err());\n    assert!(is_valid_display_name(\"\\u{1f3f3}\\u{fe0f}\\u{200d}\\u{26a7}\\u{fe0f}Name\").is_ok());\n    assert!(is_valid_display_name(\"\\u{2003}1\\u{ffa0}2\\u{200d}\").is_err());\n\n    // Make sure zero-space with an @ doesn't work\n    assert!(is_valid_display_name(&format!(\"{}@my name is\", '\\u{200b}')).is_err());\n  }\n\n  #[test]\n  fn test_valid_post_title() {\n    assert!(is_valid_post_title(\"Post Title\").is_ok());\n    assert!(\n      is_valid_post_title(\n        \"აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი\"\n      )\n      .is_ok()\n    );\n    assert!(is_valid_post_title(\"   POST TITLE 😃😃😃😃😃\").is_ok());\n    assert!(is_valid_post_title(\"\\n \\n \\n \\n    \t\t\").is_err()); // tabs/spaces/newlines\n    assert!(is_valid_post_title(\"\\u{206a}\").is_err()); // invisible chars\n    assert!(is_valid_post_title(\"\\u{1f3f3}\\u{fe0f}\\u{200d}\\u{26a7}\\u{fe0f}\").is_ok());\n  }\n\n  #[test]\n  fn test_valid_matrix_id() {\n    assert!(is_valid_matrix_id(\"@dess:matrix.org\").is_ok());\n    assert!(is_valid_matrix_id(\"@dess_:matrix.org\").is_ok());\n    assert!(is_valid_matrix_id(\"@dess:matrix.org:443\").is_ok());\n    assert!(is_valid_matrix_id(\"dess:matrix.org\").is_err());\n    assert!(is_valid_matrix_id(\" @dess:matrix.org\").is_err());\n    assert!(is_valid_matrix_id(\"@dess:matrix.org t\").is_err());\n    assert!(is_valid_matrix_id(\"@dess:matrix.org t\").is_err());\n  }\n\n  #[test]\n  fn test_valid_site_name() -> LemmyResult<()> {\n    let valid_names = [\n      (0..SITE_NAME_MAX_LENGTH).map(|_| 'A').collect::<String>(),\n      String::from(\"A\"),\n    ];\n    let invalid_names = [\n      (\n        &(0..SITE_NAME_MAX_LENGTH + 1)\n          .map(|_| 'A')\n          .collect::<String>(),\n        LemmyErrorType::SiteNameLengthOverflow,\n      ),\n      (&String::new(), LemmyErrorType::SiteNameRequired),\n    ];\n\n    valid_names.iter().for_each(|valid_name| {\n      assert!(\n        site_name_length_check(valid_name).is_ok(),\n        \"Expected {} of length {} to be Ok.\",\n        valid_name,\n        valid_name.len()\n      )\n    });\n\n    invalid_names\n      .iter()\n      .for_each(|(invalid_name, expected_err)| {\n        let result = site_name_length_check(invalid_name);\n\n        assert!(result.is_err());\n        assert!(\n          result.is_err_and(|e| e.error_type.eq(&expected_err.clone())),\n          \"Testing {}, expected error {}\",\n          invalid_name,\n          expected_err\n        );\n      });\n    Ok(())\n  }\n\n  #[test]\n  fn test_valid_bio() {\n    assert!(is_valid_bio_field(&(0..BIO_MAX_LENGTH).map(|_| 'A').collect::<String>()).is_ok());\n\n    let invalid_result =\n      is_valid_bio_field(&(0..BIO_MAX_LENGTH + 1).map(|_| 'A').collect::<String>());\n\n    assert!(\n      invalid_result.is_err()\n        && invalid_result.is_err_and(|e| e.error_type.eq(&LemmyErrorType::BioLengthOverflow))\n    );\n  }\n\n  #[test]\n  fn test_valid_site_description() {\n    assert!(\n      summary_length_check(\n        &(0..SITE_SUMMARY_MAX_LENGTH)\n          .map(|_| 'A')\n          .collect::<String>()\n      )\n      .is_ok()\n    );\n\n    let invalid_result = summary_length_check(\n      &(0..SITE_SUMMARY_MAX_LENGTH + 1)\n        .map(|_| 'A')\n        .collect::<String>(),\n    );\n\n    assert!(\n      invalid_result.is_err()\n        && invalid_result.is_err_and(|e| e\n          .error_type\n          .eq(&LemmyErrorType::SiteDescriptionLengthOverflow))\n    );\n  }\n\n  #[test]\n  fn test_valid_slur_regex() -> LemmyResult<()> {\n    let valid_regex = Some(\"(foo|bar)\");\n    build_and_check_regex(valid_regex)?;\n\n    let missing_regex = None;\n    let match_none = build_and_check_regex(missing_regex)?;\n    assert!(!match_none.is_match(\"\"));\n    assert!(!match_none.is_match(\"a\"));\n\n    let empty = Some(\"\");\n    let match_none = build_and_check_regex(empty)?;\n    assert!(!match_none.is_match(\"\"));\n    assert!(!match_none.is_match(\"a\"));\n\n    Ok(())\n  }\n\n  #[test]\n  fn test_too_permissive_slur_regex() {\n    let match_everything_regexes = [\n      (Some(\"[\"), LemmyErrorType::InvalidRegex),\n      (Some(\"(foo|bar|)\"), LemmyErrorType::PermissiveRegex),\n      (Some(\".*\"), LemmyErrorType::PermissiveRegex),\n    ];\n\n    match_everything_regexes\n      .into_iter()\n      .for_each(|(regex_str, expected_err)| {\n        let result = build_and_check_regex(regex_str);\n\n        assert!(result.is_err());\n        assert!(\n          result.is_err_and(|e| e.error_type.eq(&expected_err.clone())),\n          \"Testing regex {:?}, expected error {}\",\n          regex_str,\n          expected_err\n        );\n      });\n  }\n\n  #[test]\n  fn test_check_url_valid() -> LemmyResult<()> {\n    assert!(is_valid_url(&Url::parse(\"http://example.com\")?).is_ok());\n    assert!(is_valid_url(&Url::parse(\"https://example.com\")?).is_ok());\n    assert!(is_valid_url(&Url::parse(\"https://example.com\")?).is_ok());\n    assert!(\n      is_valid_url(&Url::parse(\"ftp://example.com\")?)\n        .is_err_and(|e| e.error_type.eq(&LemmyErrorType::InvalidUrlScheme))\n    );\n    assert!(\n      is_valid_url(&Url::parse(\"javascript:void\")?)\n        .is_err_and(|e| e.error_type.eq(&LemmyErrorType::InvalidUrlScheme))\n    );\n\n    let magnet_link = \"magnet:?xt=urn:btih:4b390af3891e323778959d5abfff4b726510f14c&dn=Ravel%20Complete%20Piano%20Sheet%20Music%20-%20Public%20Domain&tr=udp%3A%2F%2Fopen.tracker.cl%3A1337%2Fannounce\";\n    assert!(is_valid_url(&Url::parse(magnet_link)?).is_ok());\n\n    // Also make sure the length overflow hits an error\n    let mut long_str = \"http://example.com/test=\".to_string();\n    for _ in 1..URL_MAX_LENGTH {\n      long_str.push('X');\n    }\n    let long_url = Url::parse(&long_str)?;\n    assert!(\n      is_valid_url(&long_url).is_err_and(|e| e.error_type.eq(&LemmyErrorType::UrlLengthOverflow))\n    );\n\n    Ok(())\n  }\n\n  #[test]\n  fn test_url_block() -> LemmyResult<()> {\n    let set = regex::RegexSet::new(vec![\n      r\"(https://)?example\\.org/page/to/article\",\n      r\"(https://)?example\\.net/?\",\n      r\"(https://)?example\\.com/?\",\n    ])?;\n\n    assert!(is_url_blocked(&Url::parse(\"https://example.blog\")?, &set).is_ok());\n\n    assert!(is_url_blocked(&Url::parse(\"https://example.org\")?, &set).is_ok());\n\n    assert!(is_url_blocked(&Url::parse(\"https://example.com\")?, &set).is_err());\n\n    Ok(())\n  }\n\n  #[test]\n  fn test_url_parsed() -> LemmyResult<()> {\n    // Make sure the scheme is removed, and uniques also\n    assert_eq!(\n      &check_urls_are_valid(&vec![\n        \"example.com\".to_string(),\n        \"http://example.com\".to_string(),\n        \"https://example.com\".to_string(),\n        \"https://example.com/test?q=test2&q2=test3#test4\".to_string(),\n      ])?,\n      &vec![\n        \"example.com\".to_string(),\n        \"example.com/test?q=test2&q2=test3#test4\".to_string()\n      ],\n    );\n\n    assert!(check_urls_are_valid(&vec![\"https://example .com\".to_string()]).is_err());\n    Ok(())\n  }\n\n  #[test]\n  fn test_truncate() -> LemmyResult<()> {\n    assert_eq!(\"Hell\", truncate_for_db(\"Hello\", 4));\n    assert_eq!(\"word\", truncate_for_db(\"word\", 10));\n    assert_eq!(\"Wales: \", truncate_for_db(\"Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿\", 10));\n    assert_eq!(\"Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿\", truncate_for_db(\"Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿\", 14));\n    assert_eq!(\"it’s\", truncate_for_db(\"it’s like this\", 4));\n    assert_eq!(\"🤦🏼‍♂️150\", truncate_for_db(\"🤦🏼‍♂️150🤦🏼‍♂️\", 11));\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "crates/utils/tests/test_errors_used.rs",
    "content": "use lemmy_utils::error::LemmyErrorType;\nuse std::{env::current_dir, process::Command};\nuse strum::IntoEnumIterator;\n\n#[test]\n#[expect(clippy::unwrap_used, clippy::tests_outside_test_module)]\nfn test_errors_used() {\n  let mut unused_error_found = false;\n  let mut current_dir = current_dir().unwrap();\n  current_dir.pop();\n  current_dir.pop();\n  for error in LemmyErrorType::iter() {\n    let search = format!(\"LemmyErrorType::{error}\");\n    let mut grep_all = Command::new(\"grep\");\n    let grep_all = grep_all\n      .current_dir(current_dir.clone())\n      .arg(\"-R\")\n      .arg(\"--exclude=error.rs\")\n      .arg(&search)\n      .arg(\"crates/\");\n    let output = grep_all.output().unwrap();\n    let grep_all_out = std::str::from_utf8(&output.stdout).unwrap();\n\n    let mut grep_apub = Command::new(\"grep\");\n    let grep_apub = grep_apub\n      .current_dir(current_dir.clone())\n      .arg(\"-R\")\n      .arg(\"--exclude-dir=api\")\n      .arg(&search)\n      .arg(\"crates/apub/\");\n    let output = grep_apub.output().unwrap();\n    let grep_apub_out = std::str::from_utf8(&output.stdout).unwrap();\n\n    if grep_all_out.is_empty() {\n      println!(\"LemmyErrorType::{} is unused\", error);\n      unused_error_found = true;\n    }\n    if search != \"LemmyErrorType::UntranslatedError\" && grep_all_out == grep_apub_out {\n      println!(\"LemmyErrorType::{} is only used for federation\", error);\n      unused_error_found = true;\n    }\n  }\n  assert!(!unused_error_found);\n}\n"
  },
  {
    "path": "diesel.toml",
    "content": "[print_schema]\nfile = \"crates/db_schema_file/src/schema.rs\"\npatch_file = \"crates/db_schema_file/diesel_ltree.patch\"\n# Required for https://github.com/adwhit/diesel-derive-enum\ncustom_type_derives = [\"diesel::query_builder::QueryId\"]\n# This table is in the lemmy_diesel_utils crate instead.\nfilter = { except_tables = [\"previously_run_sql\"] }\nallow_tables_to_appear_in_same_query_config = \"fk_related_tables\"\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "# syntax=docker/dockerfile:1.20\nARG RUST_VERSION=1.94\nARG CARGO_BUILD_FEATURES=default\nARG RUST_RELEASE_MODE=debug\n\nARG BUILDER_IMAGE=lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION}\nARG RUNNER_IMAGE=debian:sid-slim\n\nARG UNAME=lemmy\nARG UID=1000\nARG GID=1000\n\n# Chef\nFROM ${BUILDER_IMAGE} AS chef\nWORKDIR /lemmy\n\n# Planner\nFROM chef AS planner\nCOPY . . \nRUN cargo chef prepare --recipe-path recipe.json\n\n# Builder\nFROM chef AS builder\n\nARG CARGO_BUILD_FEATURES\nARG RUST_RELEASE_MODE\nARG RUSTFLAGS\nARG CI_PIPELINE_EVENT\n\nCOPY --from=planner /lemmy/recipe.json recipe.json\n# Build dependencies - this is the caching Docker layer!\nRUN if [ \"${RUST_RELEASE_MODE}\" = \"release\" ]; \\\n  then \\\n  cargo chef cook --recipe-path recipe.json --release; \\\n  else \\\n  cargo chef cook --recipe-path recipe.json; \\\n  fi \n\nCOPY . .\n\n# Release build\n\nRUN if [ \"${RUST_RELEASE_MODE}\" = \"release\" ]; \\\n  then \\\n  cargo build --features \"${CARGO_BUILD_FEATURES}\" --release; \\\n  mv target/release/lemmy_server .; \\\n  else \\\n  cargo build --features \"${CARGO_BUILD_FEATURES}\"; \\\n  mv target/debug/lemmy_server .; \\\n  fi \n\n# Runner\nFROM ${RUNNER_IMAGE} AS runner\n\n# Add system packages that are needed: federation needs CA certificates, curl can be used for healthchecks\nRUN apt update && apt install -y libssl-dev libpq-dev ca-certificates curl git\n\nCOPY --from=builder --chmod=0755 /lemmy/lemmy_server /usr/local/bin\n\nLABEL org.opencontainers.image.authors=\"The Lemmy Authors\"\nLABEL org.opencontainers.image.source=\"https://github.com/LemmyNet/lemmy\"\nLABEL org.opencontainers.image.licenses=\"AGPL-3.0-or-later\"\nLABEL org.opencontainers.image.description=\"A link aggregator and forum for the fediverse\"\n\nARG UNAME\nARG GID\nARG UID\n\nRUN groupadd -g ${GID} -o ${UNAME} && \\\n  useradd -m -u ${UID} -g ${GID} -o -s /bin/bash ${UNAME}\nUSER $UNAME\n\nENTRYPOINT [\"lemmy_server\"]\nEXPOSE 8536\nSTOPSIGNAL SIGTERM\n"
  },
  {
    "path": "docker/README.md",
    "content": "# Building Lemmy Images\n\nLemmy's images are meant to be **built** on `linux/amd64`,\nbut they can be **executed** on both `linux/amd64` and `linux/arm64`.\n\nTo do so we need to use a _cross toolchain_ whose goal is to build\n**from** amd64 **to** arm64.\n\nNamely, we need to link the _lemmy_server_ with `pq` and `openssl`\nshared libraries and a few others, and they need to be in `arm64`,\nindeed.\n\nThe toolchain we use to cross-compile is specifically tailored for\nLemmy's needs, see [the image repository][image-repo].\n\n#### References\n\n- [The Linux Documentation Project on Shared Libraries][tldp-lib]\n\n[tldp-lib]: https://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html\n[image-repo]: https://github.com/raskyld/lemmy-cross-toolchains\n"
  },
  {
    "path": "docker/customPostgresql.conf",
    "content": "# You can use https://pgtune.leopard.in.ua to tune this for your system.\n# DB Version: 16\n# OS Type: linux\n# DB Type: web\n# Total Memory (RAM): 12 GB\n# CPUs num: 16\n# Data Storage: ssd\n\nmax_connections = 200\nshared_buffers = 3GB\neffective_cache_size = 9GB\nmaintenance_work_mem = 768MB\ncheckpoint_completion_target = 0.9\nwal_buffers = 16MB\ndefault_statistics_target = 100\nrandom_page_cost = 1.1\neffective_io_concurrency = 200\nwork_mem = 3932kB\nhuge_pages = try\nmin_wal_size = 1GB\nmax_wal_size = 8GB\nmax_worker_processes = 16\nmax_parallel_workers_per_gather = 4\nmax_parallel_workers = 16\nmax_parallel_maintenance_workers = 4\n\n# Listen address\nlisten_addresses = '*'\n\n# Logging\nsession_preload_libraries = auto_explain\nauto_explain.log_min_duration = 5ms\nauto_explain.log_analyze = true\nauto_explain.log_triggers = true\ntrack_activity_query_size = 1048576\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "x-logging: &default-logging\n  driver: \"json-file\"\n  options:\n    max-size: \"50m\"\n    max-file: \"4\"\n\nservices:\n  proxy:\n    image: nginx:1-alpine\n    ports:\n      # actual and only port facing any connection from outside\n      # Note, change the left number if port 1236 is already in use on your system\n      # You could use port 80 if you won't use a reverse proxy\n      - \"1236:1236\"\n      - \"8536:8536\"\n    volumes:\n      - ./nginx.conf:/etc/nginx/nginx.conf:ro,Z\n    restart: unless-stopped\n    depends_on:\n      - pictrs\n      - lemmy-ui\n    logging: *default-logging\n\n  lemmy:\n    build:\n      context: ../\n      dockerfile: docker/Dockerfile\n    hostname: lemmy\n    restart: unless-stopped\n    environment:\n      - RUST_LOG=warn,extism=info,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug\n      - LEMMY_DISABLE_ACTIVITY_SENDING=true\n    volumes:\n      - ./lemmy.hjson:/config/config.hjson:Z\n      - ./plugins:/plugins:Z\n    depends_on:\n      - postgres\n      - pictrs\n    logging: *default-logging\n\n  lemmy-ui:\n    # use \"image\" to pull down an already compiled lemmy-ui. make sure to comment out \"build\".\n    image: dessalines/lemmy-ui:nightly\n    # platform: linux/x86_64 # no arm64 support. uncomment platform if using m1.\n    # use \"build\" to build your local lemmy ui image for development. make sure to comment out \"image\".\n    # run: docker compose up --build\n\n    # build:\n    #   context: ../../lemmy-ui # assuming lemmy-ui is cloned besides lemmy directory\n    #   dockerfile: dev.dockerfile\n    environment:\n      # this needs to match the hostname defined in the lemmy service\n      - LEMMY_UI_BACKEND=lemmy:8536\n      # set the outside hostname here\n      - LEMMY_UI_HTTPS=false\n      - LEMMY_UI_ERUDA=true\n    depends_on:\n      - lemmy\n    restart: unless-stopped\n    logging: *default-logging\n    init: true\n\n  pictrs:\n    image: asonix/pictrs:0.5.17-pre.9\n    # this needs to match the pictrs url in lemmy.hjson\n    hostname: pictrs\n    # we can set options to pictrs like this, here we set max. image size and forced format for conversion\n    # entrypoint: /sbin/tini -- /usr/local/bin/pict-rs -p /mnt -m 4 --image-format webp\n    environment:\n      - PICTRS_OPENTELEMETRY_URL=http://otel:4137\n      - PICTRS__SERVER__API_KEY=my-pictrs-key\n      - PICTRS__MEDIA__VIDEO_CODEC=vp9\n      - PICTRS__MEDIA__GIF__MAX_WIDTH=256\n      - PICTRS__MEDIA__GIF__MAX_HEIGHT=256\n      - PICTRS__MEDIA__GIF__MAX_AREA=65536\n      - PICTRS__MEDIA__GIF__MAX_FRAME_COUNT=400\n    user: 991:991\n    volumes:\n      - ./volumes/pictrs:/mnt:Z\n    restart: unless-stopped\n    logging: *default-logging\n\n  postgres:\n    image: pgautoupgrade/pgautoupgrade:18-alpine\n    # this needs to match the database host in lemmy.hson\n    # Tune your settings via\n    # https://pgtune.leopard.in.ua/#/\n    # You can use this technique to add them here\n    # https://stackoverflow.com/a/30850095/1655478\n    hostname: postgres\n    command: postgres -c config_file=/etc/postgresql.conf\n    ports:\n      # use a different port so it doesn't conflict with potential postgres db running on the host\n      - \"5433:5432\"\n    environment:\n      - POSTGRES_USER=lemmy\n      - POSTGRES_PASSWORD=password\n      - POSTGRES_DB=lemmy\n    volumes:\n      - ./volumes/postgres:/var/lib/postgresql:Z\n      - ./customPostgresql.conf:/etc/postgresql.conf:Z\n    restart: unless-stopped\n    logging: *default-logging\n"
  },
  {
    "path": "docker/docker_db_backup.sh",
    "content": "#!/usr/bin/env bash\ndocker-compose exec postgres pg_dumpall -c -U lemmy >dump_$(date +%Y-%m-%d\"_\"%H_%M_%S).sql\n"
  },
  {
    "path": "docker/docker_update.sh",
    "content": "#!/bin/sh\nset -e\n\nHelp() {\n  # Display help\n  echo \"Usage: ./docker_update.sh [OPTIONS]\"\n  echo \"\"\n  echo \"Start all docker containers required to run Lemmy.\"\n  echo \"\"\n  echo \"Options:\"\n  echo \"-u Docker username. Only required if managing Docker via Docker Desktop with a personal access token.\"\n  echo \"-h Print this help.\"\n}\n\nwhile getopts \":hu:\" option; do\n  case $option in\n  h)\n    Help\n    exit\n    ;;\n  u)\n    DOCKER_USER=$OPTARG\n    ;;\n  *)\n    echo \"Invalid option $OPTARG.\"\n    exit\n    ;;\n  esac\ndone\n\nLOG_PREFIX=\"[🐀 lemmy]\"\nARCH=$(uname -m 2>/dev/null || echo 'unknown') # uname may not exist on windows machines; default to unknown to be safe.\n\nmkdir -p volumes/pictrs\n\necho \"$LOG_PREFIX Please provide your password to change ownership of the pictrs volume.\"\nsudo chown -R 991:991 volumes/pictrs\n\nif [ \"$ARCH\" = 'arm64' ]; then\n  echo \"$LOG_PREFIX WARN: If building from images, make sure to uncomment 'platform' in the docker-compose.yml file!\"\n\n  # You need a Docker account to pull images. Otherwise, you will get an error like: \"error getting credentials\"\n  if [ -z \"$DOCKER_USER\" ]; then\n    echo \"$LOG_PREFIX Logging into Docker Hub...\"\n    docker login\n  else\n    echo \"$LOG_PREFIX Logging into Docker Hub. Please provide your personal access token.\"\n    docker login --username=\"$DOCKER_USER\"\n  fi\n\n  echo \"$LOG_PREFIX Initializing images in the background. Please be patient if compiling from source...\"\n  docker compose up --build\nelse\n  sudo docker compose up --build\nfi\n\necho \"$LOG_PREFIX Complete! You can now access the UI at http://localhost:1236.\"\n"
  },
  {
    "path": "docker/federation/docker-compose.yml",
    "content": "version: \"3.7\"\n\nx-ui-default: &ui-default\n  init: true\n  image: dessalines/lemmy-ui:0.19.14\n  # assuming lemmy-ui is cloned besides lemmy directory\n  # build:\n  #   context: ../../../lemmy-ui\n  #   dockerfile: dev.dockerfile\n  environment:\n    - LEMMY_UI_HTTPS=false\n\nx-lemmy-default: &lemmy-default\n  build:\n    context: ../..\n    dockerfile: docker/Dockerfile\n  environment:\n    - RUST_BACKTRACE=1\n    - RUST_LOG=\"warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug\"\n  restart: always\n\nx-postgres-default: &postgres-default\n  image: pgautoupgrade/pgautoupgrade:18-alpine\n  environment:\n    - POSTGRES_USER=lemmy\n    - POSTGRES_PASSWORD=password\n    - POSTGRES_DB=lemmy\n  restart: always\n\nservices:\n  nginx:\n    image: nginx:1-alpine\n    ports:\n      - \"8540:8540\"\n      - \"8550:8550\"\n      - \"8560:8560\"\n      - \"8570:8570\"\n      - \"8580:8580\"\n    volumes:\n      - ./nginx.conf:/etc/nginx/nginx.conf:Z\n    restart: always\n    depends_on:\n      - pictrs\n      - lemmy-alpha-ui\n      - lemmy-beta-ui\n      - lemmy-gamma-ui\n      - lemmy-delta-ui\n      - lemmy-epsilon-ui\n\n  pictrs:\n    restart: always\n    image: asonix/pictrs:0.5.17-pre.9\n    user: 991:991\n    volumes:\n      - ./volumes/pictrs_alpha:/mnt:Z\n    environment:\n      - PICTRS__SERVER__API_KEY=my-pictrs-key\n\n  lemmy-alpha-ui:\n    <<: *ui-default\n    environment:\n      - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-alpha:8541\n      - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8541\n    depends_on:\n      - lemmy-alpha\n  lemmy-alpha:\n    <<: *lemmy-default\n    volumes:\n      - ./lemmy_alpha.hjson:/config/config.hjson:Z\n    depends_on:\n      - postgres_alpha\n    ports:\n      - \"8541:8541\"\n  postgres_alpha:\n    <<: *postgres-default\n    volumes:\n      - ./volumes/postgres_alpha:/var/lib/postgresql:Z\n\n  lemmy-beta-ui:\n    <<: *ui-default\n    environment:\n      - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-beta:8551\n      - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8551\n    depends_on:\n      - lemmy-beta\n  lemmy-beta:\n    <<: *lemmy-default\n    volumes:\n      - ./lemmy_beta.hjson:/config/config.hjson:Z\n    depends_on:\n      - postgres_beta\n    ports:\n      - \"8551:8551\"\n  postgres_beta:\n    <<: *postgres-default\n    volumes:\n      - ./volumes/postgres_beta:/var/lib/postgresql:Z\n\n  lemmy-gamma-ui:\n    <<: *ui-default\n    environment:\n      - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-gamma:8561\n      - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8561\n    depends_on:\n      - lemmy-gamma\n  lemmy-gamma:\n    <<: *lemmy-default\n    volumes:\n      - ./lemmy_gamma.hjson:/config/config.hjson:Z\n    depends_on:\n      - postgres_gamma\n    ports:\n      - \"8561:8561\"\n  postgres_gamma:\n    <<: *postgres-default\n    volumes:\n      - ./volumes/postgres_gamma:/var/lib/postgresql:Z\n\n  # An instance with only an allowlist for beta\n  lemmy-delta-ui:\n    <<: *ui-default\n    environment:\n      - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-delta:8571\n      - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8571\n    depends_on:\n      - lemmy-delta\n  lemmy-delta:\n    <<: *lemmy-default\n    volumes:\n      - ./lemmy_delta.hjson:/config/config.hjson:Z\n    depends_on:\n      - postgres_delta\n    ports:\n      - \"8571:8571\"\n  postgres_delta:\n    <<: *postgres-default\n    volumes:\n      - ./volumes/postgres_delta:/var/lib/postgresql:Z\n\n  # An instance who has a blocklist, with lemmy-alpha blocked\n  lemmy-epsilon-ui:\n    <<: *ui-default\n    environment:\n      - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-epsilon:8581\n      - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8581\n    depends_on:\n      - lemmy-epsilon\n  lemmy-epsilon:\n    <<: *lemmy-default\n    volumes:\n      - ./lemmy_epsilon.hjson:/config/config.hjson:Z\n    depends_on:\n      - postgres_epsilon\n    ports:\n      - \"8581:8581\"\n  postgres_epsilon:\n    <<: *postgres-default\n    volumes:\n      - ./volumes/postgres_epsilon:/var/lib/postgresql:Z\n"
  },
  {
    "path": "docker/federation/lemmy_alpha.hjson",
    "content": "{\n  hostname: lemmy-alpha:8541\n  port: 8541\n  tls_enabled: false\n  setup: {\n    admin_username: lemmy_alpha\n    admin_password: lemmylemmy\n    site_name: lemmy-alpha\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres_alpha:5432/lemmy\"\n  }\n  pictrs: {\n    api_key: \"my-pictrs-key\"\n  }\n}\n"
  },
  {
    "path": "docker/federation/lemmy_beta.hjson",
    "content": "{\n  hostname: lemmy-beta:8551\n  port: 8551\n  tls_enabled: false\n  setup: {\n    admin_username: lemmy_beta\n    admin_password: lemmylemmy\n    site_name: lemmy-beta\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres_beta:5432/lemmy\"\n  }\n  pictrs: {\n    api_key: \"my-pictrs-key\"\n  }\n}\n"
  },
  {
    "path": "docker/federation/lemmy_delta.hjson",
    "content": "{\n  hostname: lemmy-delta:8571\n  port: 8571\n  tls_enabled: false\n  setup: {\n    admin_username: lemmy_delta\n    admin_password: lemmylemmy\n    site_name: lemmy-delta\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres_delta:5432/lemmy\"\n  }\n  pictrs: {\n    api_key: \"my-pictrs-key\"\n  }\n}\n"
  },
  {
    "path": "docker/federation/lemmy_epsilon.hjson",
    "content": "{\n  hostname: lemmy-epsilon:8581\n  port: 8581\n  tls_enabled: false\n  setup: {\n    admin_username: lemmy_epsilon\n    admin_password: lemmylemmy\n    site_name: lemmy-epsilon\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres_epsilon:5432/lemmy\"\n  }\n  pictrs: {\n    api_key: \"my-pictrs-key\"\n  }\n  plugins: [{\n    file: \"https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm\"\n      hash: \"37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12\"\n  }]\n}\n"
  },
  {
    "path": "docker/federation/lemmy_gamma.hjson",
    "content": "{\n  hostname: lemmy-gamma:8561\n  port: 8561\n  tls_enabled: false\n  setup: {\n    admin_username: lemmy_gamma\n    admin_password: lemmylemmy\n    site_name: lemmy-gamma\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres_gamma:5432/lemmy\"\n  }\n  pictrs: {\n    api_key: \"my-pictrs-key\"\n  }\n}\n"
  },
  {
    "path": "docker/federation/nginx.conf",
    "content": "events {\n    worker_connections 1024;\n}\n\nhttp {\n    upstream lemmy-alpha {\n        server \"lemmy-alpha:8541\";\n    }\n    upstream lemmy-alpha-ui {\n        server \"lemmy-alpha-ui:1234\";\n    }\n    server {\n        listen 8540;\n        server_name 127.0.0.1;\n        access_log  off;\n\n        # Upload limit for pictshare\n        client_max_body_size 50M;\n\n        location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {\n            proxy_pass http://lemmy-alpha;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n        }\n        location / {\n            set $proxpass http://lemmy-alpha-ui;\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass http://lemmy-alpha;\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass http://lemmy-alpha;\n            }\n            proxy_pass $proxpass;\n\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # Cuts off the trailing slash on URLs to make them valid\n            rewrite ^(.+)/+$ $1 permanent;\n        }\n    }\n\n    upstream lemmy-beta {\n        server \"lemmy-beta:8551\";\n    }\n    upstream lemmy-beta-ui {\n        server \"lemmy-beta-ui:1234\";\n    }\n    server {\n        listen 8550;\n        server_name 127.0.0.1;\n        access_log off;\n\n        # Upload limit for pictshare\n        client_max_body_size 50M;\n\n        location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {\n            proxy_pass http://lemmy-beta;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n        }\n        location / {\n            set $proxpass http://lemmy-beta-ui;\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass http://lemmy-beta;\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass http://lemmy-beta;\n            }\n            proxy_pass $proxpass;\n\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # Cuts off the trailing slash on URLs to make them valid\n            rewrite ^(.+)/+$ $1 permanent;\n        }\n    }\n\n    upstream lemmy-gamma {\n        server \"lemmy-gamma:8561\";\n    }\n    upstream lemmy-gamma-ui {\n        server \"lemmy-gamma-ui:1234\";\n    }\n    server {\n        listen 8560;\n        server_name 127.0.0.1;\n        access_log off;\n\n        # Upload limit for pictshare\n        client_max_body_size 50M;\n\n        location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {\n            proxy_pass http://lemmy-gamma;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n        }\n        location / {\n            set $proxpass http://lemmy-gamma-ui;\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass http://lemmy-gamma;\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass http://lemmy-gamma;\n            }\n            proxy_pass $proxpass;\n\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # Cuts off the trailing slash on URLs to make them valid\n            rewrite ^(.+)/+$ $1 permanent;\n        }\n    }\n\n    upstream lemmy-delta {\n        server \"lemmy-delta:8571\";\n    }\n    upstream lemmy-delta-ui {\n        server \"lemmy-delta-ui:1234\";\n    }\n    server {\n        listen 8570;\n        server_name 127.0.0.1;\n        access_log off;\n\n        # Upload limit for pictshare\n        client_max_body_size 50M;\n\n        location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {\n            proxy_pass http://lemmy-delta;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n        }\n        location / {\n            set $proxpass http://lemmy-delta-ui;\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass http://lemmy-delta;\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass http://lemmy-delta;\n            }\n            proxy_pass $proxpass;\n\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # Cuts off the trailing slash on URLs to make them valid\n            rewrite ^(.+)/+$ $1 permanent;\n        }\n    }\n\n    upstream lemmy-epsilon {\n        server \"lemmy-epsilon:8581\";\n    }\n    upstream lemmy-epsilon-ui {\n        server \"lemmy-epsilon-ui:1234\";\n    }\n    server {\n        listen 8580;\n        server_name 127.0.0.1;\n        access_log off;\n\n        # Upload limit for pictshare\n        client_max_body_size 50M;\n\n        location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {\n            proxy_pass http://lemmy-epsilon;\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n        }\n        location / {\n            set $proxpass http://lemmy-epsilon-ui;\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass http://lemmy-epsilon;\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass http://lemmy-epsilon;\n            }\n            proxy_pass $proxpass;\n\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # Cuts off the trailing slash on URLs to make them valid\n            rewrite ^(.+)/+$ $1 permanent;\n        }\n    }\n}\n"
  },
  {
    "path": "docker/federation/start-local-instances.bash",
    "content": "#!/bin/bash\nset -e\n\nsudo docker compose down\n\nfor Item in alpha beta gamma delta epsilon; do\n  sudo mkdir -p volumes/pictrs_$Item\n  sudo chown -R 991:991 volumes/pictrs_$Item\ndone\n\nsudo docker compose up --build\n"
  },
  {
    "path": "docker/lemmy.hjson",
    "content": "{\n  # for more info about the config, check out the documentation\n  # https://join-lemmy.org/docs/en/administration/configuration.html\n\n  # This is a minimal lemmy config for the dev / main branch. Do not use for a \n  # release / stable version.\n\n  setup: {\n    admin_username: \"lemmy\"\n    admin_password: \"lemmylemmy\"\n    site_name: \"lemmy-dev\"\n  }\n  database: {\n    connection: \"postgres://lemmy:password@postgres:5432/lemmy\"\n  }\n\n  hostname: \"localhost\"\n  bind: \"0.0.0.0\"\n  port: 8536\n\n  pictrs: {\n    url: \"http://pictrs:8080/\"\n    api_key: \"my-pictrs-key\"\n  }\n\n  #opentelemetry_url: \"http://otel:4137\"\n}\n"
  },
  {
    "path": "docker/nginx.conf",
    "content": "worker_processes 1;\nevents {\n    worker_connections 1024;\n}\nhttp {\n    upstream lemmy {\n        # this needs to map to the lemmy (server) docker service hostname\n        server \"lemmy:8536\";\n    }\n    upstream lemmy-ui {\n        # this needs to map to the lemmy-ui docker service hostname\n        server \"lemmy-ui:1234\";\n    }\n\n    server {\n        # this is the port inside docker, not the public one yet\n        listen 1236;\n        listen 8536;\n        # change if needed, this is facing the public web\n        server_name localhost;\n        server_tokens off;\n\n        gzip on;\n        gzip_types text/css application/javascript image/svg+xml;\n        gzip_vary on;\n\n        # Upload limit, relevant for pictrs\n        client_max_body_size 20M;\n\n        add_header X-Frame-Options SAMEORIGIN;\n        add_header X-Content-Type-Options nosniff;\n        add_header X-XSS-Protection \"1; mode=block\";\n\n        # frontend general requests\n        location / {\n            # distinguish between ui requests and backend\n            # don't change lemmy-ui or lemmy here, they refer to the upstream definitions on top\n            set $proxpass \"http://lemmy-ui\";\n\n            if ($http_accept = \"application/activity+json\") {\n              set $proxpass \"http://lemmy\";\n            }\n            if ($http_accept = \"application/ld+json; profile=\\\"https://www.w3.org/ns/activitystreams\\\"\") {\n              set $proxpass \"http://lemmy\";\n            }\n            if ($request_method = POST) {\n              set $proxpass \"http://lemmy\";\n            }\n            proxy_pass $proxpass;\n\n            rewrite ^(.+)/+$ $1 permanent;\n            # Send actual client IP upstream\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        }\n\n        # backend\n        location ~ ^/(api|pictrs|feeds|nodeinfo|version|.well-known) {\n            proxy_pass \"http://lemmy\";\n            # proxy common stuff\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n\n            # Send actual client IP upstream\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header Host $host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        }\n    }\n}\n"
  },
  {
    "path": "docker/test_deploy.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nexport COMPOSE_DOCKER_CLI_BUILD=1\nexport DOCKER_BUILDKIT=1\n\n# Rebuilding dev docker\npushd ..\nsudo docker build . -f docker/Dockerfile --build-arg RUST_RELEASE_MODE=release -t \"dessalines/lemmy:dev\" --platform=linux/amd64 --push\n\n# Run the playbook\n# pushd ../../../lemmy-ansible\n# ansible-playbook -i test playbooks/site.yml\n# popd\n"
  },
  {
    "path": "migrations/00000000000000_diesel_initial_setup/down.sql",
    "content": "-- This file was automatically created by Diesel to setup helper functions\n-- and other internal bookkeeping. This file is safe to edit, any future\n-- changes will be added to existing projects as new migrations.\nDROP FUNCTION IF EXISTS diesel_manage_updated_at (_tbl regclass);\n\nDROP FUNCTION IF EXISTS diesel_set_updated_at ();\n\n"
  },
  {
    "path": "migrations/00000000000000_diesel_initial_setup/up.sql",
    "content": "-- This file was automatically created by Diesel to setup helper functions\n-- and other internal bookkeeping. This file is safe to edit, any future\n-- changes will be added to existing projects as new migrations.\n-- Sets up a trigger for the given table to automatically set a column called\n-- `updated_at` whenever the row is modified (unless `updated_at` was included\n-- in the modified columns)\n--\n-- # Example\n--\n-- ```sql\n-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());\n--\n-- SELECT diesel_manage_updated_at('users');\n-- ```\nCREATE OR REPLACE FUNCTION diesel_manage_updated_at (_tbl regclass)\n    RETURNS VOID\n    AS $$\nBEGIN\n    EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s\n                    FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);\nEND;\n$$\nLANGUAGE plpgsql;\n\nCREATE OR REPLACE FUNCTION diesel_set_updated_at ()\n    RETURNS TRIGGER\n    AS $$\nBEGIN\n    IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) THEN\n        NEW.updated_at := CURRENT_TIMESTAMP;\n    END IF;\n    RETURN NEW;\nEND;\n$$\nLANGUAGE plpgsql;\n\n"
  },
  {
    "path": "migrations/2019-02-26-002946_create_user/down.sql",
    "content": "DROP TABLE user_ban;\n\nDROP TABLE user_;\n\n"
  },
  {
    "path": "migrations/2019-02-26-002946_create_user/up.sql",
    "content": "CREATE TABLE user_ (\n    id serial PRIMARY KEY,\n    name varchar(20) NOT NULL,\n    fedi_name varchar(40) NOT NULL,\n    preferred_username varchar(20),\n    password_encrypted text NOT NULL,\n    email text UNIQUE,\n    icon bytea,\n    admin boolean DEFAULT FALSE NOT NULL,\n    banned boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp,\n    UNIQUE (name, fedi_name)\n);\n\nCREATE TABLE user_ban (\n    id serial PRIMARY KEY,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (user_id)\n);\n\nINSERT INTO user_ (name, fedi_name, password_encrypted)\n    VALUES ('admin', 'TBD', 'TBD');\n\n"
  },
  {
    "path": "migrations/2019-02-27-170003_create_community/down.sql",
    "content": "DROP TABLE site;\n\nDROP TABLE community_user_ban;\n\n;\n\nDROP TABLE community_moderator;\n\nDROP TABLE community_follower;\n\nDROP TABLE community;\n\nDROP TABLE category;\n\n"
  },
  {
    "path": "migrations/2019-02-27-170003_create_community/up.sql",
    "content": "CREATE TABLE category (\n    id serial PRIMARY KEY,\n    name varchar(100) NOT NULL UNIQUE\n);\n\nINSERT INTO category (name)\nVALUES\n    ('Discussion'),\n    ('Humor/Memes'),\n    ('Gaming'),\n    ('Movies'),\n    ('TV'),\n    ('Music'),\n    ('Literature'),\n    ('Comics'),\n    ('Photography'),\n    ('Art'),\n    ('Learning'),\n    ('DIY'),\n    ('Lifestyle'),\n    ('News'),\n    ('Politics'),\n    ('Society'),\n    ('Gender/Identity/Sexuality'),\n    ('Race/Colonisation'),\n    ('Religion'),\n    ('Science/Technology'),\n    ('Programming/Software'),\n    ('Health/Sports/Fitness'),\n    ('Porn'),\n    ('Places'),\n    ('Meta'),\n    ('Other');\n\nCREATE TABLE community (\n    id serial PRIMARY KEY,\n    name varchar(20) NOT NULL UNIQUE,\n    title varchar(100) NOT NULL,\n    description text,\n    category_id int REFERENCES category ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    removed boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\nCREATE TABLE community_moderator (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (community_id, user_id)\n);\n\nCREATE TABLE community_follower (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (community_id, user_id)\n);\n\nCREATE TABLE community_user_ban (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (community_id, user_id)\n);\n\nINSERT INTO community (name, title, category_id, creator_id)\n    VALUES ('main', 'The Default Community', 1, 1);\n\nCREATE TABLE site (\n    id serial PRIMARY KEY,\n    name varchar(20) NOT NULL UNIQUE,\n    description text,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\n"
  },
  {
    "path": "migrations/2019-03-03-163336_create_post/down.sql",
    "content": "DROP TABLE post_read;\n\nDROP TABLE post_saved;\n\nDROP TABLE post_like;\n\nDROP TABLE post;\n\n"
  },
  {
    "path": "migrations/2019-03-03-163336_create_post/up.sql",
    "content": "CREATE TABLE post (\n    id serial PRIMARY KEY,\n    name varchar(100) NOT NULL,\n    url text, -- These are both optional, a post can just have a title\n    body text,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    removed boolean DEFAULT FALSE NOT NULL,\n    locked boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\nCREATE TABLE post_like (\n    id serial PRIMARY KEY,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    score smallint NOT NULL, -- -1, or 1 for dislike, like, no row for no opinion\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (post_id, user_id)\n);\n\nCREATE TABLE post_saved (\n    id serial PRIMARY KEY,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (post_id, user_id)\n);\n\nCREATE TABLE post_read (\n    id serial PRIMARY KEY,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (post_id, user_id)\n);\n\n"
  },
  {
    "path": "migrations/2019-03-05-233828_create_comment/down.sql",
    "content": "DROP TABLE comment_saved;\n\nDROP TABLE comment_like;\n\nDROP TABLE comment;\n\n"
  },
  {
    "path": "migrations/2019-03-05-233828_create_comment/up.sql",
    "content": "CREATE TABLE comment (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    parent_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    content text NOT NULL,\n    removed boolean DEFAULT FALSE NOT NULL,\n    read boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\nCREATE TABLE comment_like (\n    id serial PRIMARY KEY,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    score smallint NOT NULL, -- -1, or 1 for dislike, like, no row for no opinion\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (comment_id, user_id)\n);\n\nCREATE TABLE comment_saved (\n    id serial PRIMARY KEY,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (comment_id, user_id)\n);\n\n"
  },
  {
    "path": "migrations/2019-03-30-212058_create_post_view/down.sql",
    "content": "DROP VIEW post_view;\n\nDROP FUNCTION hot_rank;\n\n"
  },
  {
    "path": "migrations/2019-03-30-212058_create_post_view/up.sql",
    "content": "-- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nBEGIN\n    -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600\n    RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer;\nEND;\n$$\nLANGUAGE plpgsql;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-04-03-155205_create_community_view/down.sql",
    "content": "DROP VIEW community_view;\n\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW site_view;\n\n"
  },
  {
    "path": "migrations/2019-04-03-155205_create_community_view/up.sql",
    "content": "CREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_moderator cm;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_name\nFROM\n    community_follower cf;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_user_ban cm;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2019-04-03-155309_create_comment_view/down.sql",
    "content": "DROP VIEW reply_view;\n\nDROP VIEW comment_view;\n\n"
  },
  {
    "path": "migrations/2019-04-03-155309_create_comment_view/up.sql",
    "content": "CREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        coalesce(sum(cl.score), 0) AS score,\n                        count(\n                            CASE WHEN cl.score = 1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS upvotes,\n                        count(\n                            CASE WHEN cl.score = -1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS downvotes\n                    FROM\n                        comment c\n                    LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                GROUP BY\n                    c.id\n)\n        SELECT\n            ac.*,\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n        (\n            SELECT\n                cs.id::bool\n            FROM\n                comment_saved cs\n            WHERE\n                u.id = cs.user_id\n                AND cs.comment_id = ac.id) AS saved\n    FROM\n        user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2019-04-07-003142_create_moderation_logs/down.sql",
    "content": "DROP TABLE mod_remove_post;\n\nDROP TABLE mod_lock_post;\n\nDROP TABLE mod_remove_comment;\n\nDROP TABLE mod_remove_community;\n\nDROP TABLE mod_ban;\n\nDROP TABLE mod_ban_from_community;\n\nDROP TABLE mod_add;\n\nDROP TABLE mod_add_community;\n\n"
  },
  {
    "path": "migrations/2019-04-07-003142_create_moderation_logs/up.sql",
    "content": "CREATE TABLE mod_remove_post (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    removed boolean DEFAULT TRUE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE mod_lock_post (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    locked boolean DEFAULT TRUE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE mod_remove_comment (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    removed boolean DEFAULT TRUE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE mod_remove_community (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    removed boolean DEFAULT TRUE,\n    expires timestamp,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n-- TODO make sure you can't ban other mods\nCREATE TABLE mod_ban_from_community (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    banned boolean DEFAULT TRUE,\n    expires timestamp,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE mod_ban (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    banned boolean DEFAULT TRUE,\n    expires timestamp,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE mod_add_community (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    removed boolean DEFAULT FALSE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n-- When removed is false that means kicked\nCREATE TABLE mod_add (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    removed boolean DEFAULT FALSE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2019-04-08-015947_create_user_view/down.sql",
    "content": "DROP VIEW user_view;\n\n"
  },
  {
    "path": "migrations/2019-04-08-015947_create_user_view/up.sql",
    "content": "CREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n"
  },
  {
    "path": "migrations/2019-04-11-144915_create_mod_views/down.sql",
    "content": "DROP VIEW mod_remove_post_view;\n\nDROP VIEW mod_lock_post_view;\n\nDROP VIEW mod_remove_comment_view;\n\nDROP VIEW mod_remove_community_view;\n\nDROP VIEW mod_ban_from_community_view;\n\nDROP VIEW mod_ban_view;\n\nDROP VIEW mod_add_community_view;\n\nDROP VIEW mod_add_view;\n\n"
  },
  {
    "path": "migrations/2019-04-11-144915_create_mod_views/up.sql",
    "content": "CREATE VIEW mod_remove_post_view AS\nSELECT\n    mrp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mrp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_remove_post mrp;\n\nCREATE VIEW mod_lock_post_view AS\nSELECT\n    mlp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mlp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mlp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_lock_post mlp;\n\nCREATE VIEW mod_remove_comment_view AS\nSELECT\n    mrc.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrc.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            c.id\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_user_id,\n    (\n        SELECT\n            name\n        FROM\n            user_ u,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND u.id = c.creator_id) AS comment_user_name,\n    (\n        SELECT\n            content\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_content,\n    (\n        SELECT\n            p.id\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_id,\n    (\n        SELECT\n            p.name\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_name,\n    (\n        SELECT\n            co.id\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_id,\n    (\n        SELECT\n            co.name\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_name\nFROM\n    mod_remove_comment mrc;\n\nCREATE VIEW mod_remove_community_view AS\nSELECT\n    mrc.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrc.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            c.name\n        FROM\n            community c\n        WHERE\n            mrc.community_id = c.id) AS community_name\nFROM\n    mod_remove_community mrc;\n\nCREATE VIEW mod_ban_from_community_view AS\nSELECT\n    mb.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mb.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mb.other_user_id = u.id) AS other_user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            mb.community_id = c.id) AS community_name\nFROM\n    mod_ban_from_community mb;\n\nCREATE VIEW mod_ban_view AS\nSELECT\n    mb.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mb.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mb.other_user_id = u.id) AS other_user_name\nFROM\n    mod_ban mb;\n\nCREATE VIEW mod_add_community_view AS\nSELECT\n    ma.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            ma.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            ma.other_user_id = u.id) AS other_user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            ma.community_id = c.id) AS community_name\nFROM\n    mod_add_community ma;\n\nCREATE VIEW mod_add_view AS\nSELECT\n    ma.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            ma.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            ma.other_user_id = u.id) AS other_user_name\nFROM\n    mod_add ma;\n\n"
  },
  {
    "path": "migrations/2019-04-29-175834_add_delete_columns/down.sql",
    "content": "DROP VIEW reply_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW community_view;\n\nDROP VIEW post_view;\n\nALTER TABLE community\n    DROP COLUMN deleted;\n\nALTER TABLE post\n    DROP COLUMN deleted;\n\nALTER TABLE comment\n    DROP COLUMN deleted;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE OR REPLACE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        coalesce(sum(cl.score), 0) AS score,\n                        count(\n                            CASE WHEN cl.score = 1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS upvotes,\n                        count(\n                            CASE WHEN cl.score = -1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS downvotes\n                    FROM\n                        comment c\n                    LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                GROUP BY\n                    c.id\n)\n        SELECT\n            ac.*,\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n        (\n            SELECT\n                cs.id::bool\n            FROM\n                comment_saved cs\n            WHERE\n                u.id = cs.user_id\n                AND cs.comment_id = ac.id) AS saved\n    FROM\n        user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2019-04-29-175834_add_delete_columns/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL;\n\nALTER TABLE post\n    ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL;\n\nALTER TABLE comment\n    ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL;\n\n-- The views\nDROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nDROP VIEW reply_view;\n\nDROP VIEW comment_view;\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        coalesce(sum(cl.score), 0) AS score,\n                        count(\n                            CASE WHEN cl.score = 1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS upvotes,\n                        count(\n                            CASE WHEN cl.score = -1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS downvotes\n                    FROM\n                        comment c\n                    LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                GROUP BY\n                    c.id\n)\n        SELECT\n            ac.*,\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n        (\n            SELECT\n                cs.id::bool\n            FROM\n                comment_saved cs\n            WHERE\n                u.id = cs.user_id\n                AND cs.comment_id = ac.id) AS saved\n    FROM\n        user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2019-05-02-051656_community_view_hot_rank/down.sql",
    "content": "DROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n"
  },
  {
    "path": "migrations/2019-05-02-051656_community_view_hot_rank/up.sql",
    "content": "DROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n"
  },
  {
    "path": "migrations/2019-06-01-222649_remove_admin/down.sql",
    "content": "INSERT INTO user_ (name, fedi_name, password_encrypted)\n    VALUES ('admin', 'TBD', 'TBD');\n\n"
  },
  {
    "path": "migrations/2019-06-01-222649_remove_admin/up.sql",
    "content": "DELETE FROM user_\nWHERE name LIKE 'admin';\n\n"
  },
  {
    "path": "migrations/2019-08-11-000918_add_nsfw_columns/down.sql",
    "content": "DROP VIEW community_view;\n\nDROP VIEW post_view;\n\nALTER TABLE community\n    DROP COLUMN nsfw;\n\nALTER TABLE post\n    DROP COLUMN nsfw;\n\nALTER TABLE user_\n    DROP COLUMN show_nsfw;\n\n-- the views\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- Post view\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-08-11-000918_add_nsfw_columns/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN nsfw boolean DEFAULT FALSE NOT NULL;\n\nALTER TABLE post\n    ADD COLUMN nsfw boolean DEFAULT FALSE NOT NULL;\n\nALTER TABLE user_\n    ADD COLUMN show_nsfw boolean DEFAULT FALSE NOT NULL;\n\n-- The views\nDROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- Post view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-08-29-040006_add_community_count/down.sql",
    "content": "DROP VIEW site_view;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2019-08-29-040006_add_community_count/up.sql",
    "content": "DROP VIEW site_view;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2019-09-05-230317_add_mod_ban_views/down.sql",
    "content": "-- Post view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-09-05-230317_add_mod_ban_views/up.sql",
    "content": "-- Create post view, adding banned_from_community\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-09-09-042010_add_stickied_posts/down.sql",
    "content": "DROP VIEW post_view;\n\nDROP VIEW mod_sticky_post_view;\n\nALTER TABLE post\n    DROP COLUMN stickied;\n\nDROP TABLE mod_sticky_post;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-09-09-042010_add_stickied_posts/up.sql",
    "content": "-- Add the column\nALTER TABLE post\n    ADD COLUMN stickied boolean DEFAULT FALSE NOT NULL;\n\n-- Add the mod table\nCREATE TABLE mod_sticky_post (\n    id serial PRIMARY KEY,\n    mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    stickied boolean DEFAULT TRUE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n-- Add mod view\nCREATE VIEW mod_sticky_post_view AS\nSELECT\n    msp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            msp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            msp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_sticky_post msp;\n\n-- Recreate the view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2019-10-15-181630_add_themes/down.sql",
    "content": "ALTER TABLE user_\n    DROP COLUMN theme;\n\n"
  },
  {
    "path": "migrations/2019-10-15-181630_add_themes/up.sql",
    "content": "ALTER TABLE user_\n    ADD COLUMN theme varchar(20) DEFAULT 'darkly' NOT NULL;\n\n"
  },
  {
    "path": "migrations/2019-10-19-052737_create_user_mention/down.sql",
    "content": "DROP VIEW user_mention_view;\n\nDROP TABLE user_mention;\n\n"
  },
  {
    "path": "migrations/2019-10-19-052737_create_user_mention/up.sql",
    "content": "CREATE TABLE user_mention (\n    id serial PRIMARY KEY,\n    recipient_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (recipient_id, comment_id)\n);\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\n"
  },
  {
    "path": "migrations/2019-10-21-011237_add_default_sorts/down.sql",
    "content": "ALTER TABLE user_\n    DROP COLUMN default_sort_type;\n\nALTER TABLE user_\n    DROP COLUMN default_listing_type;\n\n"
  },
  {
    "path": "migrations/2019-10-21-011237_add_default_sorts/up.sql",
    "content": "ALTER TABLE user_\n    ADD COLUMN default_sort_type smallint DEFAULT 0 NOT NULL;\n\nALTER TABLE user_\n    ADD COLUMN default_listing_type smallint DEFAULT 1 NOT NULL;\n\n"
  },
  {
    "path": "migrations/2019-10-24-002614_create_password_reset_request/down.sql",
    "content": "DROP TABLE password_reset_request;\n\n"
  },
  {
    "path": "migrations/2019-10-24-002614_create_password_reset_request/up.sql",
    "content": "CREATE TABLE password_reset_request (\n    id serial PRIMARY KEY,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    token_encrypted text NOT NULL,\n    published timestamp NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2019-12-09-060754_add_lang/down.sql",
    "content": "ALTER TABLE user_\n    DROP COLUMN lang;\n\n"
  },
  {
    "path": "migrations/2019-12-09-060754_add_lang/up.sql",
    "content": "ALTER TABLE user_\n    ADD COLUMN lang varchar(20) DEFAULT 'browser' NOT NULL;\n\n"
  },
  {
    "path": "migrations/2019-12-11-181820_add_site_fields/down.sql",
    "content": "-- Drop the columns\nDROP VIEW site_view;\n\nALTER TABLE site\n    DROP COLUMN enable_downvotes;\n\nALTER TABLE site\n    DROP COLUMN open_registration;\n\nALTER TABLE site\n    DROP COLUMN enable_nsfw;\n\n-- Rebuild the views\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2019-12-11-181820_add_site_fields/up.sql",
    "content": "-- Add the column\nALTER TABLE site\n    ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL;\n\nALTER TABLE site\n    ADD COLUMN open_registration boolean DEFAULT TRUE NOT NULL;\n\nALTER TABLE site\n    ADD COLUMN enable_nsfw boolean DEFAULT TRUE NOT NULL;\n\n-- Reload the view\nDROP VIEW site_view;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2019-12-29-164820_add_avatar/down.sql",
    "content": "-- the views\nDROP VIEW user_mention_view;\n\nDROP VIEW reply_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_view;\n\n-- user\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n-- post\n-- Recreate the view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- community\nDROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- Reply and comment view\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        coalesce(sum(cl.score), 0) AS score,\n                        count(\n                            CASE WHEN cl.score = 1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS upvotes,\n                        count(\n                            CASE WHEN cl.score = -1 THEN\n                                1\n                            ELSE\n                                NULL\n                            END) AS downvotes\n                    FROM\n                        comment c\n                    LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                GROUP BY\n                    c.id\n)\n        SELECT\n            ac.*,\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n        (\n            SELECT\n                cs.id::bool\n            FROM\n                comment_saved cs\n            WHERE\n                u.id = cs.user_id\n                AND cs.comment_id = ac.id) AS saved\n    FROM\n        user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\n-- community tables\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW site_view;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_moderator cm;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_name\nFROM\n    community_follower cf;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_user_ban cm;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\nALTER TABLE user_ RENAME COLUMN avatar TO icon;\n\nALTER TABLE user_\n    ALTER COLUMN icon TYPE bytea\n    USING icon::bytea;\n\n"
  },
  {
    "path": "migrations/2019-12-29-164820_add_avatar/up.sql",
    "content": "-- Rename to avatar\nALTER TABLE user_ RENAME COLUMN icon TO avatar;\n\nALTER TABLE user_\n    ALTER COLUMN avatar TYPE text;\n\n-- Rebuild nearly all the views, to include the creator avatars\n-- user\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n-- post\n-- Recreate the view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                avatar\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_avatar,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- community\nDROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                avatar\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_avatar,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- reply and comment view\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW comment_view;\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        (\n                            SELECT\n                                avatar\n                            FROM\n                                user_\n                            WHERE\n                                c.creator_id = user_.id) AS creator_avatar,\n                            coalesce(sum(cl.score), 0) AS score,\n                            count(\n                                CASE WHEN cl.score = 1 THEN\n                                    1\n                                ELSE\n                                    NULL\n                                END) AS upvotes,\n                            count(\n                                CASE WHEN cl.score = -1 THEN\n                                    1\n                                ELSE\n                                    NULL\n                                END) AS downvotes\n                        FROM\n                            comment c\n                        LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                    GROUP BY\n                        c.id\n)\n            SELECT\n                ac.*,\n                u.id AS user_id,\n                coalesce(cl.score, 0) AS my_vote,\n            (\n                SELECT\n                    cs.id::bool\n                FROM\n                    comment_saved cs\n                WHERE\n                    u.id = cs.user_id\n                    AND cs.comment_id = ac.id) AS saved\n        FROM\n            user_ u\n        CROSS JOIN all_comment ac\n        LEFT JOIN comment_like cl ON u.id = cl.user_id\n            AND ac.id = cl.comment_id\n        UNION ALL\n        SELECT\n            ac.*,\n            NULL AS user_id,\n            NULL AS my_vote,\n            NULL AS saved\n        FROM\n            all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\n-- community views\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW site_view;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_moderator cm;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_name\nFROM\n    community_follower cf;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_user_ban cm;\n\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\n"
  },
  {
    "path": "migrations/2020-01-01-200418_add_email_to_user_view/down.sql",
    "content": "-- user\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n"
  },
  {
    "path": "migrations/2020-01-01-200418_add_email_to_user_view/up.sql",
    "content": "-- user\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    email,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n"
  },
  {
    "path": "migrations/2020-01-02-172755_add_show_avatar_and_email_notifications_to_user/down.sql",
    "content": "-- Drop the columns\nDROP VIEW user_view;\n\nALTER TABLE user_\n    DROP COLUMN show_avatars;\n\nALTER TABLE user_\n    DROP COLUMN send_notifications_to_email;\n\n-- Rebuild the view\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    email,\n    fedi_name,\n    admin,\n    banned,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n"
  },
  {
    "path": "migrations/2020-01-02-172755_add_show_avatar_and_email_notifications_to_user/up.sql",
    "content": "-- Add columns\nALTER TABLE user_\n    ADD COLUMN show_avatars boolean DEFAULT TRUE NOT NULL;\n\nALTER TABLE user_\n    ADD COLUMN send_notifications_to_email boolean DEFAULT FALSE NOT NULL;\n\n-- Rebuild the user_view\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    email,\n    fedi_name,\n    admin,\n    banned,\n    show_avatars,\n    send_notifications_to_email,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n"
  },
  {
    "path": "migrations/2020-01-11-012452_add_indexes/down.sql",
    "content": "DROP INDEX idx_post_creator;\n\nDROP INDEX idx_post_community;\n\nDROP INDEX idx_post_like_post;\n\nDROP INDEX idx_post_like_user;\n\nDROP INDEX idx_comment_creator;\n\nDROP INDEX idx_comment_parent;\n\nDROP INDEX idx_comment_post;\n\nDROP INDEX idx_comment_like_comment;\n\nDROP INDEX idx_comment_like_user;\n\nDROP INDEX idx_comment_like_post;\n\nDROP INDEX idx_community_creator;\n\nDROP INDEX idx_community_category;\n\n"
  },
  {
    "path": "migrations/2020-01-11-012452_add_indexes/up.sql",
    "content": "-- Go through all the tables joins, optimize every view, CTE, etc.\nCREATE INDEX idx_post_creator ON post (creator_id);\n\nCREATE INDEX idx_post_community ON post (community_id);\n\nCREATE INDEX idx_post_like_post ON post_like (post_id);\n\nCREATE INDEX idx_post_like_user ON post_like (user_id);\n\nCREATE INDEX idx_comment_creator ON comment (creator_id);\n\nCREATE INDEX idx_comment_parent ON comment (parent_id);\n\nCREATE INDEX idx_comment_post ON comment (post_id);\n\nCREATE INDEX idx_comment_like_comment ON comment_like (comment_id);\n\nCREATE INDEX idx_comment_like_user ON comment_like (user_id);\n\nCREATE INDEX idx_comment_like_post ON comment_like (post_id);\n\nCREATE INDEX idx_community_creator ON community (creator_id);\n\nCREATE INDEX idx_community_category ON community (category_id);\n\n"
  },
  {
    "path": "migrations/2020-01-13-025151_create_materialized_views/down.sql",
    "content": "-- functions and triggers\nDROP TRIGGER refresh_user ON user_;\n\nDROP FUNCTION refresh_user ();\n\nDROP TRIGGER refresh_post ON post;\n\nDROP FUNCTION refresh_post ();\n\nDROP TRIGGER refresh_post_like ON post_like;\n\nDROP FUNCTION refresh_post_like ();\n\nDROP TRIGGER refresh_community ON community;\n\nDROP FUNCTION refresh_community ();\n\nDROP TRIGGER refresh_community_follower ON community_follower;\n\nDROP FUNCTION refresh_community_follower ();\n\nDROP TRIGGER refresh_community_user_ban ON community_user_ban;\n\nDROP FUNCTION refresh_community_user_ban ();\n\nDROP TRIGGER refresh_comment ON comment;\n\nDROP FUNCTION refresh_comment ();\n\nDROP TRIGGER refresh_comment_like ON comment_like;\n\nDROP FUNCTION refresh_comment_like ();\n\n-- post\n-- Recreate the view\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        p.*,\n        (\n            SELECT\n                u.banned\n            FROM\n                user_ u\n            WHERE\n                p.creator_id = u.id) AS banned,\n        (\n            SELECT\n                cb.id::bool\n            FROM\n                community_user_ban cb\n            WHERE\n                p.creator_id = cb.user_id\n                AND p.community_id = cb.community_id) AS banned_from_community,\n        (\n            SELECT\n                name\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_name,\n        (\n            SELECT\n                avatar\n            FROM\n                user_\n            WHERE\n                p.creator_id = user_.id) AS creator_avatar,\n        (\n            SELECT\n                name\n            FROM\n                community\n            WHERE\n                p.community_id = community.id) AS community_name,\n        (\n            SELECT\n                removed\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_removed,\n        (\n            SELECT\n                deleted\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_deleted,\n        (\n            SELECT\n                nsfw\n            FROM\n                community c\n            WHERE\n                p.community_id = c.id) AS community_nsfw,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment\n            WHERE\n                comment.post_id = p.id) AS number_of_comments,\n        coalesce(sum(pl.score), 0) AS score,\n        count(\n            CASE WHEN pl.score = 1 THEN\n                1\n            ELSE\n                NULL\n            END) AS upvotes,\n        count(\n            CASE WHEN pl.score = -1 THEN\n                1\n            ELSE\n                NULL\n            END) AS downvotes,\n        hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\n    FROM\n        post p\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        p.id\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n(\n    SELECT\n        cf.id::bool\n    FROM\n        community_follower cf\n    WHERE\n        u.id = cf.user_id\n        AND cf.community_id = ap.community_id) AS subscribed,\n(\n    SELECT\n        pr.id::bool\n    FROM\n        post_read pr\n    WHERE\n        u.id = pr.user_id\n        AND pr.post_id = ap.id) AS read,\n(\n    SELECT\n        ps.id::bool\n    FROM\n        post_saved ps\n    WHERE\n        u.id = ps.user_id\n        AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- user\nDROP MATERIALIZED VIEW user_mview;\n\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    id,\n    name,\n    avatar,\n    email,\n    fedi_name,\n    admin,\n    banned,\n    show_avatars,\n    send_notifications_to_email,\n    published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\n-- community\nDROP VIEW community_mview;\n\nDROP MATERIALIZED VIEW community_aggregates_mview;\n\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        *,\n        (\n            SELECT\n                name\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_name,\n        (\n            SELECT\n                avatar\n            FROM\n                user_ u\n            WHERE\n                c.creator_id = u.id) AS creator_avatar,\n        (\n            SELECT\n                name\n            FROM\n                category ct\n            WHERE\n                c.category_id = ct.id) AS category_name,\n        (\n            SELECT\n                count(*)\n            FROM\n                community_follower cf\n            WHERE\n                cf.community_id = c.id) AS number_of_subscribers,\n        (\n            SELECT\n                count(*)\n            FROM\n                post p\n            WHERE\n                p.community_id = c.id) AS number_of_posts,\n        (\n            SELECT\n                count(*)\n            FROM\n                comment co,\n                post p\n            WHERE\n                c.id = p.community_id\n                AND p.id = co.post_id) AS number_of_comments,\n        hot_rank ((\n            SELECT\n                count(*)\n            FROM community_follower cf\n            WHERE\n                cf.community_id = c.id), c.published) AS hot_rank\n    FROM\n        community c\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- reply and comment view\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        c.*,\n        (\n            SELECT\n                community_id\n            FROM\n                post p\n            WHERE\n                p.id = c.post_id),\n            (\n                SELECT\n                    u.banned\n                FROM\n                    user_ u\n                WHERE\n                    c.creator_id = u.id) AS banned,\n                (\n                    SELECT\n                        cb.id::bool\n                    FROM\n                        community_user_ban cb,\n                        post p\n                    WHERE\n                        c.creator_id = cb.user_id\n                        AND p.id = c.post_id\n                        AND p.community_id = cb.community_id) AS banned_from_community,\n                    (\n                        SELECT\n                            name\n                        FROM\n                            user_\n                        WHERE\n                            c.creator_id = user_.id) AS creator_name,\n                        (\n                            SELECT\n                                avatar\n                            FROM\n                                user_\n                            WHERE\n                                c.creator_id = user_.id) AS creator_avatar,\n                            coalesce(sum(cl.score), 0) AS score,\n                            count(\n                                CASE WHEN cl.score = 1 THEN\n                                    1\n                                ELSE\n                                    NULL\n                                END) AS upvotes,\n                            count(\n                                CASE WHEN cl.score = -1 THEN\n                                    1\n                                ELSE\n                                    NULL\n                                END) AS downvotes\n                        FROM\n                            comment c\n                        LEFT JOIN comment_like cl ON c.id = cl.comment_id\n                    GROUP BY\n                        c.id\n)\n            SELECT\n                ac.*,\n                u.id AS user_id,\n                coalesce(cl.score, 0) AS my_vote,\n            (\n                SELECT\n                    cs.id::bool\n                FROM\n                    comment_saved cs\n                WHERE\n                    u.id = cs.user_id\n                    AND cs.comment_id = ac.id) AS saved\n        FROM\n            user_ u\n        CROSS JOIN all_comment ac\n        LEFT JOIN comment_like cl ON u.id = cl.user_id\n            AND ac.id = cl.comment_id\n        UNION ALL\n        SELECT\n            ac.*,\n            NULL AS user_id,\n            NULL AS my_vote,\n            NULL AS saved\n        FROM\n            all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\n"
  },
  {
    "path": "migrations/2020-01-13-025151_create_materialized_views/up.sql",
    "content": "-- post\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\nGROUP BY\n    p.id;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nDROP VIEW post_view;\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- user_view\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.fedi_name,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- community\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            category ct\n        WHERE\n            c.category_id = ct.id) AS category_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            community_follower cf\n        WHERE\n            cf.community_id = c.id) AS number_of_subscribers,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.community_id = c.id) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment co,\n            post p\n        WHERE\n            c.id = p.community_id\n            AND p.id = co.post_id) AS number_of_comments,\n    hot_rank ((\n        SELECT\n            count(*)\n        FROM community_follower cf\n        WHERE\n            cf.community_id = c.id), c.published) AS hot_rank\nFROM\n    community c;\n\nCREATE MATERIALIZED VIEW community_aggregates_mview AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nCREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id);\n\nDROP VIEW community_view;\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE VIEW community_mview AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW comment_view;\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\n-- user\nCREATE OR REPLACE FUNCTION refresh_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview;\n    -- cause of bans\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_user\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON user_\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_user ();\n\n-- post\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_post\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_post ();\n\n-- post_like\nCREATE OR REPLACE FUNCTION refresh_post_like ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_post_like\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post_like\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_post_like ();\n\n-- community\nCREATE OR REPLACE FUNCTION refresh_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_community\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_community ();\n\n-- community_follower\nCREATE OR REPLACE FUNCTION refresh_community_follower ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_community_follower\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community_follower\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_community_follower ();\n\n-- community_user_ban\nCREATE OR REPLACE FUNCTION refresh_community_user_ban ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_community_user_ban\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community_user_ban\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_community_user_ban ();\n\n-- comment\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_comment\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON comment\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_comment ();\n\n-- comment_like\nCREATE OR REPLACE FUNCTION refresh_comment_like ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_comment_like\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON comment_like\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_comment_like ();\n\n"
  },
  {
    "path": "migrations/2020-01-21-001001_create_private_message/down.sql",
    "content": "-- Drop the triggers\nDROP TRIGGER refresh_private_message ON private_message;\n\nDROP FUNCTION refresh_private_message ();\n\n-- Drop the view and table\nDROP VIEW private_message_view CASCADE;\n\nDROP TABLE private_message;\n\n-- Rebuild the old views\nDROP VIEW user_view CASCADE;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.fedi_name,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- Drop the columns\nALTER TABLE user_\n    DROP COLUMN matrix_user_id;\n\n"
  },
  {
    "path": "migrations/2020-01-21-001001_create_private_message/up.sql",
    "content": "-- Creating private message\nCREATE TABLE private_message (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    recipient_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    content text NOT NULL,\n    deleted boolean DEFAULT FALSE NOT NULL,\n    read boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\n-- Create the view and materialized view which has the avatar and creator name\nCREATE VIEW private_message_view AS\nSELECT\n    pm.*,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    u2.name AS recipient_name,\n    u2.avatar AS recipient_avatar\nFROM\n    private_message pm\n    INNER JOIN user_ u ON u.id = pm.creator_id\n    INNER JOIN user_ u2 ON u2.id = pm.recipient_id;\n\nCREATE MATERIALIZED VIEW private_message_mview AS\nSELECT\n    *\nFROM\n    private_message_view;\n\nCREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id);\n\n-- Create the triggers\nCREATE OR REPLACE FUNCTION refresh_private_message ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY private_message_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_private_message\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON private_message\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_private_message ();\n\n-- Update user to include matrix id\nALTER TABLE user_\n    ADD COLUMN matrix_user_id text UNIQUE;\n\nDROP VIEW user_view CASCADE;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.fedi_name,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- This is what a group pm table would look like\n-- Not going to do it now because of the complications\n--\n-- create table private_message (\n--   id serial primary key,\n--   creator_id int references user_ on update cascade on delete cascade not null,\n--   content text not null,\n--   deleted boolean default false not null,\n--   published timestamp not null default now(),\n--   updated timestamp\n-- );\n--\n-- create table private_message_recipient (\n--   id serial primary key,\n--   private_message_id int references private_message on update cascade on delete cascade not null,\n--   recipient_id int references user_ on update cascade on delete cascade not null,\n--   read boolean default false not null,\n--   published timestamp not null default now(),\n--   unique(private_message_id, recipient_id)\n-- )\n"
  },
  {
    "path": "migrations/2020-01-29-011901_create_reply_materialized_view/down.sql",
    "content": "-- Drop the materialized / built views\nDROP VIEW reply_view;\n\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2020-01-29-011901_create_reply_materialized_view/up.sql",
    "content": "-- https://github.com/dessalines/lemmy/issues/197\nDROP VIEW reply_view;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2020-01-29-030825_create_user_mention_materialized_view/down.sql",
    "content": "DROP VIEW user_mention_mview;\n\n"
  },
  {
    "path": "migrations/2020-01-29-030825_create_user_mention_materialized_view/up.sql",
    "content": "CREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-02-02-004806_add_case_insensitive_usernames/down.sql",
    "content": "DROP INDEX idx_user_name_lower;\n\nDROP INDEX idx_user_email_lower;\n\n"
  },
  {
    "path": "migrations/2020-02-02-004806_add_case_insensitive_usernames/up.sql",
    "content": "-- Add case insensitive username and email uniqueness\n-- An example of showing the dupes:\n-- select\n--   max(id) as id,\n--   lower(name) as lname,\n--   count(*)\n-- from user_\n-- group by lower(name)\n-- having count(*) > 1;\n-- Delete username dupes, keeping the first one\nDELETE FROM user_\nWHERE id NOT IN (\n        SELECT\n            min(id)\n        FROM\n            user_\n        GROUP BY\n            lower(name),\n            lower(fedi_name));\n\n-- The user index\nCREATE UNIQUE INDEX idx_user_name_lower ON user_ (lower(name));\n\n-- Email lower\nCREATE UNIQUE INDEX idx_user_email_lower ON user_ (lower(email));\n\n-- Set empty emails properly to null\nUPDATE\n    user_\nSET\n    email = NULL\nWHERE\n    email = '';\n\n"
  },
  {
    "path": "migrations/2020-02-06-165953_change_post_title_length/down.sql",
    "content": "-- Drop the dependent views\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\nDROP VIEW mod_remove_post_view;\n\nDROP VIEW mod_sticky_post_view;\n\nDROP VIEW mod_lock_post_view;\n\nDROP VIEW mod_remove_comment_view;\n\nALTER TABLE post\n    ALTER COLUMN name TYPE varchar(100);\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\nGROUP BY\n    p.id;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- The mod views\nCREATE VIEW mod_remove_post_view AS\nSELECT\n    mrp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mrp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_remove_post mrp;\n\nCREATE VIEW mod_lock_post_view AS\nSELECT\n    mlp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mlp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mlp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_lock_post mlp;\n\nCREATE VIEW mod_remove_comment_view AS\nSELECT\n    mrc.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrc.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            c.id\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_user_id,\n    (\n        SELECT\n            name\n        FROM\n            user_ u,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND u.id = c.creator_id) AS comment_user_name,\n    (\n        SELECT\n            content\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_content,\n    (\n        SELECT\n            p.id\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_id,\n    (\n        SELECT\n            p.name\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_name,\n    (\n        SELECT\n            co.id\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_id,\n    (\n        SELECT\n            co.name\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_name\nFROM\n    mod_remove_comment mrc;\n\nCREATE VIEW mod_sticky_post_view AS\nSELECT\n    msp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            msp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            msp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_sticky_post msp;\n\n"
  },
  {
    "path": "migrations/2020-02-06-165953_change_post_title_length/up.sql",
    "content": "-- Drop the dependent views\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\nDROP VIEW mod_remove_post_view;\n\nDROP VIEW mod_sticky_post_view;\n\nDROP VIEW mod_lock_post_view;\n\nDROP VIEW mod_remove_comment_view;\n\n-- Add the extra post limit\nALTER TABLE post\n    ALTER COLUMN name TYPE varchar(200);\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\nGROUP BY\n    p.id;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- The mod views\nCREATE VIEW mod_remove_post_view AS\nSELECT\n    mrp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mrp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mrp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_remove_post mrp;\n\nCREATE VIEW mod_lock_post_view AS\nSELECT\n    mlp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mlp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            mlp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            mlp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_lock_post mlp;\n\nCREATE VIEW mod_remove_comment_view AS\nSELECT\n    mrc.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            mrc.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            c.id\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_user_id,\n    (\n        SELECT\n            name\n        FROM\n            user_ u,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND u.id = c.creator_id) AS comment_user_name,\n    (\n        SELECT\n            content\n        FROM\n            comment c\n        WHERE\n            mrc.comment_id = c.id) AS comment_content,\n    (\n        SELECT\n            p.id\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_id,\n    (\n        SELECT\n            p.name\n        FROM\n            post p,\n            comment c\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id) AS post_name,\n    (\n        SELECT\n            co.id\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_id,\n    (\n        SELECT\n            co.name\n        FROM\n            comment c,\n            post p,\n            community co\n        WHERE\n            mrc.comment_id = c.id\n            AND c.post_id = p.id\n            AND p.community_id = co.id) AS community_name\nFROM\n    mod_remove_comment mrc;\n\nCREATE VIEW mod_sticky_post_view AS\nSELECT\n    msp.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            msp.mod_user_id = u.id) AS mod_user_name,\n    (\n        SELECT\n            name\n        FROM\n            post p\n        WHERE\n            msp.post_id = p.id) AS post_name,\n    (\n        SELECT\n            c.id\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_id,\n    (\n        SELECT\n            c.name\n        FROM\n            post p,\n            community c\n        WHERE\n            msp.post_id = p.id\n            AND p.community_id = c.id) AS community_name\nFROM\n    mod_sticky_post msp;\n\n"
  },
  {
    "path": "migrations/2020-02-07-210055_add_comment_subscribed/down.sql",
    "content": "DROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW user_mention_mview;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP VIEW comment_aggregates_view;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-02-07-210055_add_comment_subscribed/up.sql",
    "content": "-- Adding community name, hot_rank, to comment_view, user_mention_view, and subscribed to comment_view\n-- Rebuild the comment view\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW user_mention_mview;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP VIEW comment_aggregates_view;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            co.name\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_name,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-02-08-145624_add_post_newest_activity_time/down.sql",
    "content": "DROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\nGROUP BY\n    p.id;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2020-02-08-145624_add_post_newest_activity_time/up.sql",
    "content": "-- Adds a newest_activity_time for the post_views, in order to sort by newest comment\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2020-03-06-202329_add_post_iframely_data/down.sql",
    "content": "-- Adds a newest_activity_time for the post_views, in order to sort by newest comment\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- Drop the columns\nALTER TABLE post\n    DROP COLUMN embed_title;\n\nALTER TABLE post\n    DROP COLUMN embed_description;\n\nALTER TABLE post\n    DROP COLUMN embed_html;\n\nALTER TABLE post\n    DROP COLUMN thumbnail_url;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2020-03-06-202329_add_post_iframely_data/up.sql",
    "content": "-- Add the columns\nALTER TABLE post\n    ADD COLUMN embed_title text;\n\nALTER TABLE post\n    ADD COLUMN embed_description text;\n\nALTER TABLE post\n    ADD COLUMN embed_html text;\n\nALTER TABLE post\n    ADD COLUMN thumbnail_url text;\n\n-- Regenerate the views\n-- Adds a newest_activity_time for the post_views, in order to sort by newest comment\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n"
  },
  {
    "path": "migrations/2020-03-26-192410_add_activitypub_tables/down.sql",
    "content": "DROP TABLE activity;\n\nALTER TABLE user_\n    DROP COLUMN actor_id,\n    DROP COLUMN private_key,\n    DROP COLUMN public_key,\n    DROP COLUMN bio,\n    DROP COLUMN local,\n    DROP COLUMN last_refreshed_at;\n\nALTER TABLE community\n    DROP COLUMN actor_id,\n    DROP COLUMN private_key,\n    DROP COLUMN public_key,\n    DROP COLUMN local,\n    DROP COLUMN last_refreshed_at;\n\n"
  },
  {
    "path": "migrations/2020-03-26-192410_add_activitypub_tables/up.sql",
    "content": "-- The Activitypub activity table\n-- All user actions must create a row here.\nCREATE TABLE activity (\n    id serial PRIMARY KEY,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- Ensures that the user is set up here.\n    data jsonb NOT NULL,\n    local boolean NOT NULL DEFAULT TRUE,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp\n);\n\n-- Making sure that id is unique\nCREATE UNIQUE INDEX idx_activity_unique_apid ON activity ((data ->> 'id'::text));\n\n-- Add federation columns to the two actor tables\nALTER TABLE user_\n-- TODO uniqueness constraints should be added on these 3 columns later\n    ADD COLUMN actor_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local\n    ADD COLUMN bio text, -- not on community, already has description\n    ADD COLUMN local boolean NOT NULL DEFAULT TRUE,\n    ADD COLUMN private_key text, -- These need to be generated from code\n    ADD COLUMN public_key text,\n    ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now() -- Used to re-fetch federated actor periodically\n;\n\n-- Community\nALTER TABLE community\n    ADD COLUMN actor_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local\n    ADD COLUMN local boolean NOT NULL DEFAULT TRUE,\n    ADD COLUMN private_key text, -- These need to be generated from code\n    ADD COLUMN public_key text,\n    ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now() -- Used to re-fetch federated actor periodically\n;\n\n-- Don't worry about rebuilding the views right now.\n"
  },
  {
    "path": "migrations/2020-04-03-194936_add_activitypub_for_posts_and_comments/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN ap_id,\n    DROP COLUMN local;\n\nALTER TABLE comment\n    DROP COLUMN ap_id,\n    DROP COLUMN local;\n\n"
  },
  {
    "path": "migrations/2020-04-03-194936_add_activitypub_for_posts_and_comments/up.sql",
    "content": "-- Add federation columns to post, comment\nALTER TABLE post\n-- TODO uniqueness constraints should be added on these 3 columns later\n    ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local\n    ADD COLUMN local boolean NOT NULL DEFAULT TRUE;\n\nALTER TABLE comment\n-- TODO uniqueness constraints should be added on these 3 columns later\n    ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local\n    ADD COLUMN local boolean NOT NULL DEFAULT TRUE;\n\n"
  },
  {
    "path": "migrations/2020-04-07-135912_add_user_community_apub_constraints/down.sql",
    "content": "-- User table\nDROP VIEW user_view CASCADE;\n\nALTER TABLE user_\n    ADD COLUMN fedi_name varchar(40) NOT NULL DEFAULT 'http://fake.com';\n\nALTER TABLE user_\n-- Default is only for existing rows\n    ALTER COLUMN fedi_name DROP DEFAULT,\n    ADD CONSTRAINT user__name_fedi_name_key UNIQUE (name, fedi_name);\n\n-- Community\nALTER TABLE community\n    ADD CONSTRAINT community_name_key UNIQUE (name);\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.fedi_name,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n"
  },
  {
    "path": "migrations/2020-04-07-135912_add_user_community_apub_constraints/up.sql",
    "content": "-- User table\n-- Need to regenerate user_view, user_mview\nDROP VIEW user_view CASCADE;\n\n-- Remove the fedi_name constraint, drop that useless column\nALTER TABLE user_\n    DROP CONSTRAINT user__name_fedi_name_key;\n\nALTER TABLE user_\n    DROP COLUMN fedi_name;\n\n-- Community\nALTER TABLE community\n    DROP CONSTRAINT community_name_key;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n"
  },
  {
    "path": "migrations/2020-04-14-163701_update_views_for_activitypub/down.sql",
    "content": "-- user_view\nDROP VIEW user_view CASCADE;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- community_view\nDROP VIEW community_aggregates_view CASCADE;\n\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            category ct\n        WHERE\n            c.category_id = ct.id) AS category_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            community_follower cf\n        WHERE\n            cf.community_id = c.id) AS number_of_subscribers,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.community_id = c.id) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment co,\n            post p\n        WHERE\n            c.id = p.community_id\n            AND p.id = co.post_id) AS number_of_comments,\n    hot_rank ((\n        SELECT\n            count(*)\n        FROM community_follower cf\n        WHERE\n            cf.community_id = c.id), c.published) AS hot_rank\nFROM\n    community c;\n\nCREATE MATERIALIZED VIEW community_aggregates_mview AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nCREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id);\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE VIEW community_mview AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- community views\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_moderator cm;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_name\nFROM\n    community_follower cf;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_user_ban cm;\n\n-- post_view\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- reply_view, comment_view, user_mention\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW user_mention_mview;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP VIEW comment_aggregates_view;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            co.name\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_name,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-04-14-163701_update_views_for_activitypub/up.sql",
    "content": "-- user_view\nDROP VIEW user_view CASCADE;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.actor_id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.bio,\n    u.local,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- community_view\nDROP VIEW community_aggregates_view CASCADE;\n\nCREATE VIEW community_aggregates_view AS\n-- Now that there's public and private keys, you have to be explicit here\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            category ct\n        WHERE\n            c.category_id = ct.id) AS category_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            community_follower cf\n        WHERE\n            cf.community_id = c.id) AS number_of_subscribers,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.community_id = c.id) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment co,\n            post p\n        WHERE\n            c.id = p.community_id\n            AND p.id = co.post_id) AS number_of_comments,\n    hot_rank ((\n        SELECT\n            count(*)\n        FROM community_follower cf\n        WHERE\n            cf.community_id = c.id), c.published) AS hot_rank\nFROM\n    community c;\n\nCREATE MATERIALIZED VIEW community_aggregates_mview AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nCREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id);\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE VIEW community_mview AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- community views\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    *,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_local,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            actor_id\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_local,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_moderator cm;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    *,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_local,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cf.user_id = u.id), (\n        SELECT\n            actor_id\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_local,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cf.community_id = c.id) AS community_name\nFROM\n    community_follower cf;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    *,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_local,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id) AS user_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            cm.user_id = u.id), (\n        SELECT\n            actor_id\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_local,\n    (\n        SELECT\n            name\n        FROM\n            community c\n        WHERE\n            cm.community_id = c.id) AS community_name\nFROM\n    community_user_ban cm;\n\n-- post_view\nDROP VIEW post_view;\n\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            actor_id\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_local,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\n-- reply_view, comment_view, user_mention\nDROP VIEW reply_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW user_mention_mview;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP VIEW comment_aggregates_view;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            co.actor_id\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_actor_id,\n    (\n        SELECT\n            co.local\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_local,\n    (\n        SELECT\n            co.name\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_name,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-04-21-123957_remove_unique_user_constraints/down.sql",
    "content": "-- The username index\nDROP INDEX idx_user_name_lower_actor_id;\n\nCREATE UNIQUE INDEX idx_user_name_lower ON user_ (lower(name));\n\n"
  },
  {
    "path": "migrations/2020-04-21-123957_remove_unique_user_constraints/up.sql",
    "content": "DROP INDEX idx_user_name_lower;\n\nCREATE UNIQUE INDEX idx_user_name_lower_actor_id ON user_ (lower(name), lower(actor_id));\n\n"
  },
  {
    "path": "migrations/2020-05-05-210233_add_activitypub_for_private_messages/down.sql",
    "content": "DROP MATERIALIZED VIEW private_message_mview;\n\nDROP VIEW private_message_view;\n\nALTER TABLE private_message\n    DROP COLUMN ap_id,\n    DROP COLUMN local;\n\nCREATE VIEW private_message_view AS\nSELECT\n    pm.*,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    u2.name AS recipient_name,\n    u2.avatar AS recipient_avatar\nFROM\n    private_message pm\n    INNER JOIN user_ u ON u.id = pm.creator_id\n    INNER JOIN user_ u2 ON u2.id = pm.recipient_id;\n\nCREATE MATERIALIZED VIEW private_message_mview AS\nSELECT\n    *\nFROM\n    private_message_view;\n\nCREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id);\n\n"
  },
  {
    "path": "migrations/2020-05-05-210233_add_activitypub_for_private_messages/up.sql",
    "content": "ALTER TABLE private_message\n    ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local\n    ADD COLUMN local boolean NOT NULL DEFAULT TRUE;\n\nDROP MATERIALIZED VIEW private_message_mview;\n\nDROP VIEW private_message_view;\n\nCREATE VIEW private_message_view AS\nSELECT\n    pm.*,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u2.name AS recipient_name,\n    u2.avatar AS recipient_avatar,\n    u2.actor_id AS recipient_actor_id,\n    u2.local AS recipient_local\nFROM\n    private_message pm\n    INNER JOIN user_ u ON u.id = pm.creator_id\n    INNER JOIN user_ u2 ON u2.id = pm.recipient_id;\n\nCREATE MATERIALIZED VIEW private_message_mview AS\nSELECT\n    *\nFROM\n    private_message_view;\n\nCREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id);\n\n"
  },
  {
    "path": "migrations/2020-06-30-135809_remove_mat_views/down.sql",
    "content": "-- Dropping all the fast tables\nDROP TABLE user_fast;\n\nDROP VIEW post_fast_view;\n\nDROP TABLE post_aggregates_fast;\n\nDROP VIEW community_fast_view;\n\nDROP TABLE community_aggregates_fast;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\n-- Re-adding all the triggers, functions, and mviews\n-- private message\nCREATE MATERIALIZED VIEW private_message_mview AS\nSELECT\n    *\nFROM\n    private_message_view;\n\nCREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id);\n\n-- Create the triggers\nCREATE OR REPLACE FUNCTION refresh_private_message ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY private_message_mview;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER refresh_private_message\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON private_message\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_private_message ();\n\n-- user\nCREATE OR REPLACE FUNCTION refresh_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview;\n    -- cause of bans\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    RETURN NULL;\nEND\n$$;\n\nDROP TRIGGER refresh_user ON user_;\n\nCREATE TRIGGER refresh_user\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON user_\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_user ();\n\nDROP VIEW user_view CASCADE;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.actor_id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.bio,\n    u.local,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.creator_id = u.id) AS number_of_posts,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            post p,\n            post_like pl\n        WHERE\n            u.id = p.creator_id\n            AND p.id = pl.post_id) AS post_score,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment c\n        WHERE\n            c.creator_id = u.id) AS number_of_comments,\n    (\n        SELECT\n            coalesce(sum(score), 0)\n        FROM\n            comment c,\n            comment_like cl\n        WHERE\n            u.id = c.creator_id\n            AND c.id = cl.comment_id) AS comment_score\nFROM\n    user_ u;\n\nCREATE MATERIALIZED VIEW user_mview AS\nSELECT\n    *\nFROM\n    user_view;\n\nCREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id);\n\n-- community\nDROP TRIGGER refresh_community ON community;\n\nCREATE TRIGGER refresh_community\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_community ();\n\nCREATE OR REPLACE FUNCTION refresh_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\nDROP VIEW community_aggregates_view CASCADE;\n\nCREATE VIEW community_aggregates_view AS\n-- Now that there's public and private keys, you have to be explicit here\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            name\n        FROM\n            category ct\n        WHERE\n            c.category_id = ct.id) AS category_name,\n    (\n        SELECT\n            count(*)\n        FROM\n            community_follower cf\n        WHERE\n            cf.community_id = c.id) AS number_of_subscribers,\n    (\n        SELECT\n            count(*)\n        FROM\n            post p\n        WHERE\n            p.community_id = c.id) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment co,\n            post p\n        WHERE\n            c.id = p.community_id\n            AND p.id = co.post_id) AS number_of_comments,\n    hot_rank ((\n        SELECT\n            count(*)\n        FROM community_follower cf\n        WHERE\n            cf.community_id = c.id), c.published) AS hot_rank\nFROM\n    community c;\n\nCREATE MATERIALIZED VIEW community_aggregates_mview AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nCREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id);\n\nCREATE VIEW community_view AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\nCREATE VIEW community_mview AS\nwith all_community AS (\n    SELECT\n        ca.*\n    FROM\n        community_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN all_community ac\nUNION ALL\nSELECT\n    ac.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    all_community ac;\n\n-- Post\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\n-- regen post view\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            p.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb\n        WHERE\n            p.creator_id = cb.user_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            p.creator_id = user_.id) AS creator_avatar,\n    (\n        SELECT\n            actor_id\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_local,\n    (\n        SELECT\n            name\n        FROM\n            community\n        WHERE\n            p.community_id = community.id) AS community_name,\n    (\n        SELECT\n            removed\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_removed,\n    (\n        SELECT\n            deleted\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_deleted,\n    (\n        SELECT\n            nsfw\n        FROM\n            community c\n        WHERE\n            p.community_id = c.id) AS community_nsfw,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment\n        WHERE\n            comment.post_id = p.id) AS number_of_comments,\n    coalesce(sum(pl.score), 0) AS score,\n    count(\n        CASE WHEN pl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN pl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(pl.score), 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published -- Prevents necro-bumps\n            ELSE\n                greatest (c.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published -- Prevents necro-bumps\n        ELSE\n            greatest (c.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN post_like pl ON p.id = pl.post_id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            1) c ON p.id = c.post_id\nGROUP BY\n    p.id,\n    c.recent_comment_time;\n\nCREATE MATERIALIZED VIEW post_aggregates_mview AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nCREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id);\n\nCREATE VIEW post_view AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_view pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nCREATE VIEW post_mview AS\nwith all_post AS (\n    SELECT\n        pa.*\n    FROM\n        post_aggregates_mview pa\n)\nSELECT\n    ap.*,\n    u.id AS user_id,\n    coalesce(pl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::bool\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND cf.community_id = ap.community_id) AS subscribed,\n    (\n        SELECT\n            pr.id::bool\n        FROM\n            post_read pr\n        WHERE\n            u.id = pr.user_id\n            AND pr.post_id = ap.id) AS read,\n    (\n        SELECT\n            ps.id::bool\n        FROM\n            post_saved ps\n        WHERE\n            u.id = ps.user_id\n            AND ps.post_id = ap.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_post ap\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND ap.id = pl.post_id\n    UNION ALL\n    SELECT\n        ap.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS read,\n        NULL AS saved\n    FROM\n        all_post ap;\n\nDROP TRIGGER refresh_post ON post;\n\nCREATE TRIGGER refresh_post\n    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post\n    FOR EACH statement\n    EXECUTE PROCEDURE refresh_post ();\n\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview;\n    REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview;\n    RETURN NULL;\nEND\n$$;\n\n-- User mention, comment, reply\nDROP VIEW user_mention_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_aggregates_view;\n\n-- reply and comment view\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    c.*,\n    (\n        SELECT\n            community_id\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id), (\n        SELECT\n            co.actor_id\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_actor_id,\n    (\n        SELECT\n            co.local\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_local,\n    (\n        SELECT\n            co.name\n        FROM\n            post p,\n            community co\n        WHERE\n            p.id = c.post_id\n            AND p.community_id = co.id) AS community_name,\n    (\n        SELECT\n            u.banned\n        FROM\n            user_ u\n        WHERE\n            c.creator_id = u.id) AS banned,\n    (\n        SELECT\n            cb.id::bool\n        FROM\n            community_user_ban cb,\n            post p\n        WHERE\n            c.creator_id = cb.user_id\n            AND p.id = c.post_id\n            AND p.community_id = cb.community_id) AS banned_from_community,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_local,\n    (\n        SELECT\n            name\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_\n        WHERE\n            c.creator_id = user_.id) AS creator_avatar,\n    coalesce(sum(cl.score), 0) AS score,\n    count(\n        CASE WHEN cl.score = 1 THEN\n            1\n        ELSE\n            NULL\n        END) AS upvotes,\n    count(\n        CASE WHEN cl.score = -1 THEN\n            1\n        ELSE\n            NULL\n        END) AS downvotes,\n    hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank\nFROM\n    comment c\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    c.id;\n\nCREATE MATERIALIZED VIEW comment_aggregates_mview AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nCREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id);\n\nCREATE VIEW comment_view AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_view ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\nCREATE VIEW comment_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.*,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.community_id = cf.community_id) AS subscribed,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    UNION ALL\n    SELECT\n        ac.*,\n        NULL AS user_id,\n        NULL AS my_vote,\n        NULL AS subscribed,\n        NULL AS saved\n    FROM\n        all_comment ac;\n\n-- Do the reply_view referencing the comment_mview\nCREATE VIEW reply_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_mview cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_mview AS\nwith all_comment AS (\n    SELECT\n        ca.*\n    FROM\n        comment_aggregates_mview ca\n)\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN all_comment ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    all_comment ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n"
  },
  {
    "path": "migrations/2020-06-30-135809_remove_mat_views/up.sql",
    "content": "-- Drop the mviews\nDROP VIEW post_mview;\n\nDROP MATERIALIZED VIEW user_mview;\n\nDROP VIEW community_mview;\n\nDROP MATERIALIZED VIEW private_message_mview;\n\nDROP VIEW user_mention_mview;\n\nDROP VIEW reply_view;\n\nDROP VIEW comment_mview;\n\nDROP MATERIALIZED VIEW post_aggregates_mview;\n\nDROP MATERIALIZED VIEW community_aggregates_mview;\n\nDROP MATERIALIZED VIEW comment_aggregates_mview;\n\nDROP TRIGGER refresh_private_message ON private_message;\n\n-- User\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.actor_id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.bio,\n    u.local,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    coalesce(pd.posts, 0) AS number_of_posts,\n    coalesce(pd.score, 0) AS post_score,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    coalesce(cd.score, 0) AS comment_score\nFROM\n    user_ u\n    LEFT JOIN (\n        SELECT\n            p.creator_id AS creator_id,\n            count(DISTINCT p.id) AS posts,\n            sum(pl.score) AS score\n        FROM\n            post p\n            JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            p.creator_id) pd ON u.id = pd.creator_id\n    LEFT JOIN (\n        SELECT\n            c.creator_id,\n            count(DISTINCT c.id) AS comments,\n            sum(cl.score) AS score\n        FROM\n            comment c\n            JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            c.creator_id) cd ON u.id = cd.creator_id;\n\nCREATE TABLE user_fast AS\nSELECT\n    *\nFROM\n    user_view;\n\nALTER TABLE user_fast\n    ADD PRIMARY KEY (id);\n\nDROP TRIGGER refresh_user ON user_;\n\nCREATE TRIGGER refresh_user\n    AFTER INSERT OR UPDATE OR DELETE ON user_\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_user ();\n\n-- Sample insert\n-- insert into user_(name, password_encrypted) values ('test_name', 'bleh');\n-- Sample delete\n-- delete from user_ where name like 'test_name';\n-- Sample update\n-- update user_ set avatar = 'hai'  where name like 'test_name';\nCREATE OR REPLACE FUNCTION refresh_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id;\n        -- Refresh post_fast, cause of user info changes\n        DELETE FROM post_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            creator_id = NEW.id;\n        DELETE FROM comment_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            creator_id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Post\n-- Redoing the views : Credit eiknat\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    -- creator details\n    u.actor_id AS creator_actor_id,\n    u.\"local\" AS creator_local,\n    u.\"name\" AS creator_name,\n    u.avatar AS creator_avatar,\n    u.banned AS banned,\n    cb.id::bool AS banned_from_community,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.removed AS community_removed,\n    c.deleted AS community_deleted,\n    c.nsfw AS community_nsfw,\n    -- post score data/comment count\n    coalesce(ct.comments, 0) AS number_of_comments,\n    coalesce(pl.score, 0) AS score,\n    coalesce(pl.upvotes, 0) AS upvotes,\n    coalesce(pl.downvotes, 0) AS downvotes,\n    hot_rank (coalesce(pl.score, 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published\n            ELSE\n                greatest (ct.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published\n        ELSE\n            greatest (ct.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN user_ u ON p.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            count(*) AS comments,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            sum(score) AS score,\n            sum(score) FILTER (WHERE score = 1) AS upvotes,\n            - sum(score) FILTER (WHERE score = -1) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_id) pl ON pl.post_id = p.id\nORDER BY\n    p.id;\n\nCREATE VIEW post_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_view pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_view pav;\n\n-- The post fast table\nCREATE TABLE post_aggregates_fast AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nALTER TABLE post_aggregates_fast\n    ADD PRIMARY KEY (id);\n\n-- For the hot rank resorting\nCREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC);\n\nCREATE VIEW post_fast_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_fast pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_fast pav;\n\nDROP TRIGGER refresh_post ON post;\n\nCREATE TRIGGER refresh_post\n    AFTER INSERT OR UPDATE OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_post ();\n\n-- Sample select\n-- select id, name from post_fast_view where name like 'test_post' and user_id is null;\n-- Sample insert\n-- insert into post(name, creator_id, community_id) values ('test_post', 2, 2);\n-- Sample delete\n-- delete from post where name like 'test_post';\n-- Sample update\n-- update post set community_id = 4  where name like 'test_post';\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Community\n-- Redoing the views : Credit eiknat\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    cat.name AS category_name,\n    coalesce(cf.subs, 0) AS number_of_subscribers,\n    coalesce(cd.posts, 0) AS number_of_posts,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    hot_rank (cf.subs, c.published) AS hot_rank\nFROM\n    community c\n    LEFT JOIN user_ u ON c.creator_id = u.id\n    LEFT JOIN category cat ON c.category_id = cat.id\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_id) cf ON cf.community_id = c.id;\n\nCREATE VIEW community_view AS\nSELECT\n    cv.*,\n    us.user AS user_id,\n    us.is_subbed::bool AS subscribed\nFROM\n    community_aggregates_view cv\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user,\n            coalesce(cf.community_id, 0) AS is_subbed\n        FROM\n            user_ u\n            LEFT JOIN community_follower cf ON u.id = cf.user_id\n                AND cf.community_id = cv.id) AS us\n    UNION ALL\n    SELECT\n        cv.*,\n        NULL AS user_id,\n        NULL AS subscribed\n    FROM\n        community_aggregates_view cv;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    cm.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_moderator cm\n    LEFT JOIN user_ u ON cm.user_id = u.id\n    LEFT JOIN community c ON cm.community_id = c.id;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    cf.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_follower cf\n    LEFT JOIN user_ u ON cf.user_id = u.id\n    LEFT JOIN community c ON cf.community_id = c.id;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    cb.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_user_ban cb\n    LEFT JOIN user_ u ON cb.user_id = u.id\n    LEFT JOIN community c ON cb.community_id = c.id;\n\n-- The community fast table\nCREATE TABLE community_aggregates_fast AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nALTER TABLE community_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW community_fast_view AS\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            community_aggregates_fast ca) ac\nUNION ALL\nSELECT\n    caf.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    community_aggregates_fast caf;\n\nDROP TRIGGER refresh_community ON community;\n\nCREATE TRIGGER refresh_community\n    AFTER INSERT OR UPDATE OR DELETE ON community\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_community ();\n\n-- Sample select\n-- select * from community_fast_view where name like 'test_community_name' and user_id is null;\n-- Sample insert\n-- insert into community(name, title, category_id, creator_id) values ('test_community_name', 'test_community_title', 1, 2);\n-- Sample delete\n-- delete from community where name like 'test_community_name';\n-- Sample update\n-- update community set title = 'test_community_title_2'  where name like 'test_community_name';\nCREATE OR REPLACE FUNCTION refresh_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to owner changes\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to community changes\n        DELETE FROM post_aggregates_fast\n        WHERE community_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            community_id = NEW.id;\n        -- TODO make sure this shows up in the users page ?\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Comment\nDROP VIEW user_mention_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- community details\n    p.community_id,\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\n-- The fast view\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- user mention\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\nDROP TRIGGER refresh_comment ON comment;\n\nCREATE TRIGGER refresh_comment\n    AFTER INSERT OR UPDATE OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_comment ();\n\n-- Sample select\n-- select * from comment_fast_view where content = 'test_comment' and user_id is null;\n-- Sample insert\n-- insert into comment(creator_id, post_id, content) values (2, 2, 'test_comment');\n-- Sample delete\n-- delete from comment where content like 'test_comment';\n-- Sample update\n-- update comment set removed = true where content like 'test_comment';\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id;\n        -- Force the hot rank as zero on week-older posts\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '1 week'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- post_like\n-- select id, score, my_vote from post_fast_view where id = 29 and user_id = 4;\n-- Sample insert\n-- insert into post_like(user_id, post_id, score) values (4, 29, 1);\n-- Sample delete\n-- delete from post_like where user_id = 4 and post_id = 29;\n-- Sample update\n-- update post_like set score = -1 where user_id = 4 and post_id = 29;\n-- TODO test this a LOT\nCREATE OR REPLACE FUNCTION refresh_post_like ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        UPDATE\n            post_aggregates_fast\n        SET\n            score = CASE WHEN (OLD.score = 1) THEN\n                score - 1\n            ELSE\n                score + 1\n            END,\n            upvotes = CASE WHEN (OLD.score = 1) THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN (OLD.score = -1) THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            id = OLD.post_id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates_fast\n        SET\n            score = CASE WHEN (NEW.score = 1) THEN\n                score + 1\n            ELSE\n                score - 1\n            END,\n            upvotes = CASE WHEN (NEW.score = 1) THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN (NEW.score = -1) THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nDROP TRIGGER refresh_post_like ON post_like;\n\nCREATE TRIGGER refresh_post_like\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_post_like ();\n\n-- comment_like\n-- select id, score, my_vote from comment_fast_view where id = 29 and user_id = 4;\n-- Sample insert\n-- insert into comment_like(user_id, comment_id, post_id, score) values (4, 29, 51, 1);\n-- Sample delete\n-- delete from comment_like where user_id = 4 and comment_id = 29;\n-- Sample update\n-- update comment_like set score = -1 where user_id = 4 and comment_id = 29;\nCREATE OR REPLACE FUNCTION refresh_comment_like ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- TODO possibly select from comment_fast to get previous scores, instead of re-fetching the views?\n    IF (TG_OP = 'DELETE') THEN\n        UPDATE\n            comment_aggregates_fast\n        SET\n            score = CASE WHEN (OLD.score = 1) THEN\n                score - 1\n            ELSE\n                score + 1\n            END,\n            upvotes = CASE WHEN (OLD.score = 1) THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN (OLD.score = -1) THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            id = OLD.comment_id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates_fast\n        SET\n            score = CASE WHEN (NEW.score = 1) THEN\n                score + 1\n            ELSE\n                score - 1\n            END,\n            upvotes = CASE WHEN (NEW.score = 1) THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN (NEW.score = -1) THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            id = NEW.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nDROP TRIGGER refresh_comment_like ON comment_like;\n\nCREATE TRIGGER refresh_comment_like\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_comment_like ();\n\n-- Community user ban\nDROP TRIGGER refresh_community_user_ban ON community_user_ban;\n\nCREATE TRIGGER refresh_community_user_ban\n    AFTER INSERT OR DELETE -- Note this is missing after update\n    ON community_user_ban\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_community_user_ban ();\n\n-- select creator_name, banned_from_community from comment_fast_view where user_id = 4 and content = 'test_before_ban';\n-- select creator_name, banned_from_community, community_id from comment_aggregates_fast where content = 'test_before_ban';\n-- Sample insert\n-- insert into comment(creator_id, post_id, content) values (1198, 341, 'test_before_ban');\n-- insert into community_user_ban(community_id, user_id) values (2, 1198);\n-- Sample delete\n-- delete from community_user_ban where user_id = 1198 and community_id = 2;\n-- delete from comment where content = 'test_before_ban';\n-- update comment_aggregates_fast set banned_from_community = false where creator_id = 1198 and community_id = 2;\nCREATE OR REPLACE FUNCTION refresh_community_user_ban ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- TODO possibly select from comment_fast to get previous scores, instead of re-fetching the views?\n    IF (TG_OP = 'DELETE') THEN\n        UPDATE\n            comment_aggregates_fast\n        SET\n            banned_from_community = FALSE\n        WHERE\n            creator_id = OLD.user_id\n            AND community_id = OLD.community_id;\n        UPDATE\n            post_aggregates_fast\n        SET\n            banned_from_community = FALSE\n        WHERE\n            creator_id = OLD.user_id\n            AND community_id = OLD.community_id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates_fast\n        SET\n            banned_from_community = TRUE\n        WHERE\n            creator_id = NEW.user_id\n            AND community_id = NEW.community_id;\n        UPDATE\n            post_aggregates_fast\n        SET\n            banned_from_community = TRUE\n        WHERE\n            creator_id = NEW.user_id\n            AND community_id = NEW.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Community follower\nDROP TRIGGER refresh_community_follower ON community_follower;\n\nCREATE TRIGGER refresh_community_follower\n    AFTER INSERT OR DELETE -- Note this is missing after update\n    ON community_follower\n    FOR EACH ROW\n    EXECUTE PROCEDURE refresh_community_follower ();\n\nCREATE OR REPLACE FUNCTION refresh_community_follower ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_subscribers = number_of_subscribers - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_subscribers = number_of_subscribers + 1\n        WHERE\n            id = NEW.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-07-08-202609_add_creator_published/down.sql",
    "content": "DROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- community details\n    p.community_id,\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- add creator_published to the post view\nDROP VIEW post_fast_view;\n\nDROP TABLE post_aggregates_fast;\n\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    -- creator details\n    u.actor_id AS creator_actor_id,\n    u.\"local\" AS creator_local,\n    u.\"name\" AS creator_name,\n    u.avatar AS creator_avatar,\n    u.banned AS banned,\n    cb.id::bool AS banned_from_community,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.removed AS community_removed,\n    c.deleted AS community_deleted,\n    c.nsfw AS community_nsfw,\n    -- post score data/comment count\n    coalesce(ct.comments, 0) AS number_of_comments,\n    coalesce(pl.score, 0) AS score,\n    coalesce(pl.upvotes, 0) AS upvotes,\n    coalesce(pl.downvotes, 0) AS downvotes,\n    hot_rank (coalesce(pl.score, 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published\n            ELSE\n                greatest (ct.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published\n        ELSE\n            greatest (ct.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN user_ u ON p.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            count(*) AS comments,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            sum(score) AS score,\n            sum(score) FILTER (WHERE score = 1) AS upvotes,\n            - sum(score) FILTER (WHERE score = -1) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_id) pl ON pl.post_id = p.id\nORDER BY\n    p.id;\n\nCREATE VIEW post_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_view pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_view pav;\n\nCREATE TABLE post_aggregates_fast AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nALTER TABLE post_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW post_fast_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_fast pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_fast pav;\n\nCREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2020-07-08-202609_add_creator_published/up.sql",
    "content": "DROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- community details\n    p.community_id,\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- add creator_published to the post view\nDROP VIEW post_fast_view;\n\nDROP TABLE post_aggregates_fast;\n\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    -- creator details\n    u.actor_id AS creator_actor_id,\n    u.\"local\" AS creator_local,\n    u.\"name\" AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    u.banned AS banned,\n    cb.id::bool AS banned_from_community,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.removed AS community_removed,\n    c.deleted AS community_deleted,\n    c.nsfw AS community_nsfw,\n    -- post score data/comment count\n    coalesce(ct.comments, 0) AS number_of_comments,\n    coalesce(pl.score, 0) AS score,\n    coalesce(pl.upvotes, 0) AS upvotes,\n    coalesce(pl.downvotes, 0) AS downvotes,\n    hot_rank (coalesce(pl.score, 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published\n            ELSE\n                greatest (ct.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published\n        ELSE\n            greatest (ct.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN user_ u ON p.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            count(*) AS comments,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            sum(score) AS score,\n            sum(score) FILTER (WHERE score = 1) AS upvotes,\n            - sum(score) FILTER (WHERE score = -1) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_id) pl ON pl.post_id = p.id\nORDER BY\n    p.id;\n\nCREATE VIEW post_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_view pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_view pav;\n\nCREATE TABLE post_aggregates_fast AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nALTER TABLE post_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW post_fast_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_fast pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_fast pav;\n\n"
  },
  {
    "path": "migrations/2020-07-12-100442_add_post_title_to_comments_view/down.sql",
    "content": "DROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- community details\n    p.community_id,\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2020-07-12-100442_add_post_title_to_comments_view/up.sql",
    "content": "DROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- post details\n    p.\"name\" AS post_name,\n    p.community_id,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.post_name,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n"
  },
  {
    "path": "migrations/2020-07-18-234519_add_unique_community_user_actor_ids/down.sql",
    "content": "ALTER TABLE community\n    ALTER COLUMN actor_id SET NOT NULL;\n\nALTER TABLE community\n    ALTER COLUMN actor_id SET DEFAULT 'http://fake.com';\n\nALTER TABLE user_\n    ALTER COLUMN actor_id SET NOT NULL;\n\nALTER TABLE user_\n    ALTER COLUMN actor_id SET DEFAULT 'http://fake.com';\n\nDROP FUNCTION generate_unique_changeme;\n\nUPDATE\n    community\nSET\n    actor_id = 'http://fake.com'\nWHERE\n    actor_id LIKE 'changeme_%';\n\nUPDATE\n    user_\nSET\n    actor_id = 'http://fake.com'\nWHERE\n    actor_id LIKE 'changeme_%';\n\nDROP INDEX idx_user_lower_actor_id;\n\nCREATE UNIQUE INDEX idx_user_name_lower_actor_id ON user_ (lower(name), lower(actor_id));\n\nDROP INDEX idx_community_lower_actor_id;\n\n"
  },
  {
    "path": "migrations/2020-07-18-234519_add_unique_community_user_actor_ids/up.sql",
    "content": "-- Following this issue : https://github.com/LemmyNet/lemmy/issues/957\n-- Creating a unique changeme actor_id\nCREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '')\n    FROM\n        generate_series(1, 20)\n$$;\n\n-- Need to delete the possible community and user dupes for ones that don't start with the fake one\n-- A few test inserts, to make sure this removes later dupes\n-- insert into community (name, title, category_id, creator_id) values ('testcom', 'another testcom', 1, 2);\nDELETE FROM community a USING (\n    SELECT\n        min(id) AS id,\n        actor_id\n    FROM\n        community\n    GROUP BY\n        actor_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.actor_id = b.actor_id\n    AND a.id <> b.id;\n\nDELETE FROM user_ a USING (\n    SELECT\n        min(id) AS id,\n        actor_id\n    FROM\n        user_\n    GROUP BY\n        actor_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.actor_id = b.actor_id\n    AND a.id <> b.id;\n\n-- Replacing the current default on the columns, to the unique one\nUPDATE\n    community\nSET\n    actor_id = generate_unique_changeme ()\nWHERE\n    actor_id = 'http://fake.com';\n\nUPDATE\n    user_\nSET\n    actor_id = generate_unique_changeme ()\nWHERE\n    actor_id = 'http://fake.com';\n\n-- Add the unique indexes\nALTER TABLE community\n    ALTER COLUMN actor_id SET NOT NULL;\n\nALTER TABLE community\n    ALTER COLUMN actor_id SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE user_\n    ALTER COLUMN actor_id SET NOT NULL;\n\nALTER TABLE user_\n    ALTER COLUMN actor_id SET DEFAULT generate_unique_changeme ();\n\n-- Add lowercase uniqueness too\nDROP INDEX idx_user_name_lower_actor_id;\n\nCREATE UNIQUE INDEX idx_user_lower_actor_id ON user_ (lower(actor_id));\n\nCREATE UNIQUE INDEX idx_community_lower_actor_id ON community (lower(actor_id));\n\n"
  },
  {
    "path": "migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/down.sql",
    "content": "-- Drops first\nDROP VIEW site_view;\n\nDROP TABLE user_fast;\n\nDROP VIEW user_view;\n\nDROP VIEW post_fast_view;\n\nDROP TABLE post_aggregates_fast;\n\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nDROP VIEW community_fast_view;\n\nDROP TABLE community_aggregates_fast;\n\nDROP VIEW private_message_view;\n\nDROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nALTER TABLE site\n    DROP COLUMN icon,\n    DROP COLUMN banner;\n\nALTER TABLE community\n    DROP COLUMN icon,\n    DROP COLUMN banner;\n\nALTER TABLE user_\n    DROP COLUMN banner;\n\n-- Site\nCREATE VIEW site_view AS\nSELECT\n    *,\n    (\n        SELECT\n            name\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_name,\n    (\n        SELECT\n            avatar\n        FROM\n            user_ u\n        WHERE\n            s.creator_id = u.id) AS creator_avatar,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s;\n\n-- User\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.actor_id,\n    u.name,\n    u.avatar,\n    u.email,\n    u.matrix_user_id,\n    u.bio,\n    u.local,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    coalesce(pd.posts, 0) AS number_of_posts,\n    coalesce(pd.score, 0) AS post_score,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    coalesce(cd.score, 0) AS comment_score\nFROM\n    user_ u\n    LEFT JOIN (\n        SELECT\n            p.creator_id AS creator_id,\n            count(DISTINCT p.id) AS posts,\n            sum(pl.score) AS score\n        FROM\n            post p\n            JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            p.creator_id) pd ON u.id = pd.creator_id\n    LEFT JOIN (\n        SELECT\n            c.creator_id,\n            count(DISTINCT c.id) AS comments,\n            sum(cl.score) AS score\n        FROM\n            comment c\n            JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            c.creator_id) cd ON u.id = cd.creator_id;\n\nCREATE TABLE user_fast AS\nSELECT\n    *\nFROM\n    user_view;\n\nALTER TABLE user_fast\n    ADD PRIMARY KEY (id);\n\n-- Post fast\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    -- creator details\n    u.actor_id AS creator_actor_id,\n    u.\"local\" AS creator_local,\n    u.\"name\" AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    u.banned AS banned,\n    cb.id::bool AS banned_from_community,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.removed AS community_removed,\n    c.deleted AS community_deleted,\n    c.nsfw AS community_nsfw,\n    -- post score data/comment count\n    coalesce(ct.comments, 0) AS number_of_comments,\n    coalesce(pl.score, 0) AS score,\n    coalesce(pl.upvotes, 0) AS upvotes,\n    coalesce(pl.downvotes, 0) AS downvotes,\n    hot_rank (coalesce(pl.score, 0), (\n            CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n                p.published\n            ELSE\n                greatest (ct.recent_comment_time, p.published)\n            END)) AS hot_rank,\n    (\n        CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN\n            p.published\n        ELSE\n            greatest (ct.recent_comment_time, p.published)\n        END) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN user_ u ON p.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            count(*) AS comments,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            sum(score) AS score,\n            sum(score) FILTER (WHERE score = 1) AS upvotes,\n            - sum(score) FILTER (WHERE score = -1) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_id) pl ON pl.post_id = p.id\nORDER BY\n    p.id;\n\nCREATE VIEW post_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_view pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_view pav;\n\nCREATE TABLE post_aggregates_fast AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nALTER TABLE post_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW post_fast_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_fast pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_fast pav;\n\n-- Community\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    cat.name AS category_name,\n    coalesce(cf.subs, 0) AS number_of_subscribers,\n    coalesce(cd.posts, 0) AS number_of_posts,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    hot_rank (cf.subs, c.published) AS hot_rank\nFROM\n    community c\n    LEFT JOIN user_ u ON c.creator_id = u.id\n    LEFT JOIN category cat ON c.category_id = cat.id\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_id) cf ON cf.community_id = c.id;\n\nCREATE VIEW community_view AS\nSELECT\n    cv.*,\n    us.user AS user_id,\n    us.is_subbed::bool AS subscribed\nFROM\n    community_aggregates_view cv\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user,\n            coalesce(cf.community_id, 0) AS is_subbed\n        FROM\n            user_ u\n            LEFT JOIN community_follower cf ON u.id = cf.user_id\n                AND cf.community_id = cv.id) AS us\n    UNION ALL\n    SELECT\n        cv.*,\n        NULL AS user_id,\n        NULL AS subscribed\n    FROM\n        community_aggregates_view cv;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    cm.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_moderator cm\n    LEFT JOIN user_ u ON cm.user_id = u.id\n    LEFT JOIN community c ON cm.community_id = c.id;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    cf.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_follower cf\n    LEFT JOIN user_ u ON cf.user_id = u.id\n    LEFT JOIN community c ON cf.community_id = c.id;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    cb.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name\nFROM\n    community_user_ban cb\n    LEFT JOIN user_ u ON cb.user_id = u.id\n    LEFT JOIN community c ON cb.community_id = c.id;\n\n-- The community fast table\nCREATE TABLE community_aggregates_fast AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nALTER TABLE community_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW community_fast_view AS\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            community_aggregates_fast ca) ac\nUNION ALL\nSELECT\n    caf.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    community_aggregates_fast caf;\n\n-- Private message\nCREATE VIEW private_message_view AS\nSELECT\n    pm.*,\n    u.name AS creator_name,\n    u.avatar AS creator_avatar,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u2.name AS recipient_name,\n    u2.avatar AS recipient_avatar,\n    u2.actor_id AS recipient_actor_id,\n    u2.local AS recipient_local\nFROM\n    private_message pm\n    INNER JOIN user_ u ON u.id = pm.creator_id\n    INNER JOIN user_ u2 ON u2.id = pm.recipient_id;\n\n-- Comments, mentions, replies\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- post details\n    p.\"name\" AS post_name,\n    p.community_id,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.post_name,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- redoing the triggers\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id;\n        -- Force the hot rank as zero on week-older posts\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '1 week'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/up.sql",
    "content": "-- This adds the following columns, as well as updates the views:\n--  Site icon\n--  Site banner\n--  Community icon\n--  Community Banner\n--  User Banner (User avatar is already there)\n--  User preferred name (already in table, needs to be added to view)\n-- It also adds hot_rank_active to post_view\nALTER TABLE site\n    ADD COLUMN icon text,\n    ADD COLUMN banner text;\n\nALTER TABLE community\n    ADD COLUMN icon text,\n    ADD COLUMN banner text;\n\nALTER TABLE user_\n    ADD COLUMN banner text;\n\nDROP VIEW site_view;\n\nCREATE VIEW site_view AS\nSELECT\n    s.*,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.avatar AS creator_avatar,\n    (\n        SELECT\n            count(*)\n        FROM\n            user_) AS number_of_users,\n    (\n        SELECT\n            count(*)\n        FROM\n            post) AS number_of_posts,\n    (\n        SELECT\n            count(*)\n        FROM\n            comment) AS number_of_comments,\n    (\n        SELECT\n            count(*)\n        FROM\n            community) AS number_of_communities\nFROM\n    site s\n    LEFT JOIN user_ u ON s.creator_id = u.id;\n\n-- User\nDROP TABLE user_fast;\n\nDROP VIEW user_view;\n\nCREATE VIEW user_view AS\nSELECT\n    u.id,\n    u.actor_id,\n    u.name,\n    u.preferred_username,\n    u.avatar,\n    u.banner,\n    u.email,\n    u.matrix_user_id,\n    u.bio,\n    u.local,\n    u.admin,\n    u.banned,\n    u.show_avatars,\n    u.send_notifications_to_email,\n    u.published,\n    coalesce(pd.posts, 0) AS number_of_posts,\n    coalesce(pd.score, 0) AS post_score,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    coalesce(cd.score, 0) AS comment_score\nFROM\n    user_ u\n    LEFT JOIN (\n        SELECT\n            p.creator_id AS creator_id,\n            count(DISTINCT p.id) AS posts,\n            sum(pl.score) AS score\n        FROM\n            post p\n            JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            p.creator_id) pd ON u.id = pd.creator_id\n    LEFT JOIN (\n        SELECT\n            c.creator_id,\n            count(DISTINCT c.id) AS comments,\n            sum(cl.score) AS score\n        FROM\n            comment c\n            JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            c.creator_id) cd ON u.id = cd.creator_id;\n\nCREATE TABLE user_fast AS\nSELECT\n    *\nFROM\n    user_view;\n\nALTER TABLE user_fast\n    ADD PRIMARY KEY (id);\n\n-- private message\nDROP VIEW private_message_view;\n\nCREATE VIEW private_message_view AS\nSELECT\n    pm.*,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.avatar AS creator_avatar,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u2.name AS recipient_name,\n    u2.preferred_username AS recipient_preferred_username,\n    u2.avatar AS recipient_avatar,\n    u2.actor_id AS recipient_actor_id,\n    u2.local AS recipient_local\nFROM\n    private_message pm\n    INNER JOIN user_ u ON u.id = pm.creator_id\n    INNER JOIN user_ u2 ON u2.id = pm.recipient_id;\n\n-- Post fast\nDROP VIEW post_fast_view;\n\nDROP TABLE post_aggregates_fast;\n\nDROP VIEW post_view;\n\nDROP VIEW post_aggregates_view;\n\nCREATE VIEW post_aggregates_view AS\nSELECT\n    p.*,\n    -- creator details\n    u.actor_id AS creator_actor_id,\n    u.\"local\" AS creator_local,\n    u.\"name\" AS creator_name,\n    u.\"preferred_username\" AS creator_preferred_username,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    u.banned AS banned,\n    cb.id::bool AS banned_from_community,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.icon AS community_icon,\n    c.removed AS community_removed,\n    c.deleted AS community_deleted,\n    c.nsfw AS community_nsfw,\n    -- post score data/comment count\n    coalesce(ct.comments, 0) AS number_of_comments,\n    coalesce(pl.score, 0) AS score,\n    coalesce(pl.upvotes, 0) AS upvotes,\n    coalesce(pl.downvotes, 0) AS downvotes,\n    hot_rank (coalesce(pl.score, 1), p.published) AS hot_rank,\n    hot_rank (coalesce(pl.score, 1), greatest (ct.recent_comment_time, p.published)) AS hot_rank_active,\n    greatest (ct.recent_comment_time, p.published) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN user_ u ON p.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            count(*) AS comments,\n            max(published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_id,\n            sum(score) AS score,\n            sum(score) FILTER (WHERE score = 1) AS upvotes,\n            - sum(score) FILTER (WHERE score = -1) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_id) pl ON pl.post_id = p.id\nORDER BY\n    p.id;\n\nCREATE VIEW post_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_view pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_view pav;\n\nCREATE TABLE post_aggregates_fast AS\nSELECT\n    *\nFROM\n    post_aggregates_view;\n\nALTER TABLE post_aggregates_fast\n    ADD PRIMARY KEY (id);\n\n-- For the hot rank resorting\nCREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_fast_hot_rank_active_published ON post_aggregates_fast (hot_rank_active DESC, published DESC);\n\nCREATE VIEW post_fast_view AS\nSELECT\n    pav.*,\n    us.id AS user_id,\n    us.user_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_read::bool AS read,\n    us.is_saved::bool AS saved\nFROM\n    post_aggregates_fast pav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id,\n            coalesce(cf.community_id, 0) AS is_subbed,\n            coalesce(pr.post_id, 0) AS is_read,\n            coalesce(ps.post_id, 0) AS is_saved,\n            coalesce(pl.score, 0) AS user_vote\n        FROM\n            user_ u\n            LEFT JOIN community_user_ban cb ON u.id = cb.user_id\n                AND cb.community_id = pav.community_id\n        LEFT JOIN community_follower cf ON u.id = cf.user_id\n            AND cf.community_id = pav.community_id\n    LEFT JOIN post_read pr ON u.id = pr.user_id\n        AND pr.post_id = pav.id\n    LEFT JOIN post_saved ps ON u.id = ps.user_id\n        AND ps.post_id = pav.id\n    LEFT JOIN post_like pl ON u.id = pl.user_id\n        AND pav.id = pl.post_id) AS us\nUNION ALL\nSELECT\n    pav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS read,\n    NULL AS saved\nFROM\n    post_aggregates_fast pav;\n\n-- Community\nDROP VIEW community_moderator_view;\n\nDROP VIEW community_follower_view;\n\nDROP VIEW community_user_ban_view;\n\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nDROP VIEW community_fast_view;\n\nDROP TABLE community_aggregates_fast;\n\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.icon,\n    c.banner,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.avatar AS creator_avatar,\n    cat.name AS category_name,\n    coalesce(cf.subs, 0) AS number_of_subscribers,\n    coalesce(cd.posts, 0) AS number_of_posts,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    hot_rank (cf.subs, c.published) AS hot_rank\nFROM\n    community c\n    LEFT JOIN user_ u ON c.creator_id = u.id\n    LEFT JOIN category cat ON c.category_id = cat.id\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_id) cf ON cf.community_id = c.id;\n\nCREATE VIEW community_view AS\nSELECT\n    cv.*,\n    us.user AS user_id,\n    us.is_subbed::bool AS subscribed\nFROM\n    community_aggregates_view cv\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user,\n            coalesce(cf.community_id, 0) AS is_subbed\n        FROM\n            user_ u\n            LEFT JOIN community_follower cf ON u.id = cf.user_id\n                AND cf.community_id = cv.id) AS us\n    UNION ALL\n    SELECT\n        cv.*,\n        NULL AS user_id,\n        NULL AS subscribed\n    FROM\n        community_aggregates_view cv;\n\nCREATE VIEW community_moderator_view AS\nSELECT\n    cm.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.preferred_username AS user_preferred_username,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name,\n    c.icon AS community_icon\nFROM\n    community_moderator cm\n    LEFT JOIN user_ u ON cm.user_id = u.id\n    LEFT JOIN community c ON cm.community_id = c.id;\n\nCREATE VIEW community_follower_view AS\nSELECT\n    cf.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.preferred_username AS user_preferred_username,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name,\n    c.icon AS community_icon\nFROM\n    community_follower cf\n    LEFT JOIN user_ u ON cf.user_id = u.id\n    LEFT JOIN community c ON cf.community_id = c.id;\n\nCREATE VIEW community_user_ban_view AS\nSELECT\n    cb.*,\n    u.actor_id AS user_actor_id,\n    u.local AS user_local,\n    u.name AS user_name,\n    u.preferred_username AS user_preferred_username,\n    u.avatar AS avatar,\n    c.actor_id AS community_actor_id,\n    c.local AS community_local,\n    c.name AS community_name,\n    c.icon AS community_icon\nFROM\n    community_user_ban cb\n    LEFT JOIN user_ u ON cb.user_id = u.id\n    LEFT JOIN community c ON cb.community_id = c.id;\n\n-- The community fast table\nCREATE TABLE community_aggregates_fast AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nALTER TABLE community_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW community_fast_view AS\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            community_aggregates_fast ca) ac\nUNION ALL\nSELECT\n    caf.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    community_aggregates_fast caf;\n\n-- Comments, mentions, replies\nDROP VIEW user_mention_view;\n\nDROP VIEW reply_fast_view;\n\nDROP VIEW comment_fast_view;\n\nDROP VIEW comment_view;\n\nDROP VIEW user_mention_fast_view;\n\nDROP TABLE comment_aggregates_fast;\n\nDROP VIEW comment_aggregates_view;\n\nCREATE VIEW comment_aggregates_view AS\nSELECT\n    ct.*,\n    -- post details\n    p.\"name\" AS post_name,\n    p.community_id,\n    -- community details\n    c.actor_id AS community_actor_id,\n    c.\"local\" AS community_local,\n    c.\"name\" AS community_name,\n    c.icon AS community_icon,\n    -- creator details\n    u.banned AS banned,\n    coalesce(cb.id, 0)::bool AS banned_from_community,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.published AS creator_published,\n    u.avatar AS creator_avatar,\n    -- score details\n    coalesce(cl.total, 0) AS score,\n    coalesce(cl.up, 0) AS upvotes,\n    coalesce(cl.down, 0) AS downvotes,\n    hot_rank (coalesce(cl.total, 1), p.published) AS hot_rank,\n    hot_rank (coalesce(cl.total, 1), ct.published) AS hot_rank_active\nFROM\n    comment ct\n    LEFT JOIN post p ON ct.post_id = p.id\n    LEFT JOIN community c ON p.community_id = c.id\n    LEFT JOIN user_ u ON ct.creator_id = u.id\n    LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id\n        AND p.id = ct.post_id\n        AND p.community_id = cb.community_id\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS up,\n            count(\n                CASE WHEN l.score = -1 THEN\n                    1\n                ELSE\n                    NULL\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            comment_id) AS cl ON cl.id = ct.id;\n\nCREATE OR REPLACE VIEW comment_view AS (\n    SELECT\n        cav.*,\n        us.user_id AS user_id,\n        us.my_vote AS my_vote,\n        us.is_subbed::bool AS subscribed,\n        us.is_saved::bool AS saved\n    FROM\n        comment_aggregates_view cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_view cav);\n\nCREATE TABLE comment_aggregates_fast AS\nSELECT\n    *\nFROM\n    comment_aggregates_view;\n\nALTER TABLE comment_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW comment_fast_view AS\nSELECT\n    cav.*,\n    us.user_id AS user_id,\n    us.my_vote AS my_vote,\n    us.is_subbed::bool AS subscribed,\n    us.is_saved::bool AS saved\nFROM\n    comment_aggregates_fast cav\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user_id,\n            coalesce(cl.score, 0) AS my_vote,\n            coalesce(cf.id, 0) AS is_subbed,\n            coalesce(cs.id, 0) AS is_saved\n        FROM\n            user_ u\n            LEFT JOIN comment_like cl ON u.id = cl.user_id\n                AND cav.id = cl.comment_id\n        LEFT JOIN comment_saved cs ON u.id = cs.user_id\n            AND cs.comment_id = cav.id\n    LEFT JOIN community_follower cf ON u.id = cf.user_id\n        AND cav.community_id = cf.community_id) AS us\nUNION ALL\nSELECT\n    cav.*,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS subscribed,\n    NULL AS saved\nFROM\n    comment_aggregates_fast cav;\n\nCREATE VIEW user_mention_view AS\nSELECT\n    c.id,\n    um.id AS user_mention_id,\n    c.creator_id,\n    c.creator_actor_id,\n    c.creator_local,\n    c.post_id,\n    c.post_name,\n    c.parent_id,\n    c.content,\n    c.removed,\n    um.read,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.community_id,\n    c.community_actor_id,\n    c.community_local,\n    c.community_name,\n    c.community_icon,\n    c.banned,\n    c.banned_from_community,\n    c.creator_name,\n    c.creator_preferred_username,\n    c.creator_avatar,\n    c.score,\n    c.upvotes,\n    c.downvotes,\n    c.hot_rank,\n    c.hot_rank_active,\n    c.user_id,\n    c.my_vote,\n    c.saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_mention um,\n    comment_view c\nWHERE\n    um.comment_id = c.id;\n\nCREATE VIEW user_mention_fast_view AS\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.community_icon,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_preferred_username,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    ac.hot_rank_active,\n    u.id AS user_id,\n    coalesce(cl.score, 0) AS my_vote,\n    (\n        SELECT\n            cs.id::bool\n        FROM\n            comment_saved cs\n        WHERE\n            u.id = cs.user_id\n            AND cs.comment_id = ac.id) AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            comment_aggregates_fast ca) ac\n    LEFT JOIN comment_like cl ON u.id = cl.user_id\n        AND ac.id = cl.comment_id\n    LEFT JOIN user_mention um ON um.comment_id = ac.id\nUNION ALL\nSELECT\n    ac.id,\n    um.id AS user_mention_id,\n    ac.creator_id,\n    ac.creator_actor_id,\n    ac.creator_local,\n    ac.post_id,\n    ac.post_name,\n    ac.parent_id,\n    ac.content,\n    ac.removed,\n    um.read,\n    ac.published,\n    ac.updated,\n    ac.deleted,\n    ac.community_id,\n    ac.community_actor_id,\n    ac.community_local,\n    ac.community_name,\n    ac.community_icon,\n    ac.banned,\n    ac.banned_from_community,\n    ac.creator_name,\n    ac.creator_preferred_username,\n    ac.creator_avatar,\n    ac.score,\n    ac.upvotes,\n    ac.downvotes,\n    ac.hot_rank,\n    ac.hot_rank_active,\n    NULL AS user_id,\n    NULL AS my_vote,\n    NULL AS saved,\n    um.recipient_id,\n    (\n        SELECT\n            actor_id\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_actor_id,\n    (\n        SELECT\n            local\n        FROM\n            user_ u\n        WHERE\n            u.id = um.recipient_id) AS recipient_local\nFROM\n    comment_aggregates_fast ac\n    LEFT JOIN user_mention um ON um.comment_id = ac.id;\n\n-- Do the reply_view referencing the comment_fast_view\nCREATE VIEW reply_fast_view AS\nwith closereply AS (\n    SELECT\n        c2.id,\n        c2.creator_id AS sender_id,\n        c.creator_id AS recipient_id\n    FROM\n        comment c\n        INNER JOIN comment c2 ON c.id = c2.parent_id\n    WHERE\n        c2.creator_id != c.creator_id\n        -- Do union where post is null\n    UNION\n    SELECT\n        c.id,\n        c.creator_id AS sender_id,\n        p.creator_id AS recipient_id\n    FROM\n        comment c,\n        post p\n    WHERE\n        c.post_id = p.id\n        AND c.parent_id IS NULL\n        AND c.creator_id != p.creator_id\n)\nSELECT\n    cv.*,\n    closereply.recipient_id\nFROM\n    comment_fast_view cv,\n    closereply\nWHERE\n    closereply.id = cv.id;\n\n-- Adding hot rank active to the triggers\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank,\n            hot_rank_active = pav.hot_rank_active\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id;\n        -- Update the comment hot_ranks as of last week\n        UPDATE\n            comment_aggregates_fast AS caf\n        SET\n            hot_rank = cav.hot_rank,\n            hot_rank_active = cav.hot_rank_active\n        FROM\n            comment_aggregates_view AS cav\n        WHERE\n            caf.id = cav.id\n            AND (cav.published > ('now'::timestamp - '1 week'::interval));\n        -- Update the post ranks\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank,\n            hot_rank_active = pav.hot_rank_active\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n        -- Force the hot rank active as zero on 2 day-older posts (necro-bump)\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank_active = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '2 days'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-08-06-205355_update_community_post_count/down.sql",
    "content": "-- Drop first\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nDROP VIEW community_fast_view;\n\nDROP TABLE community_aggregates_fast;\n\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.icon,\n    c.banner,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.avatar AS creator_avatar,\n    cat.name AS category_name,\n    coalesce(cf.subs, 0) AS number_of_subscribers,\n    coalesce(cd.posts, 0) AS number_of_posts,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    hot_rank (cf.subs, c.published) AS hot_rank\nFROM\n    community c\n    LEFT JOIN user_ u ON c.creator_id = u.id\n    LEFT JOIN category cat ON c.category_id = cat.id\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_id) cf ON cf.community_id = c.id;\n\nCREATE VIEW community_view AS\nSELECT\n    cv.*,\n    us.user AS user_id,\n    us.is_subbed::bool AS subscribed\nFROM\n    community_aggregates_view cv\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user,\n            coalesce(cf.community_id, 0) AS is_subbed\n        FROM\n            user_ u\n            LEFT JOIN community_follower cf ON u.id = cf.user_id\n                AND cf.community_id = cv.id) AS us\n    UNION ALL\n    SELECT\n        cv.*,\n        NULL AS user_id,\n        NULL AS subscribed\n    FROM\n        community_aggregates_view cv;\n\n-- The community fast table\nCREATE TABLE community_aggregates_fast AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nALTER TABLE community_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW community_fast_view AS\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            community_aggregates_fast ca) ac\nUNION ALL\nSELECT\n    caf.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    community_aggregates_fast caf;\n\n"
  },
  {
    "path": "migrations/2020-08-06-205355_update_community_post_count/up.sql",
    "content": "-- Drop first\nDROP VIEW community_view;\n\nDROP VIEW community_aggregates_view;\n\nDROP VIEW community_fast_view;\n\nDROP TABLE community_aggregates_fast;\n\nCREATE VIEW community_aggregates_view AS\nSELECT\n    c.id,\n    c.name,\n    c.title,\n    c.icon,\n    c.banner,\n    c.description,\n    c.category_id,\n    c.creator_id,\n    c.removed,\n    c.published,\n    c.updated,\n    c.deleted,\n    c.nsfw,\n    c.actor_id,\n    c.local,\n    c.last_refreshed_at,\n    u.actor_id AS creator_actor_id,\n    u.local AS creator_local,\n    u.name AS creator_name,\n    u.preferred_username AS creator_preferred_username,\n    u.avatar AS creator_avatar,\n    cat.name AS category_name,\n    coalesce(cf.subs, 0) AS number_of_subscribers,\n    coalesce(cd.posts, 0) AS number_of_posts,\n    coalesce(cd.comments, 0) AS number_of_comments,\n    hot_rank (cf.subs, c.published) AS hot_rank\nFROM\n    community c\n    LEFT JOIN user_ u ON c.creator_id = u.id\n    LEFT JOIN category cat ON c.category_id = cat.id\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            LEFT JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_id) cf ON cf.community_id = c.id;\n\nCREATE VIEW community_view AS\nSELECT\n    cv.*,\n    us.user AS user_id,\n    us.is_subbed::bool AS subscribed\nFROM\n    community_aggregates_view cv\n    CROSS JOIN LATERAL (\n        SELECT\n            u.id AS user,\n            coalesce(cf.community_id, 0) AS is_subbed\n        FROM\n            user_ u\n            LEFT JOIN community_follower cf ON u.id = cf.user_id\n                AND cf.community_id = cv.id) AS us\n    UNION ALL\n    SELECT\n        cv.*,\n        NULL AS user_id,\n        NULL AS subscribed\n    FROM\n        community_aggregates_view cv;\n\n-- The community fast table\nCREATE TABLE community_aggregates_fast AS\nSELECT\n    *\nFROM\n    community_aggregates_view;\n\nALTER TABLE community_aggregates_fast\n    ADD PRIMARY KEY (id);\n\nCREATE VIEW community_fast_view AS\nSELECT\n    ac.*,\n    u.id AS user_id,\n    (\n        SELECT\n            cf.id::boolean\n        FROM\n            community_follower cf\n        WHERE\n            u.id = cf.user_id\n            AND ac.id = cf.community_id) AS subscribed\nFROM\n    user_ u\n    CROSS JOIN (\n        SELECT\n            ca.*\n        FROM\n            community_aggregates_fast ca) ac\nUNION ALL\nSELECT\n    caf.*,\n    NULL AS user_id,\n    NULL AS subscribed\nFROM\n    community_aggregates_fast caf;\n\n"
  },
  {
    "path": "migrations/2020-08-25-132005_add_unique_ap_ids/down.sql",
    "content": "-- Drop the uniques\nALTER TABLE private_message\n    DROP CONSTRAINT idx_private_message_ap_id;\n\nALTER TABLE post\n    DROP CONSTRAINT idx_post_ap_id;\n\nALTER TABLE comment\n    DROP CONSTRAINT idx_comment_ap_id;\n\nALTER TABLE user_\n    DROP CONSTRAINT idx_user_actor_id;\n\nALTER TABLE community\n    DROP CONSTRAINT idx_community_actor_id;\n\nALTER TABLE private_message\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE private_message\n    ALTER COLUMN ap_id SET DEFAULT 'http://fake.com';\n\nALTER TABLE post\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE post\n    ALTER COLUMN ap_id SET DEFAULT 'http://fake.com';\n\nALTER TABLE comment\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE comment\n    ALTER COLUMN ap_id SET DEFAULT 'http://fake.com';\n\nUPDATE\n    private_message\nSET\n    ap_id = 'http://fake.com'\nWHERE\n    ap_id LIKE 'changeme_%';\n\nUPDATE\n    post\nSET\n    ap_id = 'http://fake.com'\nWHERE\n    ap_id LIKE 'changeme_%';\n\nUPDATE\n    comment\nSET\n    ap_id = 'http://fake.com'\nWHERE\n    ap_id LIKE 'changeme_%';\n\n"
  },
  {
    "path": "migrations/2020-08-25-132005_add_unique_ap_ids/up.sql",
    "content": "-- Add unique ap_id for private_message, comment, and post\n-- Need to delete the possible dupes for ones that don't start with the fake one\nDELETE FROM private_message a USING (\n    SELECT\n        min(id) AS id,\n        ap_id\n    FROM\n        private_message\n    GROUP BY\n        ap_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.ap_id = b.ap_id\n    AND a.id <> b.id;\n\nDELETE FROM post a USING (\n    SELECT\n        min(id) AS id,\n        ap_id\n    FROM\n        post\n    GROUP BY\n        ap_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.ap_id = b.ap_id\n    AND a.id <> b.id;\n\nDELETE FROM comment a USING (\n    SELECT\n        min(id) AS id,\n        ap_id\n    FROM\n        comment\n    GROUP BY\n        ap_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.ap_id = b.ap_id\n    AND a.id <> b.id;\n\n-- Replacing the current default on the columns, to the unique one\nUPDATE\n    private_message\nSET\n    ap_id = generate_unique_changeme ()\nWHERE\n    ap_id = 'http://fake.com';\n\nUPDATE\n    post\nSET\n    ap_id = generate_unique_changeme ()\nWHERE\n    ap_id = 'http://fake.com';\n\nUPDATE\n    comment\nSET\n    ap_id = generate_unique_changeme ()\nWHERE\n    ap_id = 'http://fake.com';\n\n-- Add the unique indexes\nALTER TABLE private_message\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE private_message\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE post\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE post\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE comment\n    ALTER COLUMN ap_id SET NOT NULL;\n\nALTER TABLE comment\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\n-- Add the uniques, for user_ and community too\nALTER TABLE private_message\n    ADD CONSTRAINT idx_private_message_ap_id UNIQUE (ap_id);\n\nALTER TABLE post\n    ADD CONSTRAINT idx_post_ap_id UNIQUE (ap_id);\n\nALTER TABLE comment\n    ADD CONSTRAINT idx_comment_ap_id UNIQUE (ap_id);\n\nALTER TABLE user_\n    ADD CONSTRAINT idx_user_actor_id UNIQUE (actor_id);\n\nALTER TABLE community\n    ADD CONSTRAINT idx_community_actor_id UNIQUE (actor_id);\n\n"
  },
  {
    "path": "migrations/2020-09-07-231141_add_migration_utils/down.sql",
    "content": "DROP SCHEMA utils CASCADE;\n\n"
  },
  {
    "path": "migrations/2020-09-07-231141_add_migration_utils/up.sql",
    "content": "CREATE SCHEMA utils;\n\nCREATE TABLE utils.deps_saved_ddl (\n    id serial NOT NULL,\n    view_schema character varying(255),\n    view_name character varying(255),\n    ddl_to_run text,\n    CONSTRAINT deps_saved_ddl_pkey PRIMARY KEY (id)\n);\n\nCREATE OR REPLACE FUNCTION utils.save_and_drop_views (p_view_schema name, p_view_name name)\n    RETURNS void\n    LANGUAGE plpgsql\n    COST 100\n    AS $BODY$\nDECLARE\n    v_curr record;\nBEGIN\n    FOR v_curr IN (\n        SELECT\n            obj_schema,\n            obj_name,\n            obj_type\n        FROM ( WITH RECURSIVE recursive_deps (\n                obj_schema,\n                obj_name,\n                obj_type,\n                depth\n) AS (\n                SELECT\n                    p_view_schema::name,\n                    p_view_name,\n                    NULL::varchar,\n                    0\n                UNION\n                SELECT\n                    dep_schema::varchar,\n                    dep_name::varchar,\n                    dep_type::varchar,\n                    recursive_deps.depth + 1\n                FROM (\n                    SELECT\n                        ref_nsp.nspname ref_schema,\n                        ref_cl.relname ref_name,\n                        rwr_cl.relkind dep_type,\n                        rwr_nsp.nspname dep_schema,\n                        rwr_cl.relname dep_name\n                    FROM\n                        pg_depend dep\n                        JOIN pg_class ref_cl ON dep.refobjid = ref_cl.oid\n                        JOIN pg_namespace ref_nsp ON ref_cl.relnamespace = ref_nsp.oid\n                        JOIN pg_rewrite rwr ON dep.objid = rwr.oid\n                        JOIN pg_class rwr_cl ON rwr.ev_class = rwr_cl.oid\n                        JOIN pg_namespace rwr_nsp ON rwr_cl.relnamespace = rwr_nsp.oid\n                    WHERE\n                        dep.deptype = 'n'\n                        AND dep.classid = 'pg_rewrite'::regclass) deps\n                    JOIN recursive_deps ON deps.ref_schema = recursive_deps.obj_schema\n                        AND deps.ref_name = recursive_deps.obj_name\n                WHERE (deps.ref_schema != deps.dep_schema\n                    OR deps.ref_name != deps.dep_name))\n            SELECT\n                obj_schema,\n                obj_name,\n                obj_type,\n                depth\n            FROM\n                recursive_deps\n            WHERE\n                depth > 0) t\n        GROUP BY\n            obj_schema,\n            obj_name,\n            obj_type\n        ORDER BY\n            max(depth) DESC)\n            LOOP\n                IF v_curr.obj_type = 'v' THEN\n                    INSERT INTO utils.deps_saved_ddl (view_schema, view_name, ddl_to_run)\n                    SELECT\n                        p_view_schema,\n                        p_view_name,\n                        'CREATE VIEW ' || v_curr.obj_schema || '.' || v_curr.obj_name || ' AS ' || view_definition\n                    FROM\n                        information_schema.views\n                    WHERE\n                        table_schema = v_curr.obj_schema\n                        AND table_name = v_curr.obj_name;\n                    EXECUTE 'DROP VIEW' || ' ' || v_curr.obj_schema || '.' || v_curr.obj_name;\n                END IF;\n            END LOOP;\nEND;\n$BODY$;\n\nCREATE OR REPLACE FUNCTION utils.restore_views (p_view_schema character varying, p_view_name character varying)\n    RETURNS void\n    LANGUAGE plpgsql\n    COST 100\n    AS $BODY$\nDECLARE\n    v_curr record;\nBEGIN\n    FOR v_curr IN (\n        SELECT\n            ddl_to_run,\n            id\n        FROM\n            utils.deps_saved_ddl\n        WHERE\n            view_schema = p_view_schema\n            AND view_name = p_view_name\n        ORDER BY\n            id DESC)\n            LOOP\n                BEGIN\n                    EXECUTE v_curr.ddl_to_run;\n                    DELETE FROM utils.deps_saved_ddl\n                    WHERE id = v_curr.id;\n                EXCEPTION\n                    WHEN OTHERS THEN\n                        -- keep looping, but please check for errors or remove left overs to handle manually\n                END;\n    END LOOP;\nEND;\n\n$BODY$;\n\n"
  },
  {
    "path": "migrations/2020-10-07-234221_fix_fast_triggers/down.sql",
    "content": "CREATE OR REPLACE FUNCTION refresh_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to owner changes\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to community changes\n        DELETE FROM post_aggregates_fast\n        WHERE community_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            community_id = NEW.id;\n        -- TODO make sure this shows up in the users page ?\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id;\n        -- Refresh post_fast, cause of user info changes\n        DELETE FROM post_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            creator_id = NEW.id;\n        DELETE FROM comment_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            creator_id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id;\n        -- Force the hot rank as zero on week-older posts\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '1 week'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-10-07-234221_fix_fast_triggers/up.sql",
    "content": "-- This adds on conflict do nothing triggers to all the insert_intos\n-- Github issue: https://github.com/LemmyNet/lemmy/issues/1179\nCREATE OR REPLACE FUNCTION refresh_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM community_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update user view due to owner changes\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update post view due to community changes\n        DELETE FROM post_aggregates_fast\n        WHERE community_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            community_id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- TODO make sure this shows up in the users page ?\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates_fast\n        SELECT\n            *\n        FROM\n            community_aggregates_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM user_fast\n        WHERE id = OLD.id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Refresh post_fast, cause of user info changes\n        DELETE FROM post_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            creator_id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n        DELETE FROM comment_aggregates_fast\n        WHERE creator_id = NEW.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            creator_id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Force the hot rank as zero on week-older posts\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '1 week'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-10-10-035723_fix_fast_triggers_2/down.sql",
    "content": "CREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Force the hot rank as zero on week-older posts\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '1 week'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-10-10-035723_fix_fast_triggers_2/up.sql",
    "content": "-- Forgot to add hot rank active to these two triggers\nCREATE OR REPLACE FUNCTION refresh_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts - 1\n        WHERE\n            id = OLD.community_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM post_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update that users number of posts, post score\n        DELETE FROM user_fast\n        WHERE id = NEW.creator_id;\n        INSERT INTO user_fast\n        SELECT\n            *\n        FROM\n            user_view\n        WHERE\n            id = NEW.creator_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update community number of posts\n        UPDATE\n            community_aggregates_fast\n        SET\n            number_of_posts = number_of_posts + 1\n        WHERE\n            id = NEW.community_id;\n        -- Update the hot rank on the post table\n        -- TODO this might not correctly update it, using a 1 week interval\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank,\n            hot_rank_active = pav.hot_rank_active\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION refresh_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments - 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        DELETE FROM comment_aggregates_fast\n        WHERE id = OLD.id;\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id\n        ON CONFLICT (id)\n            DO NOTHING;\n    ELSIF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates_fast\n        SELECT\n            *\n        FROM\n            comment_aggregates_view\n        WHERE\n            id = NEW.id;\n        -- Update user view due to comment count\n        UPDATE\n            user_fast\n        SET\n            number_of_comments = number_of_comments + 1\n        WHERE\n            id = NEW.creator_id;\n        -- Update post view due to comment count, new comment activity time, but only on new posts\n        -- TODO this could be done more efficiently\n        DELETE FROM post_aggregates_fast\n        WHERE id = NEW.post_id;\n        INSERT INTO post_aggregates_fast\n        SELECT\n            *\n        FROM\n            post_aggregates_view\n        WHERE\n            id = NEW.post_id\n        ON CONFLICT (id)\n            DO NOTHING;\n        -- Update the comment hot_ranks as of last week\n        UPDATE\n            comment_aggregates_fast AS caf\n        SET\n            hot_rank = cav.hot_rank,\n            hot_rank_active = cav.hot_rank_active\n        FROM\n            comment_aggregates_view AS cav\n        WHERE\n            caf.id = cav.id\n            AND (cav.published > ('now'::timestamp - '1 week'::interval));\n        -- Update the post ranks\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank = pav.hot_rank,\n            hot_rank_active = pav.hot_rank_active\n        FROM\n            post_aggregates_view AS pav\n        WHERE\n            paf.id = pav.id\n            AND (pav.published > ('now'::timestamp - '1 week'::interval));\n        -- Force the hot rank active as zero on 2 day-older posts (necro-bump)\n        UPDATE\n            post_aggregates_fast AS paf\n        SET\n            hot_rank_active = 0\n        WHERE\n            paf.id = NEW.post_id\n            AND (paf.published < ('now'::timestamp - '2 days'::interval));\n        -- Update community number of comments\n        UPDATE\n            community_aggregates_fast AS caf\n        SET\n            number_of_comments = number_of_comments + 1\n        FROM\n            post AS p\n        WHERE\n            caf.id = p.community_id\n            AND p.id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2020-10-13-212240_create_report_tables/down.sql",
    "content": "DROP VIEW comment_report_view;\n\nDROP VIEW post_report_view;\n\nDROP TABLE comment_report;\n\nDROP TABLE post_report;\n\n"
  },
  {
    "path": "migrations/2020-10-13-212240_create_report_tables/up.sql",
    "content": "CREATE TABLE comment_report (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting comment\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- comment being reported\n    original_comment_text text NOT NULL,\n    reason text NOT NULL,\n    resolved bool NOT NULL DEFAULT FALSE,\n    resolver_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL,\n    UNIQUE (comment_id, creator_id) -- users should only be able to report a comment once\n);\n\nCREATE TABLE post_report (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting post\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- post being reported\n    original_post_name varchar(100) NOT NULL,\n    original_post_url text,\n    original_post_body text,\n    reason text NOT NULL,\n    resolved bool NOT NULL DEFAULT FALSE,\n    resolver_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL,\n    UNIQUE (post_id, creator_id) -- users should only be able to report a post once\n);\n\nCREATE OR REPLACE VIEW comment_report_view AS\nSELECT\n    cr.*,\n    c.post_id,\n    c.content AS current_comment_text,\n    p.community_id,\n    -- report creator details\n    f.actor_id AS creator_actor_id,\n    f.name AS creator_name,\n    f.preferred_username AS creator_preferred_username,\n    f.avatar AS creator_avatar,\n    f.local AS creator_local,\n    -- comment creator details\n    u.id AS comment_creator_id,\n    u.actor_id AS comment_creator_actor_id,\n    u.name AS comment_creator_name,\n    u.preferred_username AS comment_creator_preferred_username,\n    u.avatar AS comment_creator_avatar,\n    u.local AS comment_creator_local,\n    -- resolver details\n    r.actor_id AS resolver_actor_id,\n    r.name AS resolver_name,\n    r.preferred_username AS resolver_preferred_username,\n    r.avatar AS resolver_avatar,\n    r.local AS resolver_local\nFROM\n    comment_report cr\n    LEFT JOIN comment c ON c.id = cr.comment_id\n    LEFT JOIN post p ON p.id = c.post_id\n    LEFT JOIN user_ u ON u.id = c.creator_id\n    LEFT JOIN user_ f ON f.id = cr.creator_id\n    LEFT JOIN user_ r ON r.id = cr.resolver_id;\n\nCREATE OR REPLACE VIEW post_report_view AS\nSELECT\n    pr.*,\n    p.name AS current_post_name,\n    p.url AS current_post_url,\n    p.body AS current_post_body,\n    p.community_id,\n    -- report creator details\n    f.actor_id AS creator_actor_id,\n    f.name AS creator_name,\n    f.preferred_username AS creator_preferred_username,\n    f.avatar AS creator_avatar,\n    f.local AS creator_local,\n    -- post creator details\n    u.id AS post_creator_id,\n    u.actor_id AS post_creator_actor_id,\n    u.name AS post_creator_name,\n    u.preferred_username AS post_creator_preferred_username,\n    u.avatar AS post_creator_avatar,\n    u.local AS post_creator_local,\n    -- resolver details\n    r.actor_id AS resolver_actor_id,\n    r.name AS resolver_name,\n    r.preferred_username AS resolver_preferred_username,\n    r.avatar AS resolver_avatar,\n    r.local AS resolver_local\nFROM\n    post_report pr\n    LEFT JOIN post p ON p.id = pr.post_id\n    LEFT JOIN user_ u ON u.id = p.creator_id\n    LEFT JOIN user_ f ON f.id = pr.creator_id\n    LEFT JOIN user_ r ON r.id = pr.resolver_id;\n\n"
  },
  {
    "path": "migrations/2020-10-23-115011_activity_ap_id_column/down.sql",
    "content": "ALTER TABLE activity\n    DROP COLUMN ap_id;\n\n"
  },
  {
    "path": "migrations/2020-10-23-115011_activity_ap_id_column/up.sql",
    "content": "ALTER TABLE activity\n    ADD COLUMN ap_id text;\n\n"
  },
  {
    "path": "migrations/2020-11-05-152724_activity_remove_user_id/down.sql",
    "content": "ALTER TABLE activity\n    ADD COLUMN user_id integer REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL;\n\nALTER TABLE activity\n    DROP COLUMN sensitive;\n\n"
  },
  {
    "path": "migrations/2020-11-05-152724_activity_remove_user_id/up.sql",
    "content": "ALTER TABLE activity\n    DROP COLUMN user_id;\n\nALTER TABLE activity\n    ADD COLUMN sensitive boolean DEFAULT TRUE;\n\n"
  },
  {
    "path": "migrations/2020-11-10-150835_community_follower_pending/down.sql",
    "content": "ALTER TABLE community_follower\n    DROP COLUMN pending;\n\n"
  },
  {
    "path": "migrations/2020-11-10-150835_community_follower_pending/up.sql",
    "content": "ALTER TABLE community_follower\n    ADD COLUMN pending boolean DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2020-11-26-134531_delete_user/down.sql",
    "content": "ALTER TABLE user_\n    DROP COLUMN deleted;\n\n"
  },
  {
    "path": "migrations/2020-11-26-134531_delete_user/up.sql",
    "content": "ALTER TABLE user_\n    ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2020-12-02-152437_create_site_aggregates/down.sql",
    "content": "-- Site aggregates\nDROP TABLE site_aggregates;\n\nDROP TRIGGER site_aggregates_site ON site;\n\nDROP TRIGGER site_aggregates_user_insert ON user_;\n\nDROP TRIGGER site_aggregates_user_delete ON user_;\n\nDROP TRIGGER site_aggregates_post_insert ON post;\n\nDROP TRIGGER site_aggregates_post_delete ON post;\n\nDROP TRIGGER site_aggregates_comment_insert ON comment;\n\nDROP TRIGGER site_aggregates_comment_delete ON comment;\n\nDROP TRIGGER site_aggregates_community_insert ON community;\n\nDROP TRIGGER site_aggregates_community_delete ON community;\n\nDROP FUNCTION site_aggregates_site, site_aggregates_user_insert, site_aggregates_user_delete, site_aggregates_post_insert, site_aggregates_post_delete, site_aggregates_comment_insert, site_aggregates_comment_delete, site_aggregates_community_insert, site_aggregates_community_delete;\n\n"
  },
  {
    "path": "migrations/2020-12-02-152437_create_site_aggregates/up.sql",
    "content": "-- Add site aggregates\nCREATE TABLE site_aggregates (\n    id serial PRIMARY KEY,\n    site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    users bigint NOT NULL DEFAULT 1,\n    posts bigint NOT NULL DEFAULT 0,\n    comments bigint NOT NULL DEFAULT 0,\n    communities bigint NOT NULL DEFAULT 0\n);\n\nINSERT INTO site_aggregates (site_id, users, posts, comments, communities)\nSELECT\n    id AS site_id,\n    (\n        SELECT\n            coalesce(count(*), 0)\n        FROM\n            user_\n        WHERE\n            local = TRUE) AS users,\n    (\n        SELECT\n            coalesce(count(*), 0)\n        FROM\n            post\n        WHERE\n            local = TRUE) AS posts,\n    (\n        SELECT\n            coalesce(count(*), 0)\n        FROM\n            comment\n        WHERE\n            local = TRUE) AS comments,\n    (\n        SELECT\n            coalesce(count(*), 0)\n        FROM\n            community\n        WHERE\n            local = TRUE) AS communities\nFROM\n    site;\n\n-- initial site add\nCREATE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER site_aggregates_site\n    AFTER INSERT OR DELETE ON site\n    FOR EACH ROW\n    EXECUTE PROCEDURE site_aggregates_site ();\n\n-- Add site aggregate triggers\n-- user\nCREATE FUNCTION site_aggregates_user_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        users = users + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_user_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Join to site since the creator might not be there anymore\n    UPDATE\n        site_aggregates sa\n    SET\n        users = users - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER site_aggregates_user_insert\n    AFTER INSERT ON user_\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_user_insert ();\n\nCREATE TRIGGER site_aggregates_user_delete\n    AFTER DELETE ON user_\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_user_delete ();\n\n-- post\nCREATE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        posts = posts + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_post_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        posts = posts - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER site_aggregates_post_insert\n    AFTER INSERT ON post\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_insert ();\n\nCREATE TRIGGER site_aggregates_post_delete\n    AFTER DELETE ON post\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_delete ();\n\n-- comment\nCREATE FUNCTION site_aggregates_comment_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        comments = comments + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_comment_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        comments = comments - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER site_aggregates_comment_insert\n    AFTER INSERT ON comment\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_insert ();\n\nCREATE TRIGGER site_aggregates_comment_delete\n    AFTER DELETE ON comment\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_delete ();\n\n-- community\nCREATE FUNCTION site_aggregates_community_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        communities = communities + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_community_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        communities = communities - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER site_aggregates_community_insert\n    AFTER INSERT ON community\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_insert ();\n\nCREATE TRIGGER site_aggregates_community_delete\n    AFTER DELETE ON community\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_delete ();\n\n"
  },
  {
    "path": "migrations/2020-12-03-035643_create_user_aggregates/down.sql",
    "content": "-- User aggregates\nDROP TABLE user_aggregates;\n\nDROP TRIGGER user_aggregates_user ON user_;\n\nDROP TRIGGER user_aggregates_post_count ON post;\n\nDROP TRIGGER user_aggregates_post_score ON post_like;\n\nDROP TRIGGER user_aggregates_comment_count ON comment;\n\nDROP TRIGGER user_aggregates_comment_score ON comment_like;\n\nDROP FUNCTION user_aggregates_user, user_aggregates_post_count, user_aggregates_post_score, user_aggregates_comment_count, user_aggregates_comment_score;\n\n"
  },
  {
    "path": "migrations/2020-12-03-035643_create_user_aggregates/up.sql",
    "content": "-- Add user aggregates\nCREATE TABLE user_aggregates (\n    id serial PRIMARY KEY,\n    user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_count bigint NOT NULL DEFAULT 0,\n    post_score bigint NOT NULL DEFAULT 0,\n    comment_count bigint NOT NULL DEFAULT 0,\n    comment_score bigint NOT NULL DEFAULT 0,\n    UNIQUE (user_id)\n);\n\nINSERT INTO user_aggregates (user_id, post_count, post_score, comment_count, comment_score)\nSELECT\n    u.id,\n    coalesce(pd.posts, 0),\n    coalesce(pd.score, 0),\n    coalesce(cd.comments, 0),\n    coalesce(cd.score, 0)\nFROM\n    user_ u\n    LEFT JOIN (\n        SELECT\n            p.creator_id,\n            count(DISTINCT p.id) AS posts,\n            sum(pl.score) AS score\n        FROM\n            post p\n            LEFT JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            p.creator_id) pd ON u.id = pd.creator_id\n    LEFT JOIN (\n        SELECT\n            c.creator_id,\n            count(DISTINCT c.id) AS comments,\n            sum(cl.score) AS score\n        FROM\n            comment c\n            LEFT JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            c.creator_id) cd ON u.id = cd.creator_id;\n\n-- Add user aggregate triggers\n-- initial user add\nCREATE FUNCTION user_aggregates_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO user_aggregates (user_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM user_aggregates\n        WHERE user_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_user\n    AFTER INSERT OR DELETE ON user_\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_user ();\n\n-- post count\nCREATE FUNCTION user_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            user_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            user_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            user_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                user_ u\n            LEFT JOIN post p ON u.id = p.creator_id\n            LEFT JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            u.id) pd\n    WHERE\n        ua.user_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_post_count ();\n\n-- post score\nCREATE FUNCTION user_aggregates_post_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = post_score + NEW.score\n        FROM\n            post p\n        WHERE\n            ua.user_id = p.creator_id\n            AND p.id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = post_score - OLD.score\n        FROM\n            post p\n        WHERE\n            ua.user_id = p.creator_id\n            AND p.id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_post_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_post_score ();\n\n-- comment count\nCREATE FUNCTION user_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            user_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            user_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            user_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                user_ u\n            LEFT JOIN comment c ON u.id = c.creator_id\n            LEFT JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            u.id) cd\n    WHERE\n        ua.user_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_comment_count ();\n\n-- comment score\nCREATE FUNCTION user_aggregates_comment_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = comment_score + NEW.score\n        FROM\n            comment c\n        WHERE\n            ua.user_id = c.creator_id\n            AND c.id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = comment_score - OLD.score\n        FROM\n            comment c\n        WHERE\n            ua.user_id = c.creator_id\n            AND c.id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_comment_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_comment_score ();\n\n"
  },
  {
    "path": "migrations/2020-12-04-183345_create_community_aggregates/down.sql",
    "content": "-- community aggregates\nDROP TABLE community_aggregates;\n\nDROP TRIGGER community_aggregates_community ON community;\n\nDROP TRIGGER community_aggregates_post_count ON post;\n\nDROP TRIGGER community_aggregates_comment_count ON comment;\n\nDROP TRIGGER community_aggregates_subscriber_count ON community_follower;\n\nDROP FUNCTION community_aggregates_community, community_aggregates_post_count, community_aggregates_comment_count, community_aggregates_subscriber_count;\n\n"
  },
  {
    "path": "migrations/2020-12-04-183345_create_community_aggregates/up.sql",
    "content": "-- Add community aggregates\nCREATE TABLE community_aggregates (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    subscribers bigint NOT NULL DEFAULT 0,\n    posts bigint NOT NULL DEFAULT 0,\n    comments bigint NOT NULL DEFAULT 0,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (community_id)\n);\n\nINSERT INTO community_aggregates (community_id, subscribers, posts, comments, published)\nSELECT\n    c.id,\n    coalesce(cf.subs, 0) AS subscribers,\n    coalesce(cd.posts, 0) AS posts,\n    coalesce(cd.comments, 0) AS comments,\n    c.published\nFROM\n    community c\n    LEFT JOIN (\n        SELECT\n            p.community_id,\n            count(DISTINCT p.id) AS posts,\n            count(DISTINCT ct.id) AS comments\n        FROM\n            post p\n            LEFT JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            p.community_id) cd ON cd.community_id = c.id\n    LEFT JOIN (\n        SELECT\n            community_follower.community_id,\n            count(*) AS subs\n        FROM\n            community_follower\n        GROUP BY\n            community_follower.community_id) cf ON cf.community_id = c.id;\n\n-- Add community aggregate triggers\n-- initial community add\nCREATE FUNCTION community_aggregates_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates (community_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates\n        WHERE community_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER community_aggregates_community\n    AFTER INSERT OR DELETE ON community\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_community ();\n\n-- post count\nCREATE FUNCTION community_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts + 1\n        WHERE\n            community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts - 1\n        WHERE\n            community_id = OLD.community_id;\n        -- Update the counts if the post got deleted\n        UPDATE\n            community_aggregates ca\n        SET\n            posts = coalesce(cd.posts, 0),\n            comments = coalesce(cd.comments, 0)\n        FROM (\n            SELECT\n                c.id,\n                count(DISTINCT p.id) AS posts,\n                count(DISTINCT ct.id) AS comments\n            FROM\n                community c\n            LEFT JOIN post p ON c.id = p.community_id\n            LEFT JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            c.id) cd\n    WHERE\n        ca.community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER community_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_post_count ();\n\n-- comment count\nCREATE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER community_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_comment_count ();\n\n-- subscriber count\nCREATE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers + 1\n        WHERE\n            community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers - 1\n        WHERE\n            community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER community_aggregates_subscriber_count\n    AFTER INSERT OR DELETE ON community_follower\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_subscriber_count ();\n\n"
  },
  {
    "path": "migrations/2020-12-10-152350_create_post_aggregates/down.sql",
    "content": "-- post aggregates\nDROP TABLE post_aggregates;\n\nDROP TRIGGER post_aggregates_post ON post;\n\nDROP TRIGGER post_aggregates_comment_count ON comment;\n\nDROP TRIGGER post_aggregates_score ON post_like;\n\nDROP TRIGGER post_aggregates_stickied ON post;\n\nDROP FUNCTION post_aggregates_post, post_aggregates_comment_count, post_aggregates_score, post_aggregates_stickied;\n\n"
  },
  {
    "path": "migrations/2020-12-10-152350_create_post_aggregates/up.sql",
    "content": "-- Add post aggregates\nCREATE TABLE post_aggregates (\n    id serial PRIMARY KEY,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    comments bigint NOT NULL DEFAULT 0,\n    score bigint NOT NULL DEFAULT 0,\n    upvotes bigint NOT NULL DEFAULT 0,\n    downvotes bigint NOT NULL DEFAULT 0,\n    stickied boolean NOT NULL DEFAULT FALSE,\n    published timestamp NOT NULL DEFAULT now(),\n    newest_comment_time timestamp NOT NULL DEFAULT now(),\n    UNIQUE (post_id)\n);\n\nINSERT INTO post_aggregates (post_id, comments, score, upvotes, downvotes, stickied, published, newest_comment_time)\nSELECT\n    p.id,\n    coalesce(ct.comments, 0::bigint) AS comments,\n    coalesce(pl.score, 0::bigint) AS score,\n    coalesce(pl.upvotes, 0::bigint) AS upvotes,\n    coalesce(pl.downvotes, 0::bigint) AS downvotes,\n    p.stickied,\n    p.published,\n    greatest (ct.recent_comment_time, p.published) AS newest_activity_time\nFROM\n    post p\n    LEFT JOIN (\n        SELECT\n            comment.post_id,\n            count(*) AS comments,\n            max(comment.published) AS recent_comment_time\n        FROM\n            comment\n        GROUP BY\n            comment.post_id) ct ON ct.post_id = p.id\n    LEFT JOIN (\n        SELECT\n            post_like.post_id,\n            sum(post_like.score) AS score,\n            sum(post_like.score) FILTER (WHERE post_like.score = 1) AS upvotes,\n            - sum(post_like.score) FILTER (WHERE post_like.score = '-1'::integer) AS downvotes\n        FROM\n            post_like\n        GROUP BY\n            post_like.post_id) pl ON pl.post_id = p.id;\n\n-- Add community aggregate triggers\n-- initial post add\nCREATE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_post\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_post ();\n\n-- comment count\nCREATE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id\n            AND published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_comment_count ();\n\n-- post score\nCREATE FUNCTION post_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_score ();\n\n-- post stickied\nCREATE FUNCTION post_aggregates_stickied ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        stickied = NEW.stickied\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_stickied\n    AFTER UPDATE ON post\n    FOR EACH ROW\n    WHEN (OLD.stickied IS DISTINCT FROM NEW.stickied)\n    EXECUTE PROCEDURE post_aggregates_stickied ();\n\n"
  },
  {
    "path": "migrations/2020-12-14-020038_create_comment_aggregates/down.sql",
    "content": "-- comment aggregates\nDROP TABLE comment_aggregates;\n\nDROP TRIGGER comment_aggregates_comment ON comment;\n\nDROP TRIGGER comment_aggregates_score ON comment_like;\n\nDROP FUNCTION comment_aggregates_comment, comment_aggregates_score;\n\n"
  },
  {
    "path": "migrations/2020-12-14-020038_create_comment_aggregates/up.sql",
    "content": "-- Add comment aggregates\nCREATE TABLE comment_aggregates (\n    id serial PRIMARY KEY,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    score bigint NOT NULL DEFAULT 0,\n    upvotes bigint NOT NULL DEFAULT 0,\n    downvotes bigint NOT NULL DEFAULT 0,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (comment_id)\n);\n\nINSERT INTO comment_aggregates (comment_id, score, upvotes, downvotes, published)\nSELECT\n    c.id,\n    COALESCE(cl.total, 0::bigint) AS score,\n    COALESCE(cl.up, 0::bigint) AS upvotes,\n    COALESCE(cl.down, 0::bigint) AS downvotes,\n    c.published\nFROM\n    comment c\n    LEFT JOIN (\n        SELECT\n            l.comment_id AS id,\n            sum(l.score) AS total,\n            count(\n                CASE WHEN l.score = 1 THEN\n                    1\n                ELSE\n                    NULL::integer\n                END) AS up,\n            count(\n                CASE WHEN l.score = '-1'::integer THEN\n                    1\n                ELSE\n                    NULL::integer\n                END) AS down\n        FROM\n            comment_like l\n        GROUP BY\n            l.comment_id) cl ON cl.id = c.id;\n\n-- Add comment aggregate triggers\n-- initial comment add\nCREATE FUNCTION comment_aggregates_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates (comment_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates\n        WHERE comment_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER comment_aggregates_comment\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE comment_aggregates_comment ();\n\n-- comment score\nCREATE FUNCTION comment_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            ca.comment_id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to comment because that comment may not exist anymore\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        FROM\n            comment c\n        WHERE\n            ca.comment_id = c.id\n            AND ca.comment_id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER comment_aggregates_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE comment_aggregates_score ();\n\n"
  },
  {
    "path": "migrations/2020-12-17-030456_create_alias_views/down.sql",
    "content": "DROP VIEW user_alias_1, user_alias_2, comment_alias_1;\n\n"
  },
  {
    "path": "migrations/2020-12-17-030456_create_alias_views/up.sql",
    "content": "-- Some view that act as aliases\n-- unfortunately necessary, since diesel doesn't have self joins\n-- or alias support yet\nCREATE VIEW user_alias_1 AS\nSELECT\n    *\nFROM\n    user_;\n\nCREATE VIEW user_alias_2 AS\nSELECT\n    *\nFROM\n    user_;\n\nCREATE VIEW comment_alias_1 AS\nSELECT\n    *\nFROM\n    comment;\n\n"
  },
  {
    "path": "migrations/2020-12-17-031053_remove_fast_tables_and_views/down.sql",
    "content": "-- There is no restore for this, it would require every view, table, index, etc.\n-- If you want to save past this point, you should make a DB backup.\nSELECT\n    *\nFROM\n    user_\nLIMIT 1;\n\n"
  },
  {
    "path": "migrations/2020-12-17-031053_remove_fast_tables_and_views/up.sql",
    "content": "-- Drop triggers\nDROP TRIGGER IF EXISTS refresh_comment ON comment;\n\nDROP TRIGGER IF EXISTS refresh_comment_like ON comment_like;\n\nDROP TRIGGER IF EXISTS refresh_community ON community;\n\nDROP TRIGGER IF EXISTS refresh_community_follower ON community_follower;\n\nDROP TRIGGER IF EXISTS refresh_community_user_ban ON community_user_ban;\n\nDROP TRIGGER IF EXISTS refresh_post ON post;\n\nDROP TRIGGER IF EXISTS refresh_post_like ON post_like;\n\nDROP TRIGGER IF EXISTS refresh_user ON user_;\n\n-- Drop functions\nDROP FUNCTION IF EXISTS refresh_comment, refresh_comment_like, refresh_community, refresh_community_follower, refresh_community_user_ban, refresh_post, refresh_post_like, refresh_private_message, refresh_user CASCADE;\n\n-- Drop views\nDROP VIEW IF EXISTS comment_aggregates_view, comment_fast_view, comment_report_view, comment_view, community_aggregates_view, community_fast_view, community_follower_view, community_moderator_view, community_user_ban_view, community_view, mod_add_community_view, mod_add_view, mod_ban_from_community_view, mod_ban_view, mod_lock_post_view, mod_remove_comment_view, mod_remove_community_view, mod_remove_post_view, mod_sticky_post_view, post_aggregates_view, post_fast_view, post_report_view, post_view, private_message_view, reply_fast_view, site_view, user_mention_fast_view, user_mention_view, user_view CASCADE;\n\n-- Drop fast tables\nDROP TABLE IF EXISTS comment_aggregates_fast, community_aggregates_fast, post_aggregates_fast, user_fast CASCADE;\n\n"
  },
  {
    "path": "migrations/2021-01-05-200932_add_hot_rank_indexes/down.sql",
    "content": "-- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nBEGIN\n    -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600\n    RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer;\nEND;\n$$\nLANGUAGE plpgsql;\n\nDROP INDEX idx_post_aggregates_hot, idx_post_aggregates_stickied_hot, idx_post_aggregates_active, idx_post_aggregates_stickied_active, idx_post_aggregates_score, idx_post_aggregates_stickied_score, idx_post_aggregates_published, idx_post_aggregates_stickied_published, idx_comment_published, idx_comment_aggregates_hot, idx_comment_aggregates_score, idx_user_published, idx_user_aggregates_comment_score, idx_community_published, idx_community_aggregates_hot, idx_community_aggregates_subscribers;\n\n"
  },
  {
    "path": "migrations/2021-01-05-200932_add_hot_rank_indexes/up.sql",
    "content": "-- Need to add immutable to the hot_rank function in order to index by it\n-- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nBEGIN\n    -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600\n    RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE;\n\n-- Post_aggregates\nCREATE INDEX idx_post_aggregates_stickied_hot ON post_aggregates (stickied DESC, hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_active ON post_aggregates (stickied DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_score ON post_aggregates (stickied DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_published ON post_aggregates (stickied DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC);\n\n-- Comment\nCREATE INDEX idx_comment_published ON comment (published DESC);\n\n-- Comment_aggregates\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC);\n\n-- User\nCREATE INDEX idx_user_published ON user_ (published DESC);\n\n-- User_aggregates\nCREATE INDEX idx_user_aggregates_comment_score ON user_aggregates (comment_score DESC);\n\n-- Community\nCREATE INDEX idx_community_published ON community (published DESC);\n\n-- Community_aggregates\nCREATE INDEX idx_community_aggregates_hot ON community_aggregates (hot_rank (subscribers, published) DESC, published DESC);\n\nCREATE INDEX idx_community_aggregates_subscribers ON community_aggregates (subscribers DESC);\n\n"
  },
  {
    "path": "migrations/2021-01-26-173850_default_actor_id/down.sql",
    "content": "CREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '')\n    FROM\n        generate_series(1, 20)\n$$;\n\n"
  },
  {
    "path": "migrations/2021-01-26-173850_default_actor_id/up.sql",
    "content": "CREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'http://changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '')\n    FROM\n        generate_series(1, 20)\n$$;\n\n"
  },
  {
    "path": "migrations/2021-01-27-202728_active_users_monthly/down.sql",
    "content": "ALTER TABLE site_aggregates\n    DROP COLUMN users_active_day,\n    DROP COLUMN users_active_week,\n    DROP COLUMN users_active_month,\n    DROP COLUMN users_active_half_year;\n\nALTER TABLE community_aggregates\n    DROP COLUMN users_active_day,\n    DROP COLUMN users_active_week,\n    DROP COLUMN users_active_month,\n    DROP COLUMN users_active_half_year;\n\nDROP FUNCTION site_aggregates_activity (i text);\n\nDROP FUNCTION community_aggregates_activity (i text);\n\n"
  },
  {
    "path": "migrations/2021-01-27-202728_active_users_monthly/up.sql",
    "content": "-- Add monthly and half yearly active columns for site and community aggregates\n-- These columns don't need to be updated with a trigger, so they're saved daily via queries\nALTER TABLE site_aggregates\n    ADD COLUMN users_active_day bigint NOT NULL DEFAULT 0;\n\nALTER TABLE site_aggregates\n    ADD COLUMN users_active_week bigint NOT NULL DEFAULT 0;\n\nALTER TABLE site_aggregates\n    ADD COLUMN users_active_month bigint NOT NULL DEFAULT 0;\n\nALTER TABLE site_aggregates\n    ADD COLUMN users_active_half_year bigint NOT NULL DEFAULT 0;\n\nALTER TABLE community_aggregates\n    ADD COLUMN users_active_day bigint NOT NULL DEFAULT 0;\n\nALTER TABLE community_aggregates\n    ADD COLUMN users_active_week bigint NOT NULL DEFAULT 0;\n\nALTER TABLE community_aggregates\n    ADD COLUMN users_active_month bigint NOT NULL DEFAULT 0;\n\nALTER TABLE community_aggregates\n    ADD COLUMN users_active_half_year bigint NOT NULL DEFAULT 0;\n\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS int\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN user_ u ON c.creator_id = u.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN user_ u ON p.creator_id = u.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE) a;\n    RETURN count_;\nEND;\n$$;\n\nUPDATE\n    site_aggregates\nSET\n    users_active_day = (\n        SELECT\n            *\n        FROM\n            site_aggregates_activity ('1 day'));\n\nUPDATE\n    site_aggregates\nSET\n    users_active_week = (\n        SELECT\n            *\n        FROM\n            site_aggregates_activity ('1 week'));\n\nUPDATE\n    site_aggregates\nSET\n    users_active_month = (\n        SELECT\n            *\n        FROM\n            site_aggregates_activity ('1 month'));\n\nUPDATE\n    site_aggregates\nSET\n    users_active_half_year = (\n        SELECT\n            *\n        FROM\n            site_aggregates_activity ('6 months'));\n\nCREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n    WHERE\n        p.published > ('now'::timestamp - i::interval)) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\nUPDATE\n    community_aggregates ca\nSET\n    users_active_day = mv.count_\nFROM\n    community_aggregates_activity ('1 day') mv\nWHERE\n    ca.community_id = mv.community_id_;\n\nUPDATE\n    community_aggregates ca\nSET\n    users_active_week = mv.count_\nFROM\n    community_aggregates_activity ('1 week') mv\nWHERE\n    ca.community_id = mv.community_id_;\n\nUPDATE\n    community_aggregates ca\nSET\n    users_active_month = mv.count_\nFROM\n    community_aggregates_activity ('1 month') mv\nWHERE\n    ca.community_id = mv.community_id_;\n\nUPDATE\n    community_aggregates ca\nSET\n    users_active_half_year = mv.count_\nFROM\n    community_aggregates_activity ('6 months') mv\nWHERE\n    ca.community_id = mv.community_id_;\n\n"
  },
  {
    "path": "migrations/2021-01-31-050334_add_forum_sort_index/down.sql",
    "content": "DROP INDEX idx_post_aggregates_comments;\n\n"
  },
  {
    "path": "migrations/2021-01-31-050334_add_forum_sort_index/up.sql",
    "content": "CREATE INDEX idx_post_aggregates_comments ON post_aggregates (comments DESC);\n\n"
  },
  {
    "path": "migrations/2021-02-02-153240_apub_columns/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN followers_url;\n\nALTER TABLE community\n    DROP COLUMN inbox_url;\n\nALTER TABLE community\n    DROP COLUMN shared_inbox_url;\n\nALTER TABLE user_\n    DROP COLUMN inbox_url;\n\nALTER TABLE user_\n    DROP COLUMN shared_inbox_url;\n\n"
  },
  {
    "path": "migrations/2021-02-02-153240_apub_columns/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN followers_url varchar(255) NOT NULL DEFAULT generate_unique_changeme ();\n\nALTER TABLE community\n    ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme ();\n\nALTER TABLE community\n    ADD COLUMN shared_inbox_url varchar(255);\n\nALTER TABLE user_\n    ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme ();\n\nALTER TABLE user_\n    ADD COLUMN shared_inbox_url varchar(255);\n\nALTER TABLE community\n    ADD CONSTRAINT idx_community_followers_url UNIQUE (followers_url);\n\nALTER TABLE community\n    ADD CONSTRAINT idx_community_inbox_url UNIQUE (inbox_url);\n\nALTER TABLE user_\n    ADD CONSTRAINT idx_user_inbox_url UNIQUE (inbox_url);\n\n"
  },
  {
    "path": "migrations/2021-02-10-164051_add_new_comments_sort_index/down.sql",
    "content": "DROP INDEX idx_post_aggregates_newest_comment_time, idx_post_aggregates_stickied_newest_comment_time, idx_post_aggregates_stickied_comments;\n\nALTER TABLE post_aggregates\n    DROP COLUMN newest_comment_time;\n\nALTER TABLE post_aggregates RENAME COLUMN newest_comment_time_necro TO newest_comment_time;\n\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id\n            AND published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-02-10-164051_add_new_comments_sort_index/up.sql",
    "content": "-- First rename current newest comment time to newest_comment_time_necro\n-- necro means that time is limited to 2 days, whereas newest_comment_time ignores that.\nALTER TABLE post_aggregates RENAME COLUMN newest_comment_time TO newest_comment_time_necro;\n\n-- Add the newest_comment_time column\nALTER TABLE post_aggregates\n    ADD COLUMN newest_comment_time timestamp NOT NULL DEFAULT now();\n\n-- Set the current newest_comment_time based on the old ones\nUPDATE\n    post_aggregates\nSET\n    newest_comment_time = newest_comment_time_necro;\n\n-- Add the indexes for this new column\nCREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_newest_comment_time ON post_aggregates (stickied DESC, newest_comment_time DESC);\n\n-- Forgot to add index w/ stickied first for most comments:\nCREATE INDEX idx_post_aggregates_stickied_comments ON post_aggregates (stickied DESC, comments DESC);\n\n-- Alter the comment trigger to set the newest_comment_time, and newest_comment_time_necro\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1,\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id\n            AND published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-02-13-210612_set_correct_aggregates_time_columns/down.sql",
    "content": "CREATE OR REPLACE FUNCTION comment_aggregates_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates (comment_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates\n        WHERE comment_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates (community_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates\n        WHERE community_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-02-13-210612_set_correct_aggregates_time_columns/up.sql",
    "content": "-- The published and updated columns on the aggregates tables are using now(),\n-- when they should use the correct published or updated columns\n-- This is mainly a problem with federated posts being fetched\nCREATE OR REPLACE FUNCTION comment_aggregates_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates (comment_id, published)\n            VALUES (NEW.id, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates\n        WHERE comment_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro)\n            VALUES (NEW.id, NEW.published, NEW.published, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates (community_id, published)\n            VALUES (NEW.id, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates\n        WHERE community_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-02-25-112959_remove-categories/down.sql",
    "content": "CREATE TABLE category (\n    id serial PRIMARY KEY,\n    name varchar(100) NOT NULL UNIQUE\n);\n\nINSERT INTO category (name)\nVALUES\n    ('Discussion'),\n    ('Humor/Memes'),\n    ('Gaming'),\n    ('Movies'),\n    ('TV'),\n    ('Music'),\n    ('Literature'),\n    ('Comics'),\n    ('Photography'),\n    ('Art'),\n    ('Learning'),\n    ('DIY'),\n    ('Lifestyle'),\n    ('News'),\n    ('Politics'),\n    ('Society'),\n    ('Gender/Identity/Sexuality'),\n    ('Race/Colonisation'),\n    ('Religion'),\n    ('Science/Technology'),\n    ('Programming/Software'),\n    ('Health/Sports/Fitness'),\n    ('Porn'),\n    ('Places'),\n    ('Meta'),\n    ('Other');\n\nALTER TABLE community\n    ADD category_id int REFERENCES category ON UPDATE CASCADE ON DELETE CASCADE NOT NULL DEFAULT 1;\n\n-- Default is only for existing rows\nALTER TABLE community\n    ALTER COLUMN category_id DROP DEFAULT;\n\nCREATE INDEX idx_community_category ON community (category_id);\n\n"
  },
  {
    "path": "migrations/2021-02-25-112959_remove-categories/up.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN category_id;\n\nDROP TABLE category;\n\n"
  },
  {
    "path": "migrations/2021-02-28-162616_clean_empty_post_urls/down.sql",
    "content": "-- This is a clean-up migration that cannot be undone,\n-- but Diesel requires a non-empty script so run a no-op.\nSELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2021-02-28-162616_clean_empty_post_urls/up.sql",
    "content": "UPDATE\n    post\nSET\n    url = NULL\nWHERE\n    url = '';\n\n"
  },
  {
    "path": "migrations/2021-03-04-040229_clean_icon_urls/down.sql",
    "content": "-- This is a clean-up migration that cannot be undone,\n-- but Diesel requires a non-empty script so run a no-op.\nSELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2021-03-04-040229_clean_icon_urls/up.sql",
    "content": "-- If these are not urls, it will crash the server\nUPDATE\n    user_\nSET\n    avatar = NULL\nWHERE\n    avatar NOT LIKE 'http%';\n\nUPDATE\n    user_\nSET\n    banner = NULL\nWHERE\n    banner NOT LIKE 'http%';\n\nUPDATE\n    community\nSET\n    icon = NULL\nWHERE\n    icon NOT LIKE 'http%';\n\nUPDATE\n    community\nSET\n    banner = NULL\nWHERE\n    banner NOT LIKE 'http%';\n\n"
  },
  {
    "path": "migrations/2021-03-09-171136_split_user_table_2/down.sql",
    "content": "-- post_saved\nALTER TABLE post_saved RENAME COLUMN person_id TO user_id;\n\nALTER TABLE post_saved RENAME CONSTRAINT post_saved_post_id_person_id_key TO post_saved_post_id_user_id_key;\n\nALTER TABLE post_saved RENAME CONSTRAINT post_saved_person_id_fkey TO post_saved_user_id_fkey;\n\n-- post_read\nALTER TABLE post_read RENAME COLUMN person_id TO user_id;\n\nALTER TABLE post_read RENAME CONSTRAINT post_read_post_id_person_id_key TO post_read_post_id_user_id_key;\n\nALTER TABLE post_read RENAME CONSTRAINT post_read_person_id_fkey TO post_read_user_id_fkey;\n\n-- post_like\nALTER TABLE post_like RENAME COLUMN person_id TO user_id;\n\nALTER INDEX idx_post_like_person RENAME TO idx_post_like_user;\n\nALTER TABLE post_like RENAME CONSTRAINT post_like_post_id_person_id_key TO post_like_post_id_user_id_key;\n\nALTER TABLE post_like RENAME CONSTRAINT post_like_person_id_fkey TO post_like_user_id_fkey;\n\n-- password_reset_request\nDELETE FROM password_reset_request;\n\nALTER TABLE password_reset_request\n    DROP COLUMN local_user_id;\n\nALTER TABLE password_reset_request\n    ADD COLUMN user_id integer NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- mod_sticky_post\nALTER TABLE mod_sticky_post RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_sticky_post RENAME CONSTRAINT mod_sticky_post_mod_person_id_fkey TO mod_sticky_post_mod_user_id_fkey;\n\n-- mod_remove_post\nALTER TABLE mod_remove_post RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_remove_post RENAME CONSTRAINT mod_remove_post_mod_person_id_fkey TO mod_remove_post_mod_user_id_fkey;\n\n-- mod_remove_community\nALTER TABLE mod_remove_community RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_remove_community RENAME CONSTRAINT mod_remove_community_mod_person_id_fkey TO mod_remove_community_mod_user_id_fkey;\n\n-- mod_remove_comment\nALTER TABLE mod_remove_comment RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_remove_comment RENAME CONSTRAINT mod_remove_comment_mod_person_id_fkey TO mod_remove_comment_mod_user_id_fkey;\n\n-- mod_lock_post\nALTER TABLE mod_lock_post RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_lock_post RENAME CONSTRAINT mod_lock_post_mod_person_id_fkey TO mod_lock_post_mod_user_id_fkey;\n\n-- mod_add_community\nALTER TABLE mod_ban_from_community RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN other_person_id TO other_user_id;\n\nALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_mod_person_id_fkey TO mod_ban_from_community_mod_user_id_fkey;\n\nALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_other_person_id_fkey TO mod_ban_from_community_other_user_id_fkey;\n\n-- mod_ban\nALTER TABLE mod_ban RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_ban RENAME COLUMN other_person_id TO other_user_id;\n\nALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_mod_person_id_fkey TO mod_ban_mod_user_id_fkey;\n\nALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_other_person_id_fkey TO mod_ban_other_user_id_fkey;\n\n-- mod_add_community\nALTER TABLE mod_add_community RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_add_community RENAME COLUMN other_person_id TO other_user_id;\n\nALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_mod_person_id_fkey TO mod_add_community_mod_user_id_fkey;\n\nALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_other_person_id_fkey TO mod_add_community_other_user_id_fkey;\n\n-- mod_add\nALTER TABLE mod_add RENAME COLUMN mod_person_id TO mod_user_id;\n\nALTER TABLE mod_add RENAME COLUMN other_person_id TO other_user_id;\n\nALTER TABLE mod_add RENAME CONSTRAINT mod_add_mod_person_id_fkey TO mod_add_mod_user_id_fkey;\n\nALTER TABLE mod_add RENAME CONSTRAINT mod_add_other_person_id_fkey TO mod_add_other_user_id_fkey;\n\n-- community_user_ban\nALTER TABLE community_person_ban RENAME TO community_user_ban;\n\nALTER SEQUENCE community_person_ban_id_seq\n    RENAME TO community_user_ban_id_seq;\n\nALTER TABLE community_user_ban RENAME COLUMN person_id TO user_id;\n\nALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_pkey TO community_user_ban_pkey;\n\nALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_community_id_fkey TO community_user_ban_community_id_fkey;\n\nALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_community_id_person_id_key TO community_user_ban_community_id_user_id_key;\n\nALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_person_id_fkey TO community_user_ban_user_id_fkey;\n\n-- community_moderator\nALTER TABLE community_moderator RENAME COLUMN person_id TO user_id;\n\nALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_community_id_person_id_key TO community_moderator_community_id_user_id_key;\n\nALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_person_id_fkey TO community_moderator_user_id_fkey;\n\n-- community_follower\nALTER TABLE community_follower RENAME COLUMN person_id TO user_id;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_follower_community_id_person_id_key TO community_follower_community_id_user_id_key;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_follower_person_id_fkey TO community_follower_user_id_fkey;\n\n-- comment_saved\nALTER TABLE comment_saved RENAME COLUMN person_id TO user_id;\n\nALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_comment_id_person_id_key TO comment_saved_comment_id_user_id_key;\n\nALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_person_id_fkey TO comment_saved_user_id_fkey;\n\n-- comment_like\nALTER TABLE comment_like RENAME COLUMN person_id TO user_id;\n\nALTER INDEX idx_comment_like_person RENAME TO idx_comment_like_user;\n\nALTER TABLE comment_like RENAME CONSTRAINT comment_like_comment_id_person_id_key TO comment_like_comment_id_user_id_key;\n\nALTER TABLE comment_like RENAME CONSTRAINT comment_like_person_id_fkey TO comment_like_user_id_fkey;\n\n-- user_ban\nALTER TABLE person_ban RENAME TO user_ban;\n\nALTER SEQUENCE person_ban_id_seq\n    RENAME TO user_ban_id_seq;\n\nALTER INDEX person_ban_pkey RENAME TO user_ban_pkey;\n\nALTER INDEX person_ban_person_id_key RENAME TO user_ban_user_id_key;\n\nALTER TABLE user_ban RENAME COLUMN person_id TO user_id;\n\nALTER TABLE user_ban RENAME CONSTRAINT person_ban_person_id_fkey TO user_ban_user_id_fkey;\n\n-- user_mention\nALTER TABLE person_mention RENAME TO user_mention;\n\nALTER SEQUENCE person_mention_id_seq\n    RENAME TO user_mention_id_seq;\n\nALTER INDEX person_mention_pkey RENAME TO user_mention_pkey;\n\nALTER INDEX person_mention_recipient_id_comment_id_key RENAME TO user_mention_recipient_id_comment_id_key;\n\nALTER TABLE user_mention RENAME CONSTRAINT person_mention_comment_id_fkey TO user_mention_comment_id_fkey;\n\nALTER TABLE user_mention RENAME CONSTRAINT person_mention_recipient_id_fkey TO user_mention_recipient_id_fkey;\n\n-- User aggregates table\nALTER TABLE person_aggregates RENAME TO user_aggregates;\n\nALTER SEQUENCE person_aggregates_id_seq\n    RENAME TO user_aggregates_id_seq;\n\nALTER TABLE user_aggregates RENAME COLUMN person_id TO user_id;\n\n-- Indexes\nALTER INDEX person_aggregates_pkey RENAME TO user_aggregates_pkey;\n\nALTER INDEX idx_person_aggregates_comment_score RENAME TO idx_user_aggregates_comment_score;\n\nALTER INDEX person_aggregates_person_id_key RENAME TO user_aggregates_user_id_key;\n\nALTER TABLE user_aggregates RENAME CONSTRAINT person_aggregates_person_id_fkey TO user_aggregates_user_id_fkey;\n\n-- Redo the user_aggregates table\nDROP TRIGGER person_aggregates_person ON person;\n\nDROP TRIGGER person_aggregates_post_count ON post;\n\nDROP TRIGGER person_aggregates_post_score ON post_like;\n\nDROP TRIGGER person_aggregates_comment_count ON comment;\n\nDROP TRIGGER person_aggregates_comment_score ON comment_like;\n\nDROP FUNCTION person_aggregates_person, person_aggregates_post_count, person_aggregates_post_score, person_aggregates_comment_count, person_aggregates_comment_score;\n\n-- user_ table\n-- Drop views\nDROP VIEW person_alias_1, person_alias_2;\n\n-- Rename indexes\nALTER INDEX person__pkey RENAME TO user__pkey;\n\nALTER INDEX idx_person_actor_id RENAME TO idx_user_actor_id;\n\nALTER INDEX idx_person_inbox_url RENAME TO idx_user_inbox_url;\n\nALTER INDEX idx_person_lower_actor_id RENAME TO idx_user_lower_actor_id;\n\nALTER INDEX idx_person_published RENAME TO idx_user_published;\n\n-- Rename triggers\nALTER TRIGGER site_aggregates_person_delete ON person RENAME TO site_aggregates_user_delete;\n\nALTER TRIGGER site_aggregates_person_insert ON person RENAME TO site_aggregates_user_insert;\n\n-- Rename the trigger functions\nALTER FUNCTION site_aggregates_person_delete () RENAME TO site_aggregates_user_delete;\n\nALTER FUNCTION site_aggregates_person_insert () RENAME TO site_aggregates_user_insert;\n\n-- Rename the table back to user_\nALTER TABLE person RENAME TO user_;\n\nALTER SEQUENCE person_id_seq\n    RENAME TO user__id_seq;\n\n-- Add the columns back in\nALTER TABLE user_\n    ADD COLUMN password_encrypted text NOT NULL DEFAULT 'changeme',\n    ADD COLUMN email text UNIQUE,\n    ADD COLUMN admin boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN show_nsfw boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN theme character varying(20) DEFAULT 'darkly'::character varying NOT NULL,\n    ADD COLUMN default_sort_type smallint DEFAULT 0 NOT NULL,\n    ADD COLUMN default_listing_type smallint DEFAULT 1 NOT NULL,\n    ADD COLUMN lang character varying(20) DEFAULT 'browser'::character varying NOT NULL,\n    ADD COLUMN show_avatars boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN send_notifications_to_email boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN matrix_user_id text UNIQUE;\n\n-- Default is only for existing rows\nALTER TABLE user_\n    ALTER COLUMN password_encrypted DROP DEFAULT;\n\n-- Update the user_ table with the local_user data\nUPDATE\n    user_ u\nSET\n    password_encrypted = lu.password_encrypted,\n    email = lu.email,\n    admin = lu.admin,\n    show_nsfw = lu.show_nsfw,\n    theme = lu.theme,\n    default_sort_type = lu.default_sort_type,\n    default_listing_type = lu.default_listing_type,\n    lang = lu.lang,\n    show_avatars = lu.show_avatars,\n    send_notifications_to_email = lu.send_notifications_to_email,\n    matrix_user_id = lu.matrix_user_id\nFROM\n    local_user lu\nWHERE\n    lu.person_id = u.id;\n\nCREATE UNIQUE INDEX idx_user_email_lower ON user_ (lower(email));\n\nCREATE VIEW user_alias_1 AS\nSELECT\n    *\nFROM\n    user_;\n\nCREATE VIEW user_alias_2 AS\nSELECT\n    *\nFROM\n    user_;\n\nDROP TABLE local_user;\n\n-- Add the user_aggregates table triggers\n-- initial user add\nCREATE FUNCTION user_aggregates_user ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO user_aggregates (user_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM user_aggregates\n        WHERE user_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_user\n    AFTER INSERT OR DELETE ON user_\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_user ();\n\n-- post count\nCREATE FUNCTION user_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            user_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            user_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            user_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                user_ u\n            LEFT JOIN post p ON u.id = p.creator_id\n            LEFT JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            u.id) pd\n    WHERE\n        ua.user_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_post_count ();\n\n-- post score\nCREATE FUNCTION user_aggregates_post_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = post_score + NEW.score\n        FROM\n            post p\n        WHERE\n            ua.user_id = p.creator_id\n            AND p.id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates ua\n        SET\n            post_score = post_score - OLD.score\n        FROM\n            post p\n        WHERE\n            ua.user_id = p.creator_id\n            AND p.id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_post_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_post_score ();\n\n-- comment count\nCREATE FUNCTION user_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            user_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            user_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            user_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                user_ u\n            LEFT JOIN comment c ON u.id = c.creator_id\n            LEFT JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            u.id) cd\n    WHERE\n        ua.user_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_comment_count ();\n\n-- comment score\nCREATE FUNCTION user_aggregates_comment_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = comment_score + NEW.score\n        FROM\n            comment c\n        WHERE\n            ua.user_id = c.creator_id\n            AND c.id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            user_aggregates ua\n        SET\n            comment_score = comment_score - OLD.score\n        FROM\n            comment c\n        WHERE\n            ua.user_id = c.creator_id\n            AND c.id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER user_aggregates_comment_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE user_aggregates_comment_score ();\n\n-- redo site aggregates trigger\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN user_ u ON c.creator_id = u.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN user_ u ON p.creator_id = u.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2021-03-09-171136_split_user_table_2/up.sql",
    "content": "-- Person\n-- Drop the 2 views user_alias_1, user_alias_2\nDROP VIEW user_alias_1, user_alias_2;\n\n-- rename the user_ table to person\nALTER TABLE user_ RENAME TO person;\n\nALTER SEQUENCE user__id_seq\n    RENAME TO person_id_seq;\n\n-- create a new table local_user\nCREATE TABLE local_user (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    password_encrypted text NOT NULL,\n    email text UNIQUE,\n    admin boolean DEFAULT FALSE NOT NULL,\n    show_nsfw boolean DEFAULT FALSE NOT NULL,\n    theme character varying(20) DEFAULT 'darkly'::character varying NOT NULL,\n    default_sort_type smallint DEFAULT 0 NOT NULL,\n    default_listing_type smallint DEFAULT 1 NOT NULL,\n    lang character varying(20) DEFAULT 'browser'::character varying NOT NULL,\n    show_avatars boolean DEFAULT TRUE NOT NULL,\n    send_notifications_to_email boolean DEFAULT FALSE NOT NULL,\n    matrix_user_id text,\n    UNIQUE (person_id)\n);\n\n-- Copy the local users over to the new table\nINSERT INTO local_user (person_id, password_encrypted, email, admin, show_nsfw, theme, default_sort_type, default_listing_type, lang, show_avatars, send_notifications_to_email, matrix_user_id)\nSELECT\n    id,\n    password_encrypted,\n    email,\n    admin,\n    show_nsfw,\n    theme,\n    default_sort_type,\n    default_listing_type,\n    lang,\n    show_avatars,\n    send_notifications_to_email,\n    matrix_user_id\nFROM\n    person\nWHERE\n    local = TRUE;\n\n-- Drop those columns from person\nALTER TABLE person\n    DROP COLUMN password_encrypted,\n    DROP COLUMN email,\n    DROP COLUMN admin,\n    DROP COLUMN show_nsfw,\n    DROP COLUMN theme,\n    DROP COLUMN default_sort_type,\n    DROP COLUMN default_listing_type,\n    DROP COLUMN lang,\n    DROP COLUMN show_avatars,\n    DROP COLUMN send_notifications_to_email,\n    DROP COLUMN matrix_user_id;\n\n-- Rename indexes\nALTER INDEX user__pkey RENAME TO person__pkey;\n\nALTER INDEX idx_user_actor_id RENAME TO idx_person_actor_id;\n\nALTER INDEX idx_user_inbox_url RENAME TO idx_person_inbox_url;\n\nALTER INDEX idx_user_lower_actor_id RENAME TO idx_person_lower_actor_id;\n\nALTER INDEX idx_user_published RENAME TO idx_person_published;\n\n-- Rename triggers\nALTER TRIGGER site_aggregates_user_delete ON person RENAME TO site_aggregates_person_delete;\n\nALTER TRIGGER site_aggregates_user_insert ON person RENAME TO site_aggregates_person_insert;\n\n-- Rename the trigger functions\nALTER FUNCTION site_aggregates_user_delete () RENAME TO site_aggregates_person_delete;\n\nALTER FUNCTION site_aggregates_user_insert () RENAME TO site_aggregates_person_insert;\n\n-- Create views\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n-- Redo user aggregates into person_aggregates\nALTER TABLE user_aggregates RENAME TO person_aggregates;\n\nALTER SEQUENCE user_aggregates_id_seq\n    RENAME TO person_aggregates_id_seq;\n\nALTER TABLE person_aggregates RENAME COLUMN user_id TO person_id;\n\n-- index\nALTER INDEX user_aggregates_pkey RENAME TO person_aggregates_pkey;\n\nALTER INDEX idx_user_aggregates_comment_score RENAME TO idx_person_aggregates_comment_score;\n\nALTER INDEX user_aggregates_user_id_key RENAME TO person_aggregates_person_id_key;\n\nALTER TABLE person_aggregates RENAME CONSTRAINT user_aggregates_user_id_fkey TO person_aggregates_person_id_fkey;\n\n-- Drop all the old triggers and functions\nDROP TRIGGER user_aggregates_user ON person;\n\nDROP TRIGGER user_aggregates_post_count ON post;\n\nDROP TRIGGER user_aggregates_post_score ON post_like;\n\nDROP TRIGGER user_aggregates_comment_count ON comment;\n\nDROP TRIGGER user_aggregates_comment_score ON comment_like;\n\nDROP FUNCTION user_aggregates_user, user_aggregates_post_count, user_aggregates_post_score, user_aggregates_comment_count, user_aggregates_comment_score;\n\n-- initial user add\nCREATE FUNCTION person_aggregates_person ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO person_aggregates (person_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM person_aggregates\n        WHERE person_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER person_aggregates_person\n    AFTER INSERT OR DELETE ON person\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_person ();\n\n-- post count\nCREATE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                person u\n            LEFT JOIN post p ON u.id = p.creator_id\n            LEFT JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            u.id) pd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER person_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_count ();\n\n-- post score\nCREATE FUNCTION person_aggregates_post_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = post_score + NEW.score\n        FROM\n            post p\n        WHERE\n            ua.person_id = p.creator_id\n            AND p.id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = post_score - OLD.score\n        FROM\n            post p\n        WHERE\n            ua.person_id = p.creator_id\n            AND p.id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER person_aggregates_post_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_score ();\n\n-- comment count\nCREATE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                person u\n            LEFT JOIN comment c ON u.id = c.creator_id\n            LEFT JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            u.id) cd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER person_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_comment_count ();\n\n-- comment score\nCREATE FUNCTION person_aggregates_comment_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = comment_score + NEW.score\n        FROM\n            comment c\n        WHERE\n            ua.person_id = c.creator_id\n            AND c.id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = comment_score - OLD.score\n        FROM\n            comment c\n        WHERE\n            ua.person_id = c.creator_id\n            AND c.id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER person_aggregates_comment_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_comment_score ();\n\n-- person_mention\nALTER TABLE user_mention RENAME TO person_mention;\n\nALTER SEQUENCE user_mention_id_seq\n    RENAME TO person_mention_id_seq;\n\nALTER INDEX user_mention_pkey RENAME TO person_mention_pkey;\n\nALTER INDEX user_mention_recipient_id_comment_id_key RENAME TO person_mention_recipient_id_comment_id_key;\n\nALTER TABLE person_mention RENAME CONSTRAINT user_mention_comment_id_fkey TO person_mention_comment_id_fkey;\n\nALTER TABLE person_mention RENAME CONSTRAINT user_mention_recipient_id_fkey TO person_mention_recipient_id_fkey;\n\n-- user_ban\nALTER TABLE user_ban RENAME TO person_ban;\n\nALTER SEQUENCE user_ban_id_seq\n    RENAME TO person_ban_id_seq;\n\nALTER INDEX user_ban_pkey RENAME TO person_ban_pkey;\n\nALTER INDEX user_ban_user_id_key RENAME TO person_ban_person_id_key;\n\nALTER TABLE person_ban RENAME COLUMN user_id TO person_id;\n\nALTER TABLE person_ban RENAME CONSTRAINT user_ban_user_id_fkey TO person_ban_person_id_fkey;\n\n-- comment_like\nALTER TABLE comment_like RENAME COLUMN user_id TO person_id;\n\nALTER INDEX idx_comment_like_user RENAME TO idx_comment_like_person;\n\nALTER TABLE comment_like RENAME CONSTRAINT comment_like_comment_id_user_id_key TO comment_like_comment_id_person_id_key;\n\nALTER TABLE comment_like RENAME CONSTRAINT comment_like_user_id_fkey TO comment_like_person_id_fkey;\n\n-- comment_saved\nALTER TABLE comment_saved RENAME COLUMN user_id TO person_id;\n\nALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_comment_id_user_id_key TO comment_saved_comment_id_person_id_key;\n\nALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_user_id_fkey TO comment_saved_person_id_fkey;\n\n-- community_follower\nALTER TABLE community_follower RENAME COLUMN user_id TO person_id;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_follower_community_id_user_id_key TO community_follower_community_id_person_id_key;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_follower_user_id_fkey TO community_follower_person_id_fkey;\n\n-- community_moderator\nALTER TABLE community_moderator RENAME COLUMN user_id TO person_id;\n\nALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_community_id_user_id_key TO community_moderator_community_id_person_id_key;\n\nALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_user_id_fkey TO community_moderator_person_id_fkey;\n\n-- community_user_ban\nALTER TABLE community_user_ban RENAME TO community_person_ban;\n\nALTER SEQUENCE community_user_ban_id_seq\n    RENAME TO community_person_ban_id_seq;\n\nALTER TABLE community_person_ban RENAME COLUMN user_id TO person_id;\n\nALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_pkey TO community_person_ban_pkey;\n\nALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_community_id_fkey TO community_person_ban_community_id_fkey;\n\nALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_community_id_user_id_key TO community_person_ban_community_id_person_id_key;\n\nALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_user_id_fkey TO community_person_ban_person_id_fkey;\n\n-- mod_add\nALTER TABLE mod_add RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_add RENAME COLUMN other_user_id TO other_person_id;\n\nALTER TABLE mod_add RENAME CONSTRAINT mod_add_mod_user_id_fkey TO mod_add_mod_person_id_fkey;\n\nALTER TABLE mod_add RENAME CONSTRAINT mod_add_other_user_id_fkey TO mod_add_other_person_id_fkey;\n\n-- mod_add_community\nALTER TABLE mod_add_community RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_add_community RENAME COLUMN other_user_id TO other_person_id;\n\nALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_mod_user_id_fkey TO mod_add_community_mod_person_id_fkey;\n\nALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_other_user_id_fkey TO mod_add_community_other_person_id_fkey;\n\n-- mod_ban\nALTER TABLE mod_ban RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_ban RENAME COLUMN other_user_id TO other_person_id;\n\nALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_mod_user_id_fkey TO mod_ban_mod_person_id_fkey;\n\nALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_other_user_id_fkey TO mod_ban_other_person_id_fkey;\n\n-- mod_ban_community\nALTER TABLE mod_ban_from_community RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN other_user_id TO other_person_id;\n\nALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_mod_user_id_fkey TO mod_ban_from_community_mod_person_id_fkey;\n\nALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_other_user_id_fkey TO mod_ban_from_community_other_person_id_fkey;\n\n-- mod_lock_post\nALTER TABLE mod_lock_post RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_lock_post RENAME CONSTRAINT mod_lock_post_mod_user_id_fkey TO mod_lock_post_mod_person_id_fkey;\n\n-- mod_remove_comment\nALTER TABLE mod_remove_comment RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_remove_comment RENAME CONSTRAINT mod_remove_comment_mod_user_id_fkey TO mod_remove_comment_mod_person_id_fkey;\n\n-- mod_remove_community\nALTER TABLE mod_remove_community RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_remove_community RENAME CONSTRAINT mod_remove_community_mod_user_id_fkey TO mod_remove_community_mod_person_id_fkey;\n\n-- mod_remove_post\nALTER TABLE mod_remove_post RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_remove_post RENAME CONSTRAINT mod_remove_post_mod_user_id_fkey TO mod_remove_post_mod_person_id_fkey;\n\n-- mod_sticky_post\nALTER TABLE mod_sticky_post RENAME COLUMN mod_user_id TO mod_person_id;\n\nALTER TABLE mod_sticky_post RENAME CONSTRAINT mod_sticky_post_mod_user_id_fkey TO mod_sticky_post_mod_person_id_fkey;\n\n-- password_reset_request\nDELETE FROM password_reset_request;\n\nALTER TABLE password_reset_request\n    DROP COLUMN user_id;\n\nALTER TABLE password_reset_request\n    ADD COLUMN local_user_id integer NOT NULL REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- post_like\nALTER TABLE post_like RENAME COLUMN user_id TO person_id;\n\nALTER INDEX idx_post_like_user RENAME TO idx_post_like_person;\n\nALTER TABLE post_like RENAME CONSTRAINT post_like_post_id_user_id_key TO post_like_post_id_person_id_key;\n\nALTER TABLE post_like RENAME CONSTRAINT post_like_user_id_fkey TO post_like_person_id_fkey;\n\n-- post_read\nALTER TABLE post_read RENAME COLUMN user_id TO person_id;\n\nALTER TABLE post_read RENAME CONSTRAINT post_read_post_id_user_id_key TO post_read_post_id_person_id_key;\n\nALTER TABLE post_read RENAME CONSTRAINT post_read_user_id_fkey TO post_read_person_id_fkey;\n\n-- post_saved\nALTER TABLE post_saved RENAME COLUMN user_id TO person_id;\n\nALTER TABLE post_saved RENAME CONSTRAINT post_saved_post_id_user_id_key TO post_saved_post_id_person_id_key;\n\nALTER TABLE post_saved RENAME CONSTRAINT post_saved_user_id_fkey TO post_saved_person_id_fkey;\n\n-- redo site aggregates trigger\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person u ON c.creator_id = u.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person u ON p.creator_id = u.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2021-03-19-014144_add_col_local_user_validator_time/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN validator_time;\n\n"
  },
  {
    "path": "migrations/2021-03-19-014144_add_col_local_user_validator_time/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN validator_time timestamp NOT NULL DEFAULT now();\n\n"
  },
  {
    "path": "migrations/2021-03-20-185321_move_matrix_id_to_person/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN matrix_user_id text;\n\nALTER TABLE local_user\n    ADD COLUMN admin boolean DEFAULT FALSE NOT NULL;\n\nUPDATE\n    local_user lu\nSET\n    matrix_user_id = p.matrix_user_id,\n    admin = p.admin\nFROM\n    person p\nWHERE\n    p.id = lu.person_id;\n\nDROP VIEW person_alias_1, person_alias_2;\n\nALTER TABLE person\n    DROP COLUMN matrix_user_id;\n\nALTER TABLE person\n    DROP COLUMN admin;\n\n-- Regenerate the person_alias views\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-03-20-185321_move_matrix_id_to_person/up.sql",
    "content": "ALTER TABLE person\n    ADD COLUMN matrix_user_id text;\n\nALTER TABLE person\n    ADD COLUMN admin boolean DEFAULT FALSE NOT NULL;\n\nUPDATE\n    person p\nSET\n    matrix_user_id = lu.matrix_user_id,\n    admin = lu.admin\nFROM\n    local_user lu\nWHERE\n    p.id = lu.person_id;\n\nALTER TABLE local_user\n    DROP COLUMN matrix_user_id;\n\nALTER TABLE local_user\n    DROP COLUMN admin;\n\n-- Regenerate the person_alias views\nDROP VIEW person_alias_1, person_alias_2;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-03-31-103917_add_show_score_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN show_scores;\n\n"
  },
  {
    "path": "migrations/2021-03-31-103917_add_show_score_setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN show_scores boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-03-31-105915_add_bot_account/down.sql",
    "content": "DROP VIEW person_alias_1, person_alias_2;\n\nALTER TABLE person\n    DROP COLUMN bot_account;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\nALTER TABLE local_user\n    DROP COLUMN show_bot_accounts;\n\n"
  },
  {
    "path": "migrations/2021-03-31-105915_add_bot_account/up.sql",
    "content": "-- Add the bot_account column to the person table\nDROP VIEW person_alias_1, person_alias_2;\n\nALTER TABLE person\n    ADD COLUMN bot_account boolean NOT NULL DEFAULT FALSE;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n-- Add the show_bot_accounts to the local user table as a setting\nALTER TABLE local_user\n    ADD COLUMN show_bot_accounts boolean NOT NULL DEFAULT TRUE;\n\n"
  },
  {
    "path": "migrations/2021-03-31-144349_add_site_short_description/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN description;\n\nALTER TABLE site RENAME COLUMN sidebar TO description;\n\n"
  },
  {
    "path": "migrations/2021-03-31-144349_add_site_short_description/up.sql",
    "content": "-- Renaming description to sidebar\nALTER TABLE site RENAME COLUMN description TO sidebar;\n\n-- Adding a short description column\nALTER TABLE site\n    ADD COLUMN description varchar(150);\n\n"
  },
  {
    "path": "migrations/2021-04-01-173552_rename_preferred_username_to_display_name/down.sql",
    "content": "ALTER TABLE person RENAME display_name TO preferred_username;\n\n-- Regenerate the person_alias views\nDROP VIEW person_alias_1, person_alias_2;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-04-01-173552_rename_preferred_username_to_display_name/up.sql",
    "content": "ALTER TABLE person RENAME preferred_username TO display_name;\n\n-- Regenerate the person_alias views\nDROP VIEW person_alias_1, person_alias_2;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-04-01-181826_add_community_agg_active_monthly_index/down.sql",
    "content": "DROP INDEX idx_community_aggregates_users_active_month;\n\n"
  },
  {
    "path": "migrations/2021-04-01-181826_add_community_agg_active_monthly_index/up.sql",
    "content": "CREATE INDEX idx_community_aggregates_users_active_month ON community_aggregates (users_active_month DESC);\n\n"
  },
  {
    "path": "migrations/2021-04-02-021422_remove_community_creator/down.sql",
    "content": "--  Add the column back\nALTER TABLE community\n    ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Recreate the index\nCREATE INDEX idx_community_creator ON community (creator_id);\n\n-- Add the data, selecting the highest mod\nUPDATE\n    community\nSET\n    creator_id = sub.person_id\nFROM (\n    SELECT\n        cm.community_id,\n        cm.person_id\n    FROM\n        community_moderator cm\n    LIMIT 1) AS sub\nWHERE\n    id = sub.community_id;\n\n-- Set to not null\nALTER TABLE community\n    ALTER COLUMN creator_id SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-04-02-021422_remove_community_creator/up.sql",
    "content": "-- Drop the column\nALTER TABLE community\n    DROP COLUMN creator_id;\n\n"
  },
  {
    "path": "migrations/2021-04-20-155001_limit-admins-create-community/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN community_creation_admin_only;\n\n"
  },
  {
    "path": "migrations/2021-04-20-155001_limit-admins-create-community/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN community_creation_admin_only bool NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2021-04-24-174047_add_show_read_post_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN show_read_posts;\n\n"
  },
  {
    "path": "migrations/2021-04-24-174047_add_show_read_post_setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN show_read_posts boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-07-19-130929_add_show_new_post_notifs_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN show_new_post_notifs;\n\n"
  },
  {
    "path": "migrations/2021-07-19-130929_add_show_new_post_notifs_setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN show_new_post_notifs boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-07-20-102033_actor_name_length/down.sql",
    "content": "DROP VIEW person_alias_1;\n\nDROP VIEW person_alias_2;\n\nALTER TABLE community\n    ALTER COLUMN name TYPE varchar(20);\n\nALTER TABLE community\n    ALTER COLUMN title TYPE varchar(100);\n\nALTER TABLE person\n    ALTER COLUMN name TYPE varchar(20);\n\nALTER TABLE person\n    ALTER COLUMN display_name TYPE varchar(20);\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-07-20-102033_actor_name_length/up.sql",
    "content": "DROP VIEW person_alias_1;\n\nDROP VIEW person_alias_2;\n\nALTER TABLE community\n    ALTER COLUMN name TYPE varchar(255);\n\nALTER TABLE community\n    ALTER COLUMN title TYPE varchar(255);\n\nALTER TABLE person\n    ALTER COLUMN name TYPE varchar(255);\n\nALTER TABLE person\n    ALTER COLUMN display_name TYPE varchar(255);\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-08-02-002342_comment_count_fixes/down.sql",
    "content": "DROP TRIGGER post_aggregates_comment_set_deleted ON comment;\n\nDROP FUNCTION post_aggregates_comment_deleted;\n\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1,\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id\n            AND published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-08-02-002342_comment_count_fixes/up.sql",
    "content": "-- Creating a new trigger for when comment.deleted is updated\nCREATE OR REPLACE FUNCTION post_aggregates_comment_deleted ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF NEW.deleted = TRUE THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSE\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1\n        WHERE\n            pa.post_id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_comment_set_deleted\n    AFTER UPDATE OF deleted ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_comment_deleted ();\n\n-- Fix issue with being able to necro-bump your own post\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1,\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = NEW.post_id\n            -- Fix issue with being able to necro-bump your own post\n            AND NEW.creator_id != p.creator_id\n            AND pa.published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2021-08-04-223559_create_user_community_block/down.sql",
    "content": "DROP TABLE person_block;\n\nDROP TABLE community_block;\n\n"
  },
  {
    "path": "migrations/2021-08-04-223559_create_user_community_block/up.sql",
    "content": "CREATE TABLE person_block (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    target_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (person_id, target_id)\n);\n\nCREATE TABLE community_block (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (person_id, community_id)\n);\n\n"
  },
  {
    "path": "migrations/2021-08-16-004209_fix_remove_bots_from_aggregates/down.sql",
    "content": "CREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n    WHERE\n        p.published > ('now'::timestamp - i::interval)) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person u ON c.creator_id = u.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person u ON p.creator_id = u.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2021-08-16-004209_fix_remove_bots_from_aggregates/up.sql",
    "content": "-- Make sure bots aren't included in aggregate counts\nCREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person u ON c.creator_id = u.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person u ON p.creator_id = u.id\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n            AND pe.bot_account = FALSE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2021-08-17-210508_create_mod_transfer_community/down.sql",
    "content": "DROP TABLE mod_transfer_community;\n\n"
  },
  {
    "path": "migrations/2021-08-17-210508_create_mod_transfer_community/up.sql",
    "content": "-- Add the mod_transfer_community log table\nCREATE TABLE mod_transfer_community (\n    id serial PRIMARY KEY,\n    mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    other_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    removed boolean DEFAULT FALSE,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2021-09-20-112945_jwt-secret/down.sql",
    "content": "DROP TABLE secret;\n\nDROP EXTENSION pgcrypto;\n\n"
  },
  {
    "path": "migrations/2021-09-20-112945_jwt-secret/up.sql",
    "content": "-- generate a jwt secret\nCREATE EXTENSION IF NOT EXISTS pgcrypto;\n\nCREATE TABLE secret (\n    id serial PRIMARY KEY,\n    jwt_secret varchar NOT NULL DEFAULT gen_random_uuid ()\n);\n\nINSERT INTO secret DEFAULT VALUES;\n"
  },
  {
    "path": "migrations/2021-10-01-141650_create_admin_purge/down.sql",
    "content": "DROP TABLE admin_purge_person;\n\nDROP TABLE admin_purge_community;\n\nDROP TABLE admin_purge_post;\n\nDROP TABLE admin_purge_comment;\n\n"
  },
  {
    "path": "migrations/2021-10-01-141650_create_admin_purge/up.sql",
    "content": "-- Add the admin_purge tables\nCREATE TABLE admin_purge_person (\n    id serial PRIMARY KEY,\n    admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE admin_purge_community (\n    id serial PRIMARY KEY,\n    admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE admin_purge_post (\n    id serial PRIMARY KEY,\n    admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\nCREATE TABLE admin_purge_comment (\n    id serial PRIMARY KEY,\n    admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    reason text,\n    when_ timestamp NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2021-11-22-135324_add_activity_ap_id_index/down.sql",
    "content": "ALTER TABLE activity\n    ALTER COLUMN ap_id DROP NOT NULL;\n\nCREATE UNIQUE INDEX idx_activity_unique_apid ON activity ((data ->> 'id'::text));\n\nDROP INDEX idx_activity_ap_id;\n\n"
  },
  {
    "path": "migrations/2021-11-22-135324_add_activity_ap_id_index/up.sql",
    "content": "-- Delete the empty ap_ids\nDELETE FROM activity\nWHERE ap_id IS NULL;\n\n-- Make it required\nALTER TABLE activity\n    ALTER COLUMN ap_id SET NOT NULL;\n\n-- Delete dupes, keeping the first one\nDELETE FROM activity a USING (\n    SELECT\n        min(id) AS id,\n        ap_id\n    FROM\n        activity\n    GROUP BY\n        ap_id\n    HAVING\n        count(*) > 1) b\nWHERE\n    a.ap_id = b.ap_id\n    AND a.id <> b.id;\n\n-- The index\nCREATE UNIQUE INDEX idx_activity_ap_id ON activity (ap_id);\n\n-- Drop the old index\nDROP INDEX idx_activity_unique_apid;\n\n"
  },
  {
    "path": "migrations/2021-11-22-143904_add_required_public_key/down.sql",
    "content": "ALTER TABLE community\n    ALTER COLUMN public_key DROP NOT NULL;\n\nALTER TABLE person\n    ALTER COLUMN public_key DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-11-22-143904_add_required_public_key/up.sql",
    "content": "-- Delete the empty public keys\nDELETE FROM community\nWHERE public_key IS NULL;\n\nDELETE FROM person\nWHERE public_key IS NULL;\n\n-- Make it required\nALTER TABLE community\n    ALTER COLUMN public_key SET NOT NULL;\n\nALTER TABLE person\n    ALTER COLUMN public_key SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2021-11-23-031528_add_report_published_index/down.sql",
    "content": "DROP INDEX idx_comment_report_published;\n\nDROP INDEX idx_post_report_published;\n\n"
  },
  {
    "path": "migrations/2021-11-23-031528_add_report_published_index/up.sql",
    "content": "CREATE INDEX idx_comment_report_published ON comment_report (published DESC);\n\nCREATE INDEX idx_post_report_published ON post_report (published DESC);\n\n"
  },
  {
    "path": "migrations/2021-11-23-132840_email_verification/down.sql",
    "content": "-- revert defaults from db for local user init\nALTER TABLE local_user\n    ALTER COLUMN theme SET DEFAULT 'darkly';\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 1;\n\n-- remove tables and columns for optional email verification\nALTER TABLE site\n    DROP COLUMN require_email_verification;\n\nALTER TABLE local_user\n    DROP COLUMN email_verified;\n\nDROP TABLE email_verification;\n\n"
  },
  {
    "path": "migrations/2021-11-23-132840_email_verification/up.sql",
    "content": "-- use defaults from db for local user init\nALTER TABLE local_user\n    ALTER COLUMN theme SET DEFAULT 'browser';\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 2;\n\n-- add tables and columns for optional email verification\nALTER TABLE site\n    ADD COLUMN require_email_verification boolean NOT NULL DEFAULT FALSE;\n\nALTER TABLE local_user\n    ADD COLUMN email_verified boolean NOT NULL DEFAULT FALSE;\n\nCREATE TABLE email_verification (\n    id serial PRIMARY KEY,\n    local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    email text NOT NULL,\n    verification_token text NOT NULL\n);\n\n"
  },
  {
    "path": "migrations/2021-11-23-153753_add_invite_only_columns/down.sql",
    "content": "-- Add columns to site table\nALTER TABLE site\n    DROP COLUMN require_application;\n\nALTER TABLE site\n    DROP COLUMN application_question;\n\nALTER TABLE site\n    DROP COLUMN private_instance;\n\n-- Add pending to local_user\nALTER TABLE local_user\n    DROP COLUMN accepted_application;\n\nDROP TABLE registration_application;\n\n"
  },
  {
    "path": "migrations/2021-11-23-153753_add_invite_only_columns/up.sql",
    "content": "-- Add columns to site table\nALTER TABLE site\n    ADD COLUMN require_application boolean NOT NULL DEFAULT FALSE;\n\nALTER TABLE site\n    ADD COLUMN application_question text;\n\nALTER TABLE site\n    ADD COLUMN private_instance boolean NOT NULL DEFAULT FALSE;\n\n-- Add pending to local_user\nALTER TABLE local_user\n    ADD COLUMN accepted_application boolean NOT NULL DEFAULT FALSE;\n\nCREATE TABLE registration_application (\n    id serial PRIMARY KEY,\n    local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    answer text NOT NULL,\n    admin_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    deny_reason text,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (local_user_id)\n);\n\nCREATE INDEX idx_registration_application_published ON registration_application (published DESC);\n\n"
  },
  {
    "path": "migrations/2021-12-09-225529_add_published_to_email_verification/down.sql",
    "content": "ALTER TABLE email_verification\n    DROP COLUMN published;\n\n"
  },
  {
    "path": "migrations/2021-12-09-225529_add_published_to_email_verification/up.sql",
    "content": "ALTER TABLE email_verification\n    ADD COLUMN published timestamp NOT NULL DEFAULT now();\n\n"
  },
  {
    "path": "migrations/2021-12-14-181537_add_temporary_bans/down.sql",
    "content": "DROP VIEW person_alias_1, person_alias_2;\n\nALTER TABLE person\n    DROP COLUMN ban_expires;\n\nALTER TABLE community_person_ban\n    DROP COLUMN expires;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2021-12-14-181537_add_temporary_bans/up.sql",
    "content": "-- Add ban_expires to person, community_person_ban\nALTER TABLE person\n    ADD COLUMN ban_expires timestamp;\n\nALTER TABLE community_person_ban\n    ADD COLUMN expires timestamp;\n\nDROP VIEW person_alias_1, person_alias_2;\n\nCREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2022-01-04-034553_add_hidden_column/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN hidden;\n\nDROP TABLE mod_hide_community;\n\n"
  },
  {
    "path": "migrations/2022-01-04-034553_add_hidden_column/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN hidden boolean DEFAULT FALSE;\n\nCREATE TABLE mod_hide_community (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    when_ timestamp NOT NULL DEFAULT now(),\n    reason text,\n    hidden boolean DEFAULT FALSE\n);\n\n"
  },
  {
    "path": "migrations/2022-01-20-160328_remove_site_creator/down.sql",
    "content": "--  Add the column back\nALTER TABLE site\n    ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Add the data, selecting the highest admin\nUPDATE\n    site\nSET\n    creator_id = sub.id\nFROM (\n    SELECT\n        id\n    FROM\n        person\n    WHERE\n        admin = TRUE\n    LIMIT 1) AS sub;\n\n-- Set to not null\nALTER TABLE site\n    ALTER COLUMN creator_id SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2022-01-20-160328_remove_site_creator/up.sql",
    "content": "-- Drop the column\nALTER TABLE site\n    DROP COLUMN creator_id;\n\n"
  },
  {
    "path": "migrations/2022-01-28-104106_instance-actor/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN actor_id,\n    DROP COLUMN last_refreshed_at,\n    DROP COLUMN inbox_url,\n    DROP COLUMN private_key,\n    DROP COLUMN public_key;\n\n"
  },
  {
    "path": "migrations/2022-01-28-104106_instance-actor/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN actor_id varchar(255) NOT NULL UNIQUE DEFAULT generate_unique_changeme (),\n    ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now(),\n    ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme (),\n    ADD COLUMN private_key text,\n    ADD COLUMN public_key text NOT NULL DEFAULT generate_unique_changeme ();\n\n"
  },
  {
    "path": "migrations/2022-02-01-154240_add_community_title_index/down.sql",
    "content": "DROP INDEX idx_community_title;\n\n"
  },
  {
    "path": "migrations/2022-02-01-154240_add_community_title_index/up.sql",
    "content": "CREATE INDEX idx_community_title ON community (title);\n\n"
  },
  {
    "path": "migrations/2022-02-18-210946_default_theme/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN default_theme;\n\n"
  },
  {
    "path": "migrations/2022-02-18-210946_default_theme/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN default_theme text NOT NULL DEFAULT 'browser';\n\n"
  },
  {
    "path": "migrations/2022-04-04-183652_update_community_aggregates_on_soft_delete/down.sql",
    "content": "DROP TRIGGER IF EXISTS community_aggregates_post_count ON post;\n\nDROP TRIGGER IF EXISTS community_aggregates_comment_count ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_comment_insert ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_comment_delete ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_post_insert ON post;\n\nDROP TRIGGER IF EXISTS site_aggregates_post_delete ON post;\n\nDROP TRIGGER IF EXISTS site_aggregates_community_insert ON community;\n\nDROP TRIGGER IF EXISTS site_aggregates_community_delete ON community;\n\nDROP TRIGGER IF EXISTS person_aggregates_post_count ON post;\n\nDROP TRIGGER IF EXISTS person_aggregates_comment_count ON comment;\n\nDROP FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record);\n\nDROP FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record);\n\n-- Community aggregate functions\nCREATE OR REPLACE FUNCTION community_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts + 1\n        WHERE\n            community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts - 1\n        WHERE\n            community_id = OLD.community_id;\n        -- Update the counts if the post got deleted\n        UPDATE\n            community_aggregates ca\n        SET\n            posts = coalesce(cd.posts, 0),\n            comments = coalesce(cd.comments, 0)\n        FROM (\n            SELECT\n                c.id,\n                count(DISTINCT p.id) AS posts,\n                count(DISTINCT ct.id) AS comments\n            FROM\n                community c\n            LEFT JOIN post p ON c.id = p.community_id\n            LEFT JOIN comment ct ON p.id = ct.post_id\n        GROUP BY\n            c.id) cd\n    WHERE\n        ca.community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Community aggregate triggers\nCREATE TRIGGER community_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_post_count ();\n\nCREATE TRIGGER community_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_comment_count ();\n\n-- Site aggregate functions\nCREATE OR REPLACE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        posts = posts + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_post_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        posts = posts - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_comment_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        comments = comments + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_comment_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        comments = comments - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_community_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        communities = communities + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_community_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        communities = communities - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\n-- Site update triggers\nCREATE TRIGGER site_aggregates_post_insert\n    AFTER INSERT ON post\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_insert ();\n\nCREATE TRIGGER site_aggregates_post_delete\n    AFTER DELETE ON post\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_delete ();\n\nCREATE TRIGGER site_aggregates_comment_insert\n    AFTER INSERT ON comment\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_insert ();\n\nCREATE TRIGGER site_aggregates_comment_delete\n    AFTER DELETE ON comment\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_delete ();\n\nCREATE TRIGGER site_aggregates_community_insert\n    AFTER INSERT ON community\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_insert ();\n\nCREATE TRIGGER site_aggregates_community_delete\n    AFTER DELETE ON community\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_delete ();\n\n-- Person aggregate functions\nCREATE OR REPLACE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                person u\n            LEFT JOIN post p ON u.id = p.creator_id\n            LEFT JOIN post_like pl ON p.id = pl.post_id\n        GROUP BY\n            u.id) pd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                person u\n            LEFT JOIN comment c ON u.id = c.creator_id\n            LEFT JOIN comment_like cl ON c.id = cl.comment_id\n        GROUP BY\n            u.id) cd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Person aggregate triggers\nCREATE TRIGGER person_aggregates_post_count\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_count ();\n\nCREATE TRIGGER person_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_comment_count ();\n\n"
  },
  {
    "path": "migrations/2022-04-04-183652_update_community_aggregates_on_soft_delete/up.sql",
    "content": "DROP TRIGGER IF EXISTS community_aggregates_post_count ON post;\n\nDROP TRIGGER IF EXISTS community_aggregates_comment_count ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_comment_insert ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_comment_delete ON comment;\n\nDROP TRIGGER IF EXISTS site_aggregates_post_insert ON post;\n\nDROP TRIGGER IF EXISTS site_aggregates_post_delete ON post;\n\nDROP TRIGGER IF EXISTS site_aggregates_community_insert ON community;\n\nDROP TRIGGER IF EXISTS site_aggregates_community_delete ON community;\n\nDROP TRIGGER IF EXISTS person_aggregates_post_count ON post;\n\nDROP TRIGGER IF EXISTS person_aggregates_comment_count ON comment;\n\nCREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 'f'\n                AND NEW.deleted = 't')\n            OR (OLD.removed = 'f'\n                AND NEW.removed = 't'));\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'INSERT') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 't'\n                AND NEW.deleted = 'f')\n            OR (OLD.removed = 't'\n                AND NEW.removed = 'f'));\nEND\n$$;\n\n-- Community aggregate functions\nCREATE OR REPLACE FUNCTION community_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts + 1\n        WHERE\n            community_id = NEW.community_id;\n        IF (TG_OP = 'UPDATE') THEN\n            -- Post was restored, so restore comment counts as well\n            UPDATE\n                community_aggregates ca\n            SET\n                posts = coalesce(cd.posts, 0),\n                comments = coalesce(cd.comments, 0)\n            FROM (\n                SELECT\n                    c.id,\n                    count(DISTINCT p.id) AS posts,\n                    count(DISTINCT ct.id) AS comments\n                FROM\n                    community c\n                LEFT JOIN post p ON c.id = p.community_id\n                    AND p.deleted = 'f'\n                    AND p.removed = 'f'\n            LEFT JOIN comment ct ON p.id = ct.post_id\n                AND ct.deleted = 'f'\n                AND ct.removed = 'f'\n        WHERE\n            c.id = NEW.community_id\n        GROUP BY\n            c.id) cd\n        WHERE\n            ca.community_id = NEW.community_id;\n        END IF;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts - 1\n        WHERE\n            community_id = OLD.community_id;\n        -- Update the counts if the post got deleted\n        UPDATE\n            community_aggregates ca\n        SET\n            posts = coalesce(cd.posts, 0),\n            comments = coalesce(cd.comments, 0)\n        FROM (\n            SELECT\n                c.id,\n                count(DISTINCT p.id) AS posts,\n                count(DISTINCT ct.id) AS comments\n            FROM\n                community c\n            LEFT JOIN post p ON c.id = p.community_id\n                AND p.deleted = 'f'\n                AND p.removed = 'f'\n        LEFT JOIN comment ct ON p.id = ct.post_id\n            AND ct.deleted = 'f'\n            AND ct.removed = 'f'\n    WHERE\n        c.id = OLD.community_id\n    GROUP BY\n        c.id) cd\n    WHERE\n        ca.community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- comment count\nCREATE OR REPLACE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Community aggregate triggers\nCREATE TRIGGER community_aggregates_post_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_post_count ();\n\nCREATE TRIGGER community_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_comment_count ();\n\n-- Site aggregate functions\nCREATE OR REPLACE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_post_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_comment_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            comments = comments + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_comment_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            comments = comments - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_community_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            communities = communities + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_community_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            communities = communities - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Site aggregate triggers\nCREATE TRIGGER site_aggregates_post_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_insert ();\n\nCREATE TRIGGER site_aggregates_post_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_delete ();\n\nCREATE TRIGGER site_aggregates_comment_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_insert ();\n\nCREATE TRIGGER site_aggregates_comment_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_comment_delete ();\n\nCREATE TRIGGER site_aggregates_community_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON community\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_insert ();\n\nCREATE TRIGGER site_aggregates_community_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON community\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_delete ();\n\n-- Person aggregate functions\nCREATE OR REPLACE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                person u\n            LEFT JOIN post p ON u.id = p.creator_id\n                AND p.deleted = 'f'\n                AND p.removed = 'f'\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        u.id) pd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                person u\n            LEFT JOIN comment c ON u.id = c.creator_id\n                AND c.deleted = 'f'\n                AND c.removed = 'f'\n        LEFT JOIN comment_like cl ON c.id = cl.comment_id\n    GROUP BY\n        u.id) cd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Person aggregate triggers\nCREATE TRIGGER person_aggregates_post_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_count ();\n\nCREATE TRIGGER person_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_comment_count ();\n\n"
  },
  {
    "path": "migrations/2022-04-11-210137_fix_unique_changeme/down.sql",
    "content": "CREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'http://changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '')\n    FROM\n        generate_series(1, 20)\n$$;\n\n"
  },
  {
    "path": "migrations/2022-04-11-210137_fix_unique_changeme/up.sql",
    "content": "CREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'http://changeme.invalid/' || substr(md5(random()::text), 0, 25);\n$$;\n\n"
  },
  {
    "path": "migrations/2022-04-12-114352_default_post_listing_type/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN default_post_listing_type;\n\n"
  },
  {
    "path": "migrations/2022-04-12-114352_default_post_listing_type/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN default_post_listing_type text NOT NULL DEFAULT 'Local';\n\n"
  },
  {
    "path": "migrations/2022-04-12-185205_change_default_listing_type_to_local/down.sql",
    "content": "-- 0 is All, 1 is Local, 2 is Subscribed\nALTER TABLE ONLY local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 2;\n\n"
  },
  {
    "path": "migrations/2022-04-12-185205_change_default_listing_type_to_local/up.sql",
    "content": "-- 0 is All, 1 is Local, 2 is Subscribed\nALTER TABLE ONLY local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 1;\n\n"
  },
  {
    "path": "migrations/2022-04-19-111004_default_require_application/down.sql",
    "content": "ALTER TABLE site\n    ALTER COLUMN require_application SET DEFAULT FALSE;\n\nALTER TABLE site\n    ALTER COLUMN application_question SET DEFAULT NULL;\n\n"
  },
  {
    "path": "migrations/2022-04-19-111004_default_require_application/up.sql",
    "content": "ALTER TABLE site\n    ALTER COLUMN require_application SET DEFAULT TRUE;\n\nALTER TABLE site\n    ALTER COLUMN application_question SET DEFAULT 'To verify that you are human, please explain why you want to create an account on this site';\n\n"
  },
  {
    "path": "migrations/2022-04-26-105145_only_mod_can_post/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN posting_restricted_to_mods;\n\n"
  },
  {
    "path": "migrations/2022-04-26-105145_only_mod_can_post/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN posting_restricted_to_mods boolean DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2022-05-19-153931_legal-information/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN legal_information;\n\n"
  },
  {
    "path": "migrations/2022-05-19-153931_legal-information/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN legal_information text;\n\n"
  },
  {
    "path": "migrations/2022-05-20-135341_embed-url/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN embed_video_url;\n\nALTER TABLE post\n    ADD COLUMN embed_html text;\n\n"
  },
  {
    "path": "migrations/2022-05-20-135341_embed-url/up.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN embed_html;\n\nALTER TABLE post\n    ADD COLUMN embed_video_url text;\n\n"
  },
  {
    "path": "migrations/2022-06-12-012121_add_site_hide_modlog_names/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN hide_modlog_mod_names;\n\n"
  },
  {
    "path": "migrations/2022-06-12-012121_add_site_hide_modlog_names/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2022-06-13-124806_post_report_name_length/down.sql",
    "content": "ALTER TABLE post_report\n    ALTER COLUMN original_post_name TYPE varchar(100);\n\n"
  },
  {
    "path": "migrations/2022-06-13-124806_post_report_name_length/up.sql",
    "content": "-- adjust length limit to match post.name\nALTER TABLE post_report\n    ALTER COLUMN original_post_name TYPE varchar(200);\n\n"
  },
  {
    "path": "migrations/2022-06-21-123144_language-tags/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN language_id;\n\nDROP TABLE local_user_language;\n\nDROP TABLE LANGUAGE;\n\nALTER TABLE local_user RENAME COLUMN interface_language TO lang;\n\n"
  },
  {
    "path": "migrations/2022-06-21-123144_language-tags/up.sql",
    "content": "CREATE TABLE\nLANGUAGE (\n    id serial PRIMARY KEY,\n    code varchar(3),\n    name text\n);\n\nCREATE TABLE local_user_language (\n    id serial PRIMARY KEY,\n    local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    language_id int REFERENCES\n    LANGUAGE ON\n    UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    UNIQUE (local_user_id, language_id)\n);\n\nALTER TABLE local_user RENAME COLUMN lang TO interface_language;\n\nINSERT INTO\nLANGUAGE (id, code, name)\n    VALUES (0, 'und', 'Undetermined');\n\nALTER TABLE post\n    ADD COLUMN language_id integer REFERENCES LANGUAGE NOT\n    NULL DEFAULT 0;\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('aa', 'Afaraf');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ab', 'аҧсуа бызшәа');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ae', 'avesta');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('af', 'Afrikaans');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ak', 'Akan');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('am', 'አማርኛ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('an', 'aragonés');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ar', 'اَلْعَرَبِيَّةُ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('as', 'অসমীয়া');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('av', 'авар мацӀ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ay', 'aymar aru');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('az', 'azərbaycan dili');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ba', 'башҡорт теле');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('be', 'беларуская мова');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bg', 'български език');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bi', 'Bislama');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bm', 'bamanankan');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bn', 'বাংলা');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bo', 'བོད་ཡིག');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('br', 'brezhoneg');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('bs', 'bosanski jezik');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ca', 'Català');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ce', 'нохчийн мотт');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ch', 'Chamoru');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('co', 'corsu');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('cr', 'ᓀᐦᐃᔭᐍᐏᐣ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('cs', 'čeština');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('cu', 'ѩзыкъ словѣньскъ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('cv', 'чӑваш чӗлхи');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('cy', 'Cymraeg');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('da', 'dansk');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('de', 'Deutsch');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('dv', 'ދިވެހި');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('dz', 'རྫོང་ཁ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ee', 'Eʋegbe');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('el', 'Ελληνικά');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('en', 'English');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('eo', 'Esperanto');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('es', 'Español');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('et', 'eesti');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('eu', 'euskara');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fa', 'فارسی');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ff', 'Fulfulde');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fi', 'suomi');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fj', 'vosa Vakaviti');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fo', 'føroyskt');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fr', 'Français');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('fy', 'Frysk');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ga', 'Gaeilge');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('gd', 'Gàidhlig');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('gl', 'galego');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('gn', E'Avañe\\'ẽ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('gu', 'ગુજરાતી');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('gv', 'Gaelg');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ha', 'هَوُسَ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('he', 'עברית');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('hi', 'हिन्दी');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ho', 'Hiri Motu');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('hr', 'Hrvatski');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ht', 'Kreyòl ayisyen');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('hu', 'magyar');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('hy', 'Հայերեն');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('hz', 'Otjiherero');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ia', 'Interlingua');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('id', 'Bahasa Indonesia');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ie', 'Interlingue');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ig', 'Asụsụ Igbo');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ii', 'ꆈꌠ꒿ Nuosuhxop');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ik', 'Iñupiaq');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('io', 'Ido');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('is', 'Íslenska');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('it', 'Italiano');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('iu', 'ᐃᓄᒃᑎᑐᑦ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ja', '日本語');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('jv', 'basa Jawa');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ka', 'ქართული');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kg', 'Kikongo');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ki', 'Gĩkũyũ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kj', 'Kuanyama');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kk', 'қазақ тілі');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kl', 'kalaallisut');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('km', 'ខេមរភាសា');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kn', 'ಕನ್ನಡ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ko', '한국어');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kr', 'Kanuri');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ks', 'कश्मीरी');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ku', 'Kurdî');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kv', 'коми кыв');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('kw', 'Kernewek');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ky', 'Кыргызча');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('la', 'latine');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lb', 'Lëtzebuergesch');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lg', 'Luganda');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('li', 'Limburgs');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ln', 'Lingála');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lo', 'ພາສາລາວ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lt', 'lietuvių kalba');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lu', 'Kiluba');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('lv', 'latviešu valoda');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mg', 'fiteny malagasy');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mh', 'Kajin M̧ajeļ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mi', 'te reo Māori');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mk', 'македонски јазик');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ml', 'മലയാളം');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mn', 'Монгол хэл');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mr', 'मराठी');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ms', 'Bahasa Melayu');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('mt', 'Malti');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('my', 'ဗမာစာ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('na', 'Dorerin Naoero');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nb', 'Norsk bokmål');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nd', 'isiNdebele');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ne', 'नेपाली');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ng', 'Owambo');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nl', 'Nederlands');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nn', 'Norsk nynorsk');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('no', 'Norsk');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nr', 'isiNdebele');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('nv', 'Diné bizaad');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ny', 'chiCheŵa');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('oc', 'occitan');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('oj', 'ᐊᓂᔑᓈᐯᒧᐎᓐ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('om', 'Afaan Oromoo');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('or', 'ଓଡ଼ିଆ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('os', 'ирон æвзаг');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('pa', 'ਪੰਜਾਬੀ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('pi', 'पाऴि');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('pl', 'Polski');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ps', 'پښتو');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('pt', 'Português');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('qu', 'Runa Simi');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('rm', 'rumantsch grischun');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('rn', 'Ikirundi');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ro', 'Română');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ru', 'Русский');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('rw', 'Ikinyarwanda');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sa', 'संस्कृतम्');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sc', 'sardu');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sd', 'सिन्धी');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('se', 'Davvisámegiella');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sg', 'yângâ tî sängö');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('si', 'සිංහල');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sk', 'slovenčina');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sl', 'slovenščina');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sm', E'gagana fa\\'a Samoa');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sn', 'chiShona');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('so', 'Soomaaliga');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sq', 'Shqip');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sr', 'српски језик');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ss', 'SiSwati');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('st', 'Sesotho');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('su', 'Basa Sunda');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sv', 'Svenska');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('sw', 'Kiswahili');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ta', 'தமிழ்');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('te', 'తెలుగు');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tg', 'тоҷикӣ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('th', 'ไทย');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ti', 'ትግርኛ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tk', 'Türkmençe');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tl', 'Wikang Tagalog');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tn', 'Setswana');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('to', 'faka Tonga');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tr', 'Türkçe');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ts', 'Xitsonga');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tt', 'татар теле');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('tw', 'Twi');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ty', 'Reo Tahiti');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ug', 'ئۇيغۇرچە‎');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('uk', 'Українська');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ur', 'اردو');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('uz', 'Ўзбек');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('ve', 'Tshivenḓa');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('vi', 'Tiếng Việt');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('vo', 'Volapük');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('wa', 'walon');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('wo', 'Wollof');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('xh', 'isiXhosa');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('yi', 'ייִדיש');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('yo', 'Yorùbá');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('za', 'Saɯ cueŋƅ');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('zh', '中文');\n\nINSERT INTO\nLANGUAGE (code, name)\n    VALUES ('zu', 'isiZulu');\n\n"
  },
  {
    "path": "migrations/2022-07-07-182650_comment_ltrees/down.sql",
    "content": "ALTER TABLE comment\n    ADD COLUMN parent_id integer;\n\n-- Constraints and index\nALTER TABLE comment\n    ADD CONSTRAINT comment_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE INDEX idx_comment_parent ON comment (parent_id);\n\n-- Update the parent_id column\n-- subpath(subpath(0, -1), -1) gets the immediate parent but it fails null checks\nUPDATE\n    comment\nSET\n    parent_id = cast(ltree2text (nullif (subpath (nullif (subpath (path, 0, -1), '0'), -1), '0')) AS INTEGER);\n\nALTER TABLE comment\n    DROP COLUMN path;\n\nALTER TABLE comment_aggregates\n    DROP COLUMN child_count;\n\nDROP EXTENSION ltree;\n\n-- Add back in the read column\nALTER TABLE comment\n    ADD COLUMN read boolean DEFAULT FALSE NOT NULL;\n\nUPDATE\n    comment c\nSET\n    read = cr.read\nFROM\n    comment_reply cr\nWHERE\n    cr.comment_id = c.id;\n\nCREATE VIEW comment_alias_1 AS\nSELECT\n    *\nFROM\n    comment;\n\nDROP TABLE comment_reply;\n\n"
  },
  {
    "path": "migrations/2022-07-07-182650_comment_ltrees/up.sql",
    "content": "-- Remove the comment.read column, and create a new comment_reply table,\n-- similar to the person_mention table.\n--\n-- This is necessary because self-joins using ltrees would be too tough with SQL views\n--\n-- Every comment should have a row here, because all comments have a recipient,\n-- either the post creator, or the parent commenter.\nCREATE TABLE comment_reply (\n    id serial PRIMARY KEY,\n    recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read boolean DEFAULT FALSE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (recipient_id, comment_id)\n);\n\n-- Ones where parent_id is null, use the post creator recipient\nINSERT INTO comment_reply (recipient_id, comment_id, read)\nSELECT\n    p.creator_id,\n    c.id,\n    c.read\nFROM\n    comment c\n    INNER JOIN post p ON c.post_id = p.id\nWHERE\n    c.parent_id IS NULL;\n\n--  Ones where there is a parent_id, self join to comment to get the parent comment creator\nINSERT INTO comment_reply (recipient_id, comment_id, read)\nSELECT\n    c2.creator_id,\n    c.id,\n    c.read\nFROM\n    comment c\n    INNER JOIN comment c2 ON c.parent_id = c2.id;\n\n-- Drop comment_alias view\nDROP VIEW comment_alias_1;\n\nALTER TABLE comment\n    DROP COLUMN read;\n\nCREATE EXTENSION IF NOT EXISTS ltree;\n\nALTER TABLE comment\n    ADD COLUMN path ltree NOT NULL DEFAULT '0';\n\nALTER TABLE comment_aggregates\n    ADD COLUMN child_count integer NOT NULL DEFAULT 0;\n\n-- The ltree path column should be the comment_id parent paths, separated by dots.\n-- Stackoverflow: building an ltree from a parent_id hierarchical tree:\n-- https://stackoverflow.com/a/1144848/1655478\nCREATE TEMPORARY TABLE comment_temp AS\nWITH RECURSIVE q AS (\n    SELECT\n        h,\n        1 AS level,\n        ARRAY[id] AS breadcrumb\n    FROM\n        comment h\n    WHERE\n        parent_id IS NULL\n    UNION ALL\n    SELECT\n        hi,\n        q.level + 1 AS level,\n        breadcrumb || id\n    FROM\n        q\n        JOIN comment hi ON hi.parent_id = (q.h).id\n)\nSELECT\n    (q.h).id,\n    (q.h).parent_id,\n    level,\n    breadcrumb::varchar AS path,\n    text2ltree ('0.' || array_to_string(breadcrumb, '.')) AS ltree_path\nFROM\n    q\nORDER BY\n    breadcrumb;\n\n-- Remove indexes and foreign key constraints, and disable triggers for faster updates\nALTER TABLE comment DISABLE TRIGGER USER;\n\nALTER TABLE comment\n    DROP CONSTRAINT IF EXISTS comment_creator_id_fkey;\n\nALTER TABLE comment\n    DROP CONSTRAINT IF EXISTS comment_parent_id_fkey;\n\nALTER TABLE comment\n    DROP CONSTRAINT IF EXISTS comment_post_id_fkey;\n\nALTER TABLE comment\n    DROP CONSTRAINT IF EXISTS idx_comment_ap_id;\n\nDROP INDEX IF EXISTS idx_comment_creator;\n\nDROP INDEX IF EXISTS idx_comment_parent;\n\nDROP INDEX IF EXISTS idx_comment_post;\n\nDROP INDEX IF EXISTS idx_comment_published;\n\n-- Add the ltree column\nUPDATE\n    comment c\nSET\n    path = ct.ltree_path\nFROM\n    comment_temp ct\nWHERE\n    c.id = ct.id;\n\n-- Without this, `DROP EXTENSION` in down.sql throws an object dependency error if up.sql and down.sql\n-- are run in the same database connection\nDROP TABLE comment_temp;\n\n-- Update the child counts\nUPDATE\n    comment_aggregates ca\nSET\n    child_count = c2.child_count\nFROM (\n    SELECT\n        c.id,\n        c.path,\n        count(c2.id) AS child_count\n    FROM\n        comment c\n    LEFT JOIN comment c2 ON c2.path <@ c.path\n        AND c2.path != c.path\nGROUP BY\n    c.id) AS c2\nWHERE\n    ca.comment_id = c2.id;\n\n-- Delete comments at a depth of > 150, otherwise the index creation below will fail\nDELETE FROM comment\nWHERE nlevel (path) > 150;\n\n-- Delete from comment where there is a missing post\nDELETE FROM comment c\nWHERE NOT EXISTS (\n        SELECT\n        FROM\n            post p\n        WHERE\n            p.id = c.post_id);\n\n-- Delete from comment where there is a missing creator_id\nDELETE FROM comment c\nWHERE NOT EXISTS (\n        SELECT\n        FROM\n            person p\n        WHERE\n            p.id = c.creator_id);\n\n-- Re-enable old constraints and indexes\nALTER TABLE comment\n    ADD CONSTRAINT \"comment_creator_id_fkey\" FOREIGN KEY (creator_id) REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE comment\n    ADD CONSTRAINT \"comment_post_id_fkey\" FOREIGN KEY (post_id) REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE comment\n    ADD CONSTRAINT \"idx_comment_ap_id\" UNIQUE (ap_id);\n\nCREATE INDEX idx_comment_creator ON comment (creator_id);\n\nCREATE INDEX idx_comment_post ON comment (post_id);\n\nCREATE INDEX idx_comment_published ON comment (published DESC);\n\n-- Create the index\nCREATE INDEX idx_path_gist ON comment USING gist (path);\n\n-- Drop the parent_id column\nALTER TABLE comment\n    DROP COLUMN parent_id CASCADE;\n\nALTER TABLE comment ENABLE TRIGGER USER;\n\n"
  },
  {
    "path": "migrations/2022-08-04-150644_add_application_email_admins/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN application_email_admins;\n\n"
  },
  {
    "path": "migrations/2022-08-04-150644_add_application_email_admins/up.sql",
    "content": "-- Adding a field to email admins for new applications\nALTER TABLE site\n    ADD COLUMN application_email_admins boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2022-08-04-214722_add_distinguished_comment/down.sql",
    "content": "ALTER TABLE comment\n    DROP COLUMN distinguished;\n\n"
  },
  {
    "path": "migrations/2022-08-04-214722_add_distinguished_comment/up.sql",
    "content": "ALTER TABLE comment\n    ADD COLUMN distinguished boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2022-08-05-203502_add_person_post_aggregates/down.sql",
    "content": "DROP TABLE person_post_aggregates;\n\n"
  },
  {
    "path": "migrations/2022-08-05-203502_add_person_post_aggregates/up.sql",
    "content": "-- This table stores the # of read comments for a person, on a post\n-- It can then be joined to post_aggregates to get an unread count:\n-- unread = post_aggregates.comments - person_post_aggregates.read_comments\nCREATE TABLE person_post_aggregates (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read_comments bigint NOT NULL DEFAULT 0,\n    published timestamp NOT NULL DEFAULT now(),\n    UNIQUE (person_id, post_id)\n);\n\n"
  },
  {
    "path": "migrations/2022-08-22-193848_comment-language-tags/down.sql",
    "content": "ALTER TABLE comment\n    DROP COLUMN language_id;\n\n"
  },
  {
    "path": "migrations/2022-08-22-193848_comment-language-tags/up.sql",
    "content": "ALTER TABLE comment\n    ADD COLUMN language_id integer REFERENCES LANGUAGE NOT\n    NULL DEFAULT 0;\n\n"
  },
  {
    "path": "migrations/2022-09-07-113813_drop_ccnew_indexes_function/down.sql",
    "content": "DROP FUNCTION drop_ccnew_indexes;\n\n"
  },
  {
    "path": "migrations/2022-09-07-113813_drop_ccnew_indexes_function/up.sql",
    "content": "CREATE OR REPLACE FUNCTION drop_ccnew_indexes ()\n    RETURNS integer\n    AS $$\nDECLARE\n    i RECORD;\nBEGIN\n    FOR i IN (\n        SELECT\n            relname\n        FROM\n            pg_class\n        WHERE\n            relname LIKE '%ccnew%')\n        LOOP\n            EXECUTE 'DROP INDEX ' || i.relname;\n        END LOOP;\n    RETURN 1;\nEND;\n$$\nLANGUAGE plpgsql;\n\n"
  },
  {
    "path": "migrations/2022-09-07-114618_pm-reports/down.sql",
    "content": "DROP TABLE private_message_report;\n\n"
  },
  {
    "path": "migrations/2022-09-07-114618_pm-reports/up.sql",
    "content": "CREATE TABLE private_message_report (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting comment\n    private_message_id int REFERENCES private_message ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- comment being reported\n    original_pm_text text NOT NULL,\n    reason text NOT NULL,\n    resolved bool NOT NULL DEFAULT FALSE,\n    resolver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL,\n    UNIQUE (private_message_id, creator_id) -- users should only be able to report a pm once\n);\n\n"
  },
  {
    "path": "migrations/2022-09-08-102358_site-and-community-languages/down.sql",
    "content": "DROP TABLE site_language;\n\nDROP TABLE community_language;\n\nDELETE FROM local_user_language;\n\n"
  },
  {
    "path": "migrations/2022-09-08-102358_site-and-community-languages/up.sql",
    "content": "CREATE TABLE site_language (\n    id serial PRIMARY KEY,\n    site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    language_id int REFERENCES\n    LANGUAGE ON\n    UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    UNIQUE (site_id, language_id)\n);\n\nCREATE TABLE community_language (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    language_id int REFERENCES\n    LANGUAGE ON\n    UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    UNIQUE (community_id, language_id)\n);\n\n-- update existing users, sites and communities to have all languages enabled\nDO $$\nDECLARE\n    xid integer;\nBEGIN\n    FOR xid IN\n    SELECT\n        id\n    FROM\n        local_user LOOP\n            INSERT INTO local_user_language (local_user_id, language_id) (\n                SELECT\n                    xid,\n                    language.id AS lid\n                FROM\n                    LANGUAGE);\n        END LOOP;\n    FOR xid IN\n    SELECT\n        id\n    FROM\n        site LOOP\n            INSERT INTO site_language (site_id, language_id) (\n                SELECT\n                    xid,\n                    language.id AS lid\n                FROM\n                    LANGUAGE);\n        END LOOP;\n    FOR xid IN\n    SELECT\n        id\n    FROM\n        community LOOP\n            INSERT INTO community_language (community_id, language_id) (\n                SELECT\n                    xid,\n                    language.id AS lid\n                FROM\n                    LANGUAGE);\n        END LOOP;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2022-09-24-161829_remove_table_aliases/down.sql",
    "content": "CREATE VIEW person_alias_1 AS\nSELECT\n    *\nFROM\n    person;\n\nCREATE VIEW person_alias_2 AS\nSELECT\n    *\nFROM\n    person;\n\n"
  },
  {
    "path": "migrations/2022-09-24-161829_remove_table_aliases/up.sql",
    "content": "-- Drop the alias views\nDROP VIEW person_alias_1, person_alias_2;\n\n"
  },
  {
    "path": "migrations/2022-10-06-183632_move_blocklist_to_db/down.sql",
    "content": "-- Add back site columns\nALTER TABLE site\n    ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN open_registration boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN enable_nsfw boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN community_creation_admin_only boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN require_email_verification boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN require_application boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN application_question text DEFAULT 'To verify that you are human, please explain why you want to create an account on this site'::text,\n    ADD COLUMN private_instance boolean DEFAULT FALSE NOT NULL,\n    ADD COLUMN default_theme text DEFAULT 'browser'::text NOT NULL,\n    ADD COLUMN default_post_listing_type text DEFAULT 'Local'::text NOT NULL,\n    ADD COLUMN legal_information text,\n    ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN application_email_admins boolean DEFAULT FALSE NOT NULL;\n\n-- Insert the data back from local_site\nUPDATE\n    site\nSET\n    enable_downvotes = ls.enable_downvotes,\n    open_registration = ls.open_registration,\n    enable_nsfw = ls.enable_nsfw,\n    community_creation_admin_only = ls.community_creation_admin_only,\n    require_email_verification = ls.require_email_verification,\n    require_application = ls.require_application,\n    application_question = ls.application_question,\n    private_instance = ls.private_instance,\n    default_theme = ls.default_theme,\n    default_post_listing_type = ls.default_post_listing_type,\n    legal_information = ls.legal_information,\n    hide_modlog_mod_names = ls.hide_modlog_mod_names,\n    application_email_admins = ls.application_email_admins,\n    published = ls.published,\n    updated = ls.updated\nFROM (\n    SELECT\n        site_id,\n        enable_downvotes,\n        open_registration,\n        enable_nsfw,\n        community_creation_admin_only,\n        require_email_verification,\n        require_application,\n        application_question,\n        private_instance,\n        default_theme,\n        default_post_listing_type,\n        legal_information,\n        hide_modlog_mod_names,\n        application_email_admins,\n        published,\n        updated\n    FROM\n        local_site) AS ls\nWHERE\n    site.id = ls.site_id;\n\n-- drop instance columns\nALTER TABLE site\n    DROP COLUMN instance_id;\n\nALTER TABLE person\n    DROP COLUMN instance_id;\n\nALTER TABLE community\n    DROP COLUMN instance_id;\n\nDROP TABLE local_site_rate_limit;\n\nDROP TABLE local_site;\n\nDROP TABLE federation_allowlist;\n\nDROP TABLE federation_blocklist;\n\nDROP TABLE instance;\n\n"
  },
  {
    "path": "migrations/2022-10-06-183632_move_blocklist_to_db/up.sql",
    "content": "-- Create an instance table\n-- Holds any connected or unconnected domain\nCREATE TABLE instance (\n    id serial PRIMARY KEY,\n    domain varchar(255) NOT NULL UNIQUE,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL\n);\n\n-- Insert all the domains to the instance table\nINSERT INTO instance (DOMAIN)\nSELECT DISTINCT\n    substring(p.actor_id FROM '(?:.*://)?(?:www\\.)?([^/?]*)')\nFROM (\n    SELECT\n        actor_id\n    FROM\n        site\n    UNION\n    SELECT\n        actor_id\n    FROM\n        person\n    UNION\n    SELECT\n        actor_id\n    FROM\n        community) AS p;\n\n-- Alter site, person, and community tables to reference the instance table.\nALTER TABLE site\n    ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE person\n    ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE community\n    ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Add those columns\nUPDATE\n    site\nSET\n    instance_id = i.id\nFROM\n    instance i\nWHERE\n    substring(actor_id FROM '(?:.*://)?(?:www\\.)?([^/?]*)') = i.domain;\n\nUPDATE\n    person\nSET\n    instance_id = i.id\nFROM\n    instance i\nWHERE\n    substring(actor_id FROM '(?:.*://)?(?:www\\.)?([^/?]*)') = i.domain;\n\nUPDATE\n    community\nSET\n    instance_id = i.id\nFROM\n    instance i\nWHERE\n    substring(actor_id FROM '(?:.*://)?(?:www\\.)?([^/?]*)') = i.domain;\n\n-- Make those columns unique not null now\nALTER TABLE site\n    ALTER COLUMN instance_id SET NOT NULL;\n\nALTER TABLE site\n    ADD CONSTRAINT idx_site_instance_unique UNIQUE (instance_id);\n\nALTER TABLE person\n    ALTER COLUMN instance_id SET NOT NULL;\n\nALTER TABLE community\n    ALTER COLUMN instance_id SET NOT NULL;\n\n-- Create allowlist and blocklist tables\nCREATE TABLE federation_allowlist (\n    id serial PRIMARY KEY,\n    instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL\n);\n\nCREATE TABLE federation_blocklist (\n    id serial PRIMARY KEY,\n    instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp NULL\n);\n\n-- Move all the extra site settings-type columns to a local_site table\n-- Add a lot of other fields currently in the lemmy.hjson\nCREATE TABLE local_site (\n    id serial PRIMARY KEY,\n    site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE,\n    -- Site table fields\n    site_setup boolean DEFAULT FALSE NOT NULL,\n    enable_downvotes boolean DEFAULT TRUE NOT NULL,\n    open_registration boolean DEFAULT TRUE NOT NULL,\n    enable_nsfw boolean DEFAULT TRUE NOT NULL,\n    community_creation_admin_only boolean DEFAULT FALSE NOT NULL,\n    require_email_verification boolean DEFAULT FALSE NOT NULL,\n    require_application boolean DEFAULT TRUE NOT NULL,\n    application_question text DEFAULT 'to verify that you are human, please explain why you want to create an account on this site'::text,\n    private_instance boolean DEFAULT FALSE NOT NULL,\n    default_theme text DEFAULT 'browser'::text NOT NULL,\n    default_post_listing_type text DEFAULT 'Local'::text NOT NULL,\n    legal_information text,\n    hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL,\n    application_email_admins boolean DEFAULT FALSE NOT NULL,\n    -- Fields from lemmy.hjson\n    slur_filter_regex text,\n    actor_name_max_length int DEFAULT 20 NOT NULL,\n    federation_enabled boolean DEFAULT TRUE NOT NULL,\n    federation_debug boolean DEFAULT FALSE NOT NULL,\n    federation_strict_allowlist boolean DEFAULT TRUE NOT NULL,\n    federation_http_fetch_retry_limit int DEFAULT 25 NOT NULL,\n    federation_worker_count int DEFAULT 64 NOT NULL,\n    captcha_enabled boolean DEFAULT FALSE NOT NULL,\n    captcha_difficulty varchar(255) DEFAULT 'medium' NOT NULL,\n    -- Time fields\n    published timestamp without time zone DEFAULT now() NOT NULL,\n    updated timestamp without time zone\n);\n\n-- local_site_rate_limit is its own table, so as to not go over 32 columns, and force diesel to use the 64-column-tables feature\nCREATE TABLE local_site_rate_limit (\n    id serial PRIMARY KEY,\n    local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE,\n    message int DEFAULT 180 NOT NULL,\n    message_per_second int DEFAULT 60 NOT NULL,\n    post int DEFAULT 6 NOT NULL,\n    post_per_second int DEFAULT 600 NOT NULL,\n    register int DEFAULT 3 NOT NULL,\n    register_per_second int DEFAULT 3600 NOT NULL,\n    image int DEFAULT 6 NOT NULL,\n    image_per_second int DEFAULT 3600 NOT NULL,\n    comment int DEFAULT 6 NOT NULL,\n    comment_per_second int DEFAULT 600 NOT NULL,\n    search int DEFAULT 60 NOT NULL,\n    search_per_second int DEFAULT 600 NOT NULL,\n    published timestamp without time zone DEFAULT now() NOT NULL,\n    updated timestamp without time zone\n);\n\n-- Insert the data into local_site\nINSERT INTO local_site (site_id, site_setup, enable_downvotes, open_registration, enable_nsfw, community_creation_admin_only, require_email_verification, require_application, application_question, private_instance, default_theme, default_post_listing_type, legal_information, hide_modlog_mod_names, application_email_admins, published, updated)\nSELECT\n    id,\n    TRUE, -- Assume site if setup if there's already a site row\n    enable_downvotes,\n    open_registration,\n    enable_nsfw,\n    community_creation_admin_only,\n    require_email_verification,\n    require_application,\n    application_question,\n    private_instance,\n    default_theme,\n    default_post_listing_type,\n    legal_information,\n    hide_modlog_mod_names,\n    application_email_admins,\n    published,\n    updated\nFROM\n    site\nORDER BY\n    id\nLIMIT 1;\n\n-- Default here\nINSERT INTO local_site_rate_limit (local_site_id)\nSELECT\n    id\nFROM\n    local_site\nORDER BY\n    id\nLIMIT 1;\n\n-- Drop all those columns from site\nALTER TABLE site\n    DROP COLUMN enable_downvotes,\n    DROP COLUMN open_registration,\n    DROP COLUMN enable_nsfw,\n    DROP COLUMN community_creation_admin_only,\n    DROP COLUMN require_email_verification,\n    DROP COLUMN require_application,\n    DROP COLUMN application_question,\n    DROP COLUMN private_instance,\n    DROP COLUMN default_theme,\n    DROP COLUMN default_post_listing_type,\n    DROP COLUMN legal_information,\n    DROP COLUMN hide_modlog_mod_names,\n    DROP COLUMN application_email_admins;\n\n"
  },
  {
    "path": "migrations/2022-11-13-181529_create_taglines/down.sql",
    "content": "DROP TABLE tagline;\n\n"
  },
  {
    "path": "migrations/2022-11-13-181529_create_taglines/up.sql",
    "content": "CREATE TABLE tagline (\n    id serial PRIMARY KEY,\n    local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    content text NOT NULL,\n    published timestamp without time zone DEFAULT now() NOT NULL,\n    updated timestamp without time zone\n);\n\n"
  },
  {
    "path": "migrations/2022-11-20-032430_sticky_local/down.sql",
    "content": "DROP TRIGGER IF EXISTS post_aggregates_featured_local ON post;\n\nDROP TRIGGER IF EXISTS post_aggregates_featured_community ON post;\n\nDROP FUNCTION post_aggregates_featured_community;\n\nDROP FUNCTION post_aggregates_featured_local;\n\nALTER TABLE post\n    ADD stickied boolean NOT NULL DEFAULT FALSE;\n\nUPDATE\n    post\nSET\n    stickied = featured_community;\n\nALTER TABLE post\n    DROP COLUMN featured_community;\n\nALTER TABLE post\n    DROP COLUMN featured_local;\n\nALTER TABLE post_aggregates\n    ADD stickied boolean NOT NULL DEFAULT FALSE;\n\nUPDATE\n    post_aggregates\nSET\n    stickied = featured_community;\n\nALTER TABLE post_aggregates\n    DROP COLUMN featured_community;\n\nALTER TABLE post_aggregates\n    DROP COLUMN featured_local;\n\nALTER TABLE mod_feature_post RENAME COLUMN featured TO stickied;\n\nALTER TABLE mod_feature_post\n    DROP COLUMN is_featured_community;\n\nALTER TABLE mod_feature_post\n    ALTER COLUMN stickied DROP NOT NULL;\n\nALTER TABLE mod_feature_post RENAME TO mod_sticky_post;\n\nCREATE FUNCTION post_aggregates_stickied ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        stickied = NEW.stickied\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_stickied\n    AFTER UPDATE ON post\n    FOR EACH ROW\n    WHEN (OLD.stickied IS DISTINCT FROM NEW.stickied)\n    EXECUTE PROCEDURE post_aggregates_stickied ();\n\nCREATE INDEX idx_post_aggregates_stickied_newest_comment_time ON post_aggregates (stickied DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_comments ON post_aggregates (stickied DESC, comments DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_hot ON post_aggregates (stickied DESC, hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_active ON post_aggregates (stickied DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_score ON post_aggregates (stickied DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_stickied_published ON post_aggregates (stickied DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2022-11-20-032430_sticky_local/up.sql",
    "content": "DROP TRIGGER IF EXISTS post_aggregates_stickied ON post;\n\nDROP FUNCTION post_aggregates_stickied;\n\nALTER TABLE post\n    ADD featured_community boolean NOT NULL DEFAULT FALSE;\n\nALTER TABLE post\n    ADD featured_local boolean NOT NULL DEFAULT FALSE;\n\nUPDATE\n    post\nSET\n    featured_community = stickied;\n\nALTER TABLE post\n    DROP COLUMN stickied;\n\nALTER TABLE post_aggregates\n    ADD featured_community boolean NOT NULL DEFAULT FALSE;\n\nALTER TABLE post_aggregates\n    ADD featured_local boolean NOT NULL DEFAULT FALSE;\n\nUPDATE\n    post_aggregates\nSET\n    featured_community = stickied;\n\nALTER TABLE post_aggregates\n    DROP COLUMN stickied;\n\nALTER TABLE mod_sticky_post RENAME COLUMN stickied TO featured;\n\nALTER TABLE mod_sticky_post\n    ALTER COLUMN featured SET NOT NULL;\n\nALTER TABLE mod_sticky_post\n    ADD is_featured_community boolean NOT NULL DEFAULT TRUE;\n\nALTER TABLE mod_sticky_post RENAME TO mod_feature_post;\n\nCREATE FUNCTION post_aggregates_featured_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        featured_community = NEW.featured_community\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_featured_local ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        featured_local = NEW.featured_local\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_featured_community\n    AFTER UPDATE ON public.post\n    FOR EACH ROW\n    WHEN (old.featured_community IS DISTINCT FROM new.featured_community)\n    EXECUTE FUNCTION public.post_aggregates_featured_community ();\n\nCREATE TRIGGER post_aggregates_featured_local\n    AFTER UPDATE ON public.post\n    FOR EACH ROW\n    WHEN (old.featured_local IS DISTINCT FROM new.featured_local)\n    EXECUTE FUNCTION public.post_aggregates_featured_local ();\n\n"
  },
  {
    "path": "migrations/2022-11-21-143249_remove-federation-settings/down.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN federation_strict_allowlist bool DEFAULT TRUE NOT NULL;\n\nALTER TABLE local_site\n    ADD COLUMN federation_http_fetch_retry_limit int NOT NULL DEFAULT 25;\n\n"
  },
  {
    "path": "migrations/2022-11-21-143249_remove-federation-settings/up.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN federation_strict_allowlist;\n\nALTER TABLE local_site\n    DROP COLUMN federation_http_fetch_retry_limit;\n\n"
  },
  {
    "path": "migrations/2022-11-21-204256_user-following/down.sql",
    "content": "DROP TABLE person_follower;\n\nALTER TABLE community_follower\n    ALTER COLUMN pending DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2022-11-21-204256_user-following/up.sql",
    "content": "-- create user follower table with two references to persons\nCREATE TABLE person_follower (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    follower_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp NOT NULL DEFAULT now(),\n    pending boolean NOT NULL,\n    UNIQUE (follower_id, person_id)\n);\n\nUPDATE\n    community_follower\nSET\n    pending = FALSE\nWHERE\n    pending IS NULL;\n\nALTER TABLE community_follower\n    ALTER COLUMN pending SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2022-12-05-110642_registration_mode/down.sql",
    "content": "-- add back old registration columns\nALTER TABLE local_site\n    ADD COLUMN open_registration boolean NOT NULL DEFAULT TRUE;\n\nALTER TABLE local_site\n    ADD COLUMN require_application boolean NOT NULL DEFAULT TRUE;\n\n-- regenerate their values\nWITH subquery AS (\n    SELECT\n        registration_mode,\n        CASE WHEN registration_mode = 'closed' THEN\n            FALSE\n        ELSE\n            TRUE\n        END\n    FROM\n        local_site)\nUPDATE\n    local_site\nSET\n    open_registration = subquery.case\nFROM\n    subquery;\n\nWITH subquery AS (\n    SELECT\n        registration_mode,\n        CASE WHEN registration_mode = 'open' THEN\n            FALSE\n        ELSE\n            TRUE\n        END\n    FROM\n        local_site)\nUPDATE\n    local_site\nSET\n    require_application = subquery.case\nFROM\n    subquery;\n\n-- drop new column and type\nALTER TABLE local_site\n    DROP COLUMN registration_mode;\n\nDROP TYPE registration_mode_enum;\n\n"
  },
  {
    "path": "migrations/2022-12-05-110642_registration_mode/up.sql",
    "content": "-- create enum for registration modes\nCREATE TYPE registration_mode_enum AS enum (\n    'closed',\n    'require_application',\n    'open'\n);\n\n-- use this enum for registration mode setting\nALTER TABLE local_site\n    ADD COLUMN registration_mode registration_mode_enum NOT NULL DEFAULT 'require_application';\n\n-- generate registration mode value from previous settings\nWITH subquery AS (\n    SELECT\n        open_registration,\n        require_application,\n        CASE WHEN open_registration = FALSE THEN\n            'closed'::registration_mode_enum\n        WHEN open_registration = TRUE\n            AND require_application = TRUE THEN\n            'require_application'\n        ELSE\n            'open'\n        END\n    FROM\n        local_site)\nUPDATE\n    local_site\nSET\n    registration_mode = subquery.case\nFROM\n    subquery;\n\n-- drop old registration settings\nALTER TABLE local_site\n    DROP COLUMN open_registration;\n\nALTER TABLE local_site\n    DROP COLUMN require_application;\n\n"
  },
  {
    "path": "migrations/2023-01-17-165819_cleanup_post_aggregates_indexes/down.sql",
    "content": "-- Drop the new indexes\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time, idx_post_aggregates_featured_community_newest_comment_time, idx_post_aggregates_featured_local_comments, idx_post_aggregates_featured_community_comments, idx_post_aggregates_featured_local_hot, idx_post_aggregates_featured_community_hot, idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active, idx_post_aggregates_featured_local_score, idx_post_aggregates_featured_community_score, idx_post_aggregates_featured_local_published, idx_post_aggregates_featured_community_published;\n\n-- Create the old indexes\nCREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_comments ON post_aggregates (comments DESC);\n\nCREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC);\n\nCREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC);\n\n"
  },
  {
    "path": "migrations/2023-01-17-165819_cleanup_post_aggregates_indexes/up.sql",
    "content": "-- Drop the old indexes\nDROP INDEX idx_post_aggregates_newest_comment_time, idx_post_aggregates_comments, idx_post_aggregates_hot, idx_post_aggregates_active, idx_post_aggregates_score, idx_post_aggregates_published;\n\n-- All of the post fetching queries now start with either\n-- featured_local desc, or featured_community desc, then the other sorts.\n-- So you now need to double these indexes\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates (featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_comments ON post_aggregates (featured_local DESC, comments DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_comments ON post_aggregates (featured_community DESC, comments DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank (score, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published ON post_aggregates (featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-02-01-012747_fix_active_index/down.sql",
    "content": "DROP INDEX idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active;\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC);\n\n"
  },
  {
    "path": "migrations/2023-02-01-012747_fix_active_index/up.sql",
    "content": "-- This should use the newest_comment_time_necro, not the newest_comment_time for the hot_rank\nDROP INDEX idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active;\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\n"
  },
  {
    "path": "migrations/2023-02-05-102549_drop-site-federation-debug/down.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN federation_debug boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-02-05-102549_drop-site-federation-debug/up.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN federation_debug;\n\n"
  },
  {
    "path": "migrations/2023-02-07-030958_community-collections/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN moderators_url;\n\nALTER TABLE community\n    DROP COLUMN featured_url;\n\n"
  },
  {
    "path": "migrations/2023-02-07-030958_community-collections/up.sql",
    "content": "ALTER TABLE community\n    ADD COLUMN moderators_url varchar(255) UNIQUE;\n\nALTER TABLE community\n    ADD COLUMN featured_url varchar(255) UNIQUE;\n\n"
  },
  {
    "path": "migrations/2023-02-11-173347_custom_emojis/down.sql",
    "content": "DROP TABLE custom_emoji_keyword;\n\nDROP TABLE custom_emoji;\n\n"
  },
  {
    "path": "migrations/2023-02-11-173347_custom_emojis/up.sql",
    "content": "CREATE TABLE custom_emoji (\n    id serial PRIMARY KEY,\n    local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    shortcode varchar(128) NOT NULL UNIQUE,\n    image_url text NOT NULL UNIQUE,\n    alt_text text NOT NULL,\n    category text NOT NULL,\n    published timestamp without time zone DEFAULT now() NOT NULL,\n    updated timestamp without time zone\n);\n\nCREATE TABLE custom_emoji_keyword (\n    id serial PRIMARY KEY,\n    custom_emoji_id int REFERENCES custom_emoji ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    keyword varchar(128) NOT NULL,\n    UNIQUE (custom_emoji_id, keyword)\n);\n\nCREATE INDEX idx_custom_emoji_category ON custom_emoji (id, category);\n\n"
  },
  {
    "path": "migrations/2023-02-13-172528_add_report_email_admins/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN reports_email_admins;\n\n"
  },
  {
    "path": "migrations/2023-02-13-172528_add_report_email_admins/up.sql",
    "content": "-- Adding a field to email admins for new reports\nALTER TABLE local_site\n    ADD COLUMN reports_email_admins boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2023-02-13-221303_add_instance_software_and_version/down.sql",
    "content": "ALTER TABLE instance\n    DROP COLUMN software;\n\nALTER TABLE instance\n    DROP COLUMN version;\n\n"
  },
  {
    "path": "migrations/2023-02-13-221303_add_instance_software_and_version/up.sql",
    "content": "-- Add Software and Version columns from nodeinfo to the instance table\nALTER TABLE instance\n    ADD COLUMN software varchar(255);\n\nALTER TABLE instance\n    ADD COLUMN version varchar(255);\n\n"
  },
  {
    "path": "migrations/2023-02-15-212546_add_post_comment_saved_indexes/down.sql",
    "content": "DROP INDEX idx_post_saved_person_id, idx_comment_saved_person_id;\n\n"
  },
  {
    "path": "migrations/2023-02-15-212546_add_post_comment_saved_indexes/up.sql",
    "content": "CREATE INDEX idx_post_saved_person_id ON post_saved (person_id);\n\nCREATE INDEX idx_comment_saved_person_id ON comment_saved (person_id);\n\n"
  },
  {
    "path": "migrations/2023-02-16-194139_add_totp_secret/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN totp_2fa_secret;\n\nALTER TABLE local_user\n    DROP COLUMN totp_2fa_url;\n\n"
  },
  {
    "path": "migrations/2023-02-16-194139_add_totp_secret/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN totp_2fa_secret text;\n\nALTER TABLE local_user\n    ADD COLUMN totp_2fa_url text;\n\n"
  },
  {
    "path": "migrations/2023-04-14-175955_add_listingtype_sorttype_enums/down.sql",
    "content": "-- Some fixes\nALTER TABLE community\n    ALTER COLUMN hidden DROP NOT NULL;\n\nALTER TABLE community\n    ALTER COLUMN posting_restricted_to_mods DROP NOT NULL;\n\nALTER TABLE activity\n    ALTER COLUMN sensitive DROP NOT NULL;\n\nALTER TABLE mod_add\n    ALTER COLUMN removed DROP NOT NULL;\n\nALTER TABLE mod_add_community\n    ALTER COLUMN removed DROP NOT NULL;\n\nALTER TABLE mod_ban\n    ALTER COLUMN banned DROP NOT NULL;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN banned DROP NOT NULL;\n\nALTER TABLE mod_hide_community\n    ALTER COLUMN hidden DROP NOT NULL;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN LOCKED DROP NOT NULL;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN removed DROP NOT NULL;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN removed DROP NOT NULL;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN removed DROP NOT NULL;\n\nALTER TABLE mod_transfer_community\n    ADD COLUMN removed boolean DEFAULT FALSE;\n\nALTER TABLE LANGUAGE\n    ALTER COLUMN code DROP NOT NULL;\n\nALTER TABLE LANGUAGE\n    ALTER COLUMN name DROP NOT NULL;\n\n-- Fix the registration mode enums\nALTER TYPE registration_mode_enum RENAME VALUE 'Closed' TO 'closed';\n\nALTER TYPE registration_mode_enum RENAME VALUE 'RequireApplication' TO 'require_application';\n\nALTER TYPE registration_mode_enum RENAME VALUE 'Open' TO 'open';\n\n-- add back old columns\n-- Alter the local_user table\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type TYPE smallint\n    USING\n        CASE default_sort_type\n        WHEN 'Active' THEN\n            0\n        WHEN 'Hot' THEN\n            1\n        WHEN 'New' THEN\n            2\n        WHEN 'Old' THEN\n            3\n        WHEN 'TopDay' THEN\n            4\n        WHEN 'TopWeek' THEN\n            5\n        WHEN 'TopMonth' THEN\n            6\n        WHEN 'TopYear' THEN\n            7\n        WHEN 'TopAll' THEN\n            8\n        WHEN 'MostComments' THEN\n            9\n        WHEN 'NewComments' THEN\n            10\n        ELSE\n            0\n        END;\n\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type SET DEFAULT 0;\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type TYPE smallint\n    USING\n        CASE default_listing_type\n        WHEN 'All' THEN\n            0\n        WHEN 'Local' THEN\n            1\n        WHEN 'Subscribed' THEN\n            2\n        ELSE\n            1\n        END;\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 1;\n\n-- Alter the local site column\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type DROP DEFAULT;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type TYPE text;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type SET DEFAULT 'Local';\n\n-- Drop the types\nDROP TYPE listing_type_enum;\n\nDROP TYPE sort_type_enum;\n\n"
  },
  {
    "path": "migrations/2023-04-14-175955_add_listingtype_sorttype_enums/up.sql",
    "content": "-- A few DB fixes\nALTER TABLE community\n    ALTER COLUMN hidden SET NOT NULL;\n\nALTER TABLE community\n    ALTER COLUMN posting_restricted_to_mods SET NOT NULL;\n\nALTER TABLE activity\n    ALTER COLUMN sensitive SET NOT NULL;\n\nALTER TABLE mod_add\n    ALTER COLUMN removed SET NOT NULL;\n\nALTER TABLE mod_add_community\n    ALTER COLUMN removed SET NOT NULL;\n\nALTER TABLE mod_ban\n    ALTER COLUMN banned SET NOT NULL;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN banned SET NOT NULL;\n\nALTER TABLE mod_hide_community\n    ALTER COLUMN hidden SET NOT NULL;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN LOCKED SET NOT NULL;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN removed SET NOT NULL;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN removed SET NOT NULL;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN removed SET NOT NULL;\n\nALTER TABLE mod_transfer_community\n    DROP COLUMN removed;\n\nALTER TABLE LANGUAGE\n    ALTER COLUMN code SET NOT NULL;\n\nALTER TABLE LANGUAGE\n    ALTER COLUMN name SET NOT NULL;\n\n-- Fix the registration mode enums\nALTER TYPE registration_mode_enum RENAME VALUE 'closed' TO 'Closed';\n\nALTER TYPE registration_mode_enum RENAME VALUE 'require_application' TO 'RequireApplication';\n\nALTER TYPE registration_mode_enum RENAME VALUE 'open' TO 'Open';\n\n-- Create the enums\nCREATE TYPE sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'TopDay',\n    'TopWeek',\n    'TopMonth',\n    'TopYear',\n    'TopAll',\n    'MostComments',\n    'NewComments'\n);\n\nCREATE TYPE listing_type_enum AS ENUM (\n    'All',\n    'Local',\n    'Subscribed'\n);\n\n-- Alter the local_user table\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type TYPE sort_type_enum\n    USING\n        CASE default_sort_type\n        WHEN 0 THEN\n            'Active'\n        WHEN 1 THEN\n            'Hot'\n        WHEN 2 THEN\n            'New'\n        WHEN 3 THEN\n            'Old'\n        WHEN 4 THEN\n            'TopDay'\n        WHEN 5 THEN\n            'TopWeek'\n        WHEN 6 THEN\n            'TopMonth'\n        WHEN 7 THEN\n            'TopYear'\n        WHEN 8 THEN\n            'TopAll'\n        WHEN 9 THEN\n            'MostComments'\n        WHEN 10 THEN\n            'NewComments'\n        ELSE\n            'Active'\n        END::sort_type_enum;\n\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type SET DEFAULT 'Active';\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type TYPE listing_type_enum\n    USING\n        CASE default_listing_type\n        WHEN 0 THEN\n            'All'\n        WHEN 1 THEN\n            'Local'\n        WHEN 2 THEN\n            'Subscribed'\n        ELSE\n            'Local'\n        END::listing_type_enum;\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type SET DEFAULT 'Local';\n\n-- Alter the local site column\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type DROP DEFAULT;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type TYPE listing_type_enum\n    USING default_post_listing_type::listing_type_enum;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type SET DEFAULT 'Local';\n\n"
  },
  {
    "path": "migrations/2023-04-23-164732_add_person_details_indexes/down.sql",
    "content": "DROP INDEX idx_person_lower_name;\n\nDROP INDEX idx_community_lower_name;\n\nDROP INDEX idx_community_moderator_published;\n\nDROP INDEX idx_community_moderator_community;\n\nDROP INDEX idx_community_moderator_person;\n\nDROP INDEX idx_comment_saved_comment;\n\nDROP INDEX idx_comment_saved_person;\n\nDROP INDEX idx_community_block_community;\n\nDROP INDEX idx_community_block_person;\n\nDROP INDEX idx_community_follower_community;\n\nDROP INDEX idx_community_follower_person;\n\nDROP INDEX idx_person_block_person;\n\nDROP INDEX idx_person_block_target;\n\nDROP INDEX idx_post_language;\n\nDROP INDEX idx_comment_language;\n\nDROP INDEX idx_person_aggregates_person;\n\nDROP INDEX idx_person_post_aggregates_post;\n\nDROP INDEX idx_person_post_aggregates_person;\n\nDROP INDEX idx_comment_reply_comment;\n\nDROP INDEX idx_comment_reply_recipient;\n\nDROP INDEX idx_comment_reply_published;\n\n"
  },
  {
    "path": "migrations/2023-04-23-164732_add_person_details_indexes/up.sql",
    "content": "-- Add a few indexes to speed up person details queries\nCREATE INDEX idx_person_lower_name ON person (lower(name));\n\nCREATE INDEX idx_community_lower_name ON community (lower(name));\n\nCREATE INDEX idx_community_moderator_published ON community_moderator (published);\n\nCREATE INDEX idx_community_moderator_community ON community_moderator (community_id);\n\nCREATE INDEX idx_community_moderator_person ON community_moderator (person_id);\n\nCREATE INDEX idx_comment_saved_comment ON comment_saved (comment_id);\n\nCREATE INDEX idx_comment_saved_person ON comment_saved (person_id);\n\nCREATE INDEX idx_community_block_community ON community_block (community_id);\n\nCREATE INDEX idx_community_block_person ON community_block (person_id);\n\nCREATE INDEX idx_community_follower_community ON community_follower (community_id);\n\nCREATE INDEX idx_community_follower_person ON community_follower (person_id);\n\nCREATE INDEX idx_person_block_person ON person_block (person_id);\n\nCREATE INDEX idx_person_block_target ON person_block (target_id);\n\nCREATE INDEX idx_post_language ON post (language_id);\n\nCREATE INDEX idx_comment_language ON comment (language_id);\n\nCREATE INDEX idx_person_aggregates_person ON person_aggregates (person_id);\n\nCREATE INDEX idx_person_post_aggregates_post ON person_post_aggregates (post_id);\n\nCREATE INDEX idx_person_post_aggregates_person ON person_post_aggregates (person_id);\n\nCREATE INDEX idx_comment_reply_comment ON comment_reply (comment_id);\n\nCREATE INDEX idx_comment_reply_recipient ON comment_reply (recipient_id);\n\nCREATE INDEX idx_comment_reply_published ON comment_reply (published DESC);\n\n"
  },
  {
    "path": "migrations/2023-05-10-095739_force_enable_undetermined_language/down.sql",
    "content": "SELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2023-05-10-095739_force_enable_undetermined_language/up.sql",
    "content": "-- force enable undetermined language for all users\nINSERT INTO local_user_language (local_user_id, language_id)\nSELECT\n    id,\n    0\nFROM\n    local_user\nON CONFLICT (local_user_id,\n    language_id)\n    DO NOTHING;\n\n"
  },
  {
    "path": "migrations/2023-06-06-104440_index_post_url/down.sql",
    "content": "-- Change back the column type\nALTER TABLE post\n    ALTER COLUMN url TYPE text;\n\n-- Drop the index\nDROP INDEX idx_post_url;\n\n"
  },
  {
    "path": "migrations/2023-06-06-104440_index_post_url/up.sql",
    "content": "-- Make a hard limit of 512 for the post.url column\n-- Truncate existing long rows.\nUPDATE\n    post\nSET\n    url =\n    LEFT (url,\n        512)\nWHERE\n    length(url) > 512;\n\n-- Enforce the limit\nALTER TABLE post\n    ALTER COLUMN url TYPE varchar(512);\n\n-- Add the index\nCREATE INDEX idx_post_url ON post (url);\n\n"
  },
  {
    "path": "migrations/2023-06-07-105918_add_hot_rank_columns/down.sql",
    "content": "-- Remove the new columns\nALTER TABLE post_aggregates\n    DROP COLUMN hot_rank;\n\nALTER TABLE post_aggregates\n    DROP COLUMN hot_rank_active;\n\nALTER TABLE comment_aggregates\n    DROP COLUMN hot_rank;\n\nALTER TABLE community_aggregates\n    DROP COLUMN hot_rank;\n\n-- Drop some new indexes\nDROP INDEX idx_post_aggregates_score;\n\nDROP INDEX idx_post_aggregates_published;\n\nDROP INDEX idx_post_aggregates_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_community;\n\nDROP INDEX idx_post_aggregates_featured_local;\n\n-- Recreate the old indexes\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_comments ON public.post_aggregates USING btree (featured_community DESC, comments DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank ((score)::numeric, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (featured_community DESC, hot_rank ((score)::numeric, published) DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (featured_community DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (featured_community DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank ((score)::numeric, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (featured_community DESC, hot_rank ((score)::numeric, newest_comment_time_necro) DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_comment_aggregates_hot ON public.comment_aggregates USING btree (hot_rank ((score)::numeric, published) DESC, published DESC);\n\nCREATE INDEX idx_community_aggregates_hot ON public.community_aggregates USING btree (hot_rank ((subscribers)::numeric, published) DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-06-07-105918_add_hot_rank_columns/up.sql",
    "content": "-- This converts the old hot_rank functions, to columns\n-- Remove the old compound indexes\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_local_comments;\n\nDROP INDEX idx_post_aggregates_featured_community_comments;\n\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_post_aggregates_featured_local_published;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_comment_aggregates_hot;\n\nDROP INDEX idx_community_aggregates_hot;\n\n-- Add the new hot rank columns for post and comment aggregates\n-- Note: 1728 is the result of the hot_rank function, with a score of 1, posted now\n-- hot_rank = 10000*log10(1 + 3)/Power(2, 1.8)\nALTER TABLE post_aggregates\n    ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728;\n\nALTER TABLE post_aggregates\n    ADD COLUMN hot_rank_active integer NOT NULL DEFAULT 1728;\n\nALTER TABLE comment_aggregates\n    ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728;\n\nALTER TABLE community_aggregates\n    ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728;\n\n-- Populate them initially\n-- Note: After initial population, these are updated in a periodic scheduled job,\n-- with only the last week being updated.\nUPDATE\n    post_aggregates\nSET\n    hot_rank_active = hot_rank (score::numeric, newest_comment_time_necro);\n\nUPDATE\n    post_aggregates\nSET\n    hot_rank = hot_rank (score::numeric, published);\n\nUPDATE\n    comment_aggregates\nSET\n    hot_rank = hot_rank (score::numeric, published);\n\nUPDATE\n    community_aggregates\nSET\n    hot_rank = hot_rank (subscribers::numeric, published);\n\n-- Create single column indexes\nCREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC);\n\nCREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC);\n\nCREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_newest_comment_time_necro ON post_aggregates (newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community ON post_aggregates (featured_community DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local ON post_aggregates (featured_local DESC);\n\nCREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank_active DESC);\n\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC);\n\nCREATE INDEX idx_community_aggregates_hot ON community_aggregates (hot_rank DESC);\n\n"
  },
  {
    "path": "migrations/2023-06-17-175955_add_listingtype_sorttype_hour_enums/down.sql",
    "content": "ALTER TABLE local_user\n    ALTER default_sort_type DROP DEFAULT;\n\n-- update the default sort type\nUPDATE\n    local_user\nSET\n    default_sort_type = 'TopDay'\nWHERE\n    default_sort_type IN ('TopHour', 'TopSixHour', 'TopTwelveHour');\n\n-- rename the old enum\nALTER TYPE sort_type_enum RENAME TO sort_type_enum__;\n\n-- create the new enum\nCREATE TYPE sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'TopDay',\n    'TopWeek',\n    'TopMonth',\n    'TopYear',\n    'TopAll',\n    'MostComments',\n    'NewComments'\n);\n\n-- alter all you enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type TYPE sort_type_enum\n    USING default_sort_type::text::sort_type_enum;\n\nALTER TABLE local_user\n    ALTER default_sort_type SET DEFAULT 'Active';\n\n-- drop the old enum\nDROP TYPE sort_type_enum__;\n\n"
  },
  {
    "path": "migrations/2023-06-17-175955_add_listingtype_sorttype_hour_enums/up.sql",
    "content": "-- Update the enums\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopHour';\n\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopSixHour';\n\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopTwelveHour';\n\n"
  },
  {
    "path": "migrations/2023-06-19-055530_add_retry_worker_setting/down.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN federation_worker_count int DEFAULT 64 NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-06-19-055530_add_retry_worker_setting/up.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN federation_worker_count;\n\n"
  },
  {
    "path": "migrations/2023-06-19-120700_no_double_deletion/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nCREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 'f'\n                AND NEW.deleted = 't')\n            OR (OLD.removed = 'f'\n                AND NEW.removed = 't'));\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-06-19-120700_no_double_deletion/up.sql",
    "content": "-- Deleting after removing should not decrement the count twice.\nCREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 'f'\n                AND NEW.deleted = 't')\n            OR (OLD.removed = 'f'\n                AND NEW.removed = 't'));\nEND\n$$;\n\n-- Recalculate proper comment count.\nUPDATE\n    person_aggregates\nSET\n    comment_count = cnt.count\nFROM (\n    SELECT\n        creator_id,\n        count(*) AS count\n    FROM\n        comment\n    WHERE\n        deleted = 'f'\n        AND removed = 'f'\n    GROUP BY\n        creator_id) cnt\nWHERE\n    person_aggregates.person_id = cnt.creator_id;\n\n-- Recalculate proper comment score.\nUPDATE\n    person_aggregates ua\nSET\n    comment_score = cd.score\nFROM (\n    SELECT\n        u.id AS creator_id,\n        coalesce(0, sum(cl.score)) AS score\n        -- User join because comments could be empty\n    FROM\n        person u\n    LEFT JOIN comment c ON u.id = c.creator_id\n        AND c.deleted = 'f'\n        AND c.removed = 'f'\n    LEFT JOIN comment_like cl ON c.id = cl.comment_id\nGROUP BY\n    u.id) cd\nWHERE\n    ua.person_id = cd.creator_id;\n\n-- Recalculate proper post count.\nUPDATE\n    person_aggregates\nSET\n    post_count = cnt.count\nFROM (\n    SELECT\n        creator_id,\n        count(*) AS count\n    FROM\n        post\n    WHERE\n        deleted = 'f'\n        AND removed = 'f'\n    GROUP BY\n        creator_id) cnt\nWHERE\n    person_aggregates.person_id = cnt.creator_id;\n\n-- Recalculate proper post score.\nUPDATE\n    person_aggregates ua\nSET\n    post_score = pd.score\nFROM (\n    SELECT\n        u.id AS creator_id,\n        coalesce(0, sum(pl.score)) AS score\n        -- User join because posts could be empty\n    FROM\n        person u\n    LEFT JOIN post p ON u.id = p.creator_id\n        AND p.deleted = 'f'\n        AND p.removed = 'f'\n    LEFT JOIN post_like pl ON p.id = pl.post_id\nGROUP BY\n    u.id) pd\nWHERE\n    ua.person_id = pd.creator_id;\n\n"
  },
  {
    "path": "migrations/2023-06-20-191145_add_listingtype_sorttype_3_6_9_months_enums/down.sql",
    "content": "ALTER TABLE local_user\n    ALTER default_sort_type DROP DEFAULT;\n\n-- update the default sort type\nUPDATE\n    local_user\nSET\n    default_sort_type = 'TopDay'\nWHERE\n    default_sort_type IN ('TopThreeMonths', 'TopSixMonths', 'TopNineMonths');\n\n-- rename the old enum\nALTER TYPE sort_type_enum RENAME TO sort_type_enum__;\n\n-- create the new enum\nCREATE TYPE sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'TopDay',\n    'TopWeek',\n    'TopMonth',\n    'TopYear',\n    'TopAll',\n    'MostComments',\n    'NewComments',\n    'TopHour',\n    'TopSixHour',\n    'TopTwelveHour'\n);\n\n-- alter all you enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type TYPE sort_type_enum\n    USING default_sort_type::text::sort_type_enum;\n\nALTER TABLE local_user\n    ALTER default_sort_type SET DEFAULT 'Active';\n\n-- drop the old enum\nDROP TYPE sort_type_enum__;\n\n"
  },
  {
    "path": "migrations/2023-06-20-191145_add_listingtype_sorttype_3_6_9_months_enums/up.sql",
    "content": "-- Update the enums\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopThreeMonths';\n\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopSixMonths';\n\nALTER TYPE sort_type_enum\n    ADD VALUE 'TopNineMonths';\n\n"
  },
  {
    "path": "migrations/2023-06-21-153242_add_captcha/down.sql",
    "content": "DROP TABLE captcha_answer;\n\n"
  },
  {
    "path": "migrations/2023-06-21-153242_add_captcha/up.sql",
    "content": "CREATE TABLE captcha_answer (\n    id serial PRIMARY KEY,\n    uuid uuid NOT NULL UNIQUE DEFAULT gen_random_uuid (),\n    answer text NOT NULL,\n    published timestamp NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2023-06-22-051755_fix_local_communities_marked_non_local/down.sql",
    "content": "-- Add a no-op statement to prevent `diesel migration redo` errors\nSELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2023-06-22-051755_fix_local_communities_marked_non_local/up.sql",
    "content": "UPDATE\n    community c\nSET\n    local = TRUE\nFROM\n    local_site ls\n    JOIN site s ON ls.site_id = s.id\nWHERE\n    c.instance_id = s.instance_id\n    AND NOT c.local;\n\n"
  },
  {
    "path": "migrations/2023-06-22-101245_increase_user_theme_column_size/down.sql",
    "content": "ALTER TABLE ONLY local_user\n    ALTER COLUMN theme TYPE character varying(20);\n\nALTER TABLE ONLY local_user\n    ALTER COLUMN theme SET DEFAULT 'browser'::character varying;\n\n"
  },
  {
    "path": "migrations/2023-06-22-101245_increase_user_theme_column_size/up.sql",
    "content": "ALTER TABLE ONLY local_user\n    ALTER COLUMN theme TYPE text;\n\nALTER TABLE ONLY local_user\n    ALTER COLUMN theme SET DEFAULT 'browser'::text;\n\n"
  },
  {
    "path": "migrations/2023-06-24-072904_add_open_links_in_new_tab_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN open_links_in_new_tab;\n\n"
  },
  {
    "path": "migrations/2023-06-24-072904_add_open_links_in_new_tab_setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN open_links_in_new_tab boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-06-24-185942_aggegates_published_indexes/down.sql",
    "content": "DROP INDEX idx_comment_aggregates_published;\n\nDROP INDEX idx_community_aggregates_published;\n\n"
  },
  {
    "path": "migrations/2023-06-24-185942_aggegates_published_indexes/up.sql",
    "content": "-- Add indexes on published column (needed for hot_rank updates)\nCREATE INDEX idx_community_aggregates_published ON community_aggregates (published DESC);\n\nCREATE INDEX idx_comment_aggregates_published ON comment_aggregates (published DESC);\n\n"
  },
  {
    "path": "migrations/2023-06-27-065106_add_ui_settings/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN blur_nsfw;\n\nALTER TABLE local_user\n    DROP COLUMN auto_expand;\n\n"
  },
  {
    "path": "migrations/2023-06-27-065106_add_ui_settings/up.sql",
    "content": "-- Add the blur_nsfw to the local user table as a setting\nALTER TABLE local_user\n    ADD COLUMN blur_nsfw boolean NOT NULL DEFAULT TRUE;\n\n-- Add the auto_expand to the local user table as a setting\nALTER TABLE local_user\n    ADD COLUMN auto_expand boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2023-07-04-153335_add_optimized_indexes/down.sql",
    "content": "-- Drop the new indexes\nDROP INDEX idx_person_admin;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_local_published;\n\nDROP INDEX idx_post_aggregates_published;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\n-- Create single column indexes again\nCREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC);\n\nCREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC);\n\nCREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_newest_comment_time_necro ON post_aggregates (newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community ON post_aggregates (featured_community DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local ON post_aggregates (featured_local DESC);\n\nCREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank_active DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-04-153335_add_optimized_indexes/up.sql",
    "content": "-- Create an admin person index\nCREATE INDEX IF NOT EXISTS idx_person_admin ON person (admin);\n\n-- Compound indexes, using featured_, then the other sorts, proved to be much faster\n-- Drop the old indexes\nDROP INDEX idx_post_aggregates_score;\n\nDROP INDEX idx_post_aggregates_published;\n\nDROP INDEX idx_post_aggregates_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_community;\n\nDROP INDEX idx_post_aggregates_featured_local;\n\nDROP INDEX idx_post_aggregates_hot;\n\nDROP INDEX idx_post_aggregates_active;\n\n-- featured_local\nCREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates (featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON post_aggregates (featured_local DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published ON post_aggregates (featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC);\n\n-- featured_community\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (featured_community DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-05-000058_person-admin/down.sql",
    "content": "DROP INDEX idx_person_admin;\n\nCREATE INDEX idx_person_admin ON person (admin);\n\n"
  },
  {
    "path": "migrations/2023-07-05-000058_person-admin/up.sql",
    "content": "DROP INDEX IF EXISTS idx_person_admin;\n\nCREATE INDEX idx_person_admin ON person (admin)\nWHERE\n    admin;\n\n-- allow quickly finding all admins (PersonView::admins)\n"
  },
  {
    "path": "migrations/2023-07-06-151124_hot-rank-future/down.sql",
    "content": "CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nBEGIN\n    -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600\n    RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE;\n\n"
  },
  {
    "path": "migrations/2023-07-06-151124_hot-rank-future/up.sql",
    "content": "CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600;\nBEGIN\n    IF (hours_diff > 0) THEN\n        RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer;\n    ELSE\n        RETURN 0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n"
  },
  {
    "path": "migrations/2023-07-08-101154_fix_soft_delete_aggregates/down.sql",
    "content": "-- 2023-06-19-120700_no_double_deletion/up.sql\nCREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 'f'\n                AND NEW.deleted = 't')\n            OR (OLD.removed = 'f'\n                AND NEW.removed = 't'));\nEND\n$$;\n\n-- 2022-04-04-183652_update_community_aggregates_on_soft_delete/up.sql\nCREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'INSERT') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND ((OLD.deleted = 't'\n                AND NEW.deleted = 'f')\n            OR (OLD.removed = 't'\n                AND NEW.removed = 'f'));\nEND\n$$;\n\n-- 2021-08-02-002342_comment_count_fixes/up.sql\nCREATE OR REPLACE FUNCTION post_aggregates_comment_deleted ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF NEW.deleted = TRUE THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSE\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1\n        WHERE\n            pa.post_id = NEW.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE TRIGGER post_aggregates_comment_set_deleted\n    AFTER UPDATE OF deleted ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_comment_deleted ();\n\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments + 1,\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = NEW.post_id\n            -- Fix issue with being able to necro-bump your own post\n            AND NEW.creator_id != p.creator_id\n            AND pa.published > ('now'::timestamp - '2 days'::interval);\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    ELSIF (TG_OP = 'UPDATE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- 2020-12-10-152350_create_post_aggregates/up.sql\nCREATE OR REPLACE TRIGGER post_aggregates_comment_count\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_comment_count ();\n\n"
  },
  {
    "path": "migrations/2023-07-08-101154_fix_soft_delete_aggregates/up.sql",
    "content": "-- Fix for duplicated decrementations when both `deleted` and `removed` fields are set subsequently\nCREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND OLD.deleted = 'f'\n        AND OLD.removed = 'f'\n        AND (NEW.deleted = 't'\n            OR NEW.removed = 't');\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'INSERT') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND NEW.deleted = 'f'\n        AND NEW.removed = 'f'\n        AND (OLD.deleted = 't'\n            OR OLD.removed = 't');\nEND\n$$;\n\n-- Fix for post's comment count not updating after setting `removed` to 't'\nDROP TRIGGER IF EXISTS post_aggregates_comment_set_deleted ON comment;\n\nDROP FUNCTION post_aggregates_comment_deleted ();\n\nCREATE OR REPLACE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Check for post existence - it may not exist anymore\n    IF TG_OP = 'INSERT' OR EXISTS (\n        SELECT\n            1\n        FROM\n            post p\n        WHERE\n            p.id = OLD.post_id) THEN\n        IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n            UPDATE\n                post_aggregates pa\n            SET\n                comments = comments + 1\n            WHERE\n                pa.post_id = NEW.post_id;\n        ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n            UPDATE\n                post_aggregates pa\n            SET\n                comments = comments - 1\n            WHERE\n                pa.post_id = OLD.post_id;\n        END IF;\n    END IF;\n    IF TG_OP = 'INSERT' THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = NEW.post_id\n            -- Fix issue with being able to necro-bump your own post\n            AND NEW.creator_id != p.creator_id\n            AND pa.published > ('now'::timestamp - '2 days'::interval);\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER post_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_comment_count ();\n\n"
  },
  {
    "path": "migrations/2023-07-10-075550_add-infinite-scroll-setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN infinite_scroll_enabled;\n\n"
  },
  {
    "path": "migrations/2023-07-10-075550_add-infinite-scroll-setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN infinite_scroll_enabled boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-07-11-084714_receive_activity_table/down.sql",
    "content": "CREATE TABLE activity (\n    id serial PRIMARY KEY,\n    data jsonb NOT NULL,\n    local boolean NOT NULL DEFAULT TRUE,\n    published timestamp NOT NULL DEFAULT now(),\n    updated timestamp,\n    ap_id text NOT NULL,\n    sensitive boolean NOT NULL DEFAULT TRUE\n);\n\nINSERT INTO activity (ap_id, data, sensitive, published)\nSELECT\n    ap_id,\n    data,\n    sensitive,\n    published\nFROM\n    sent_activity\nORDER BY\n    id DESC\nLIMIT 100000;\n\n-- We cant copy received_activity entries back into activities table because we dont have data\n-- which is mandatory.\nDROP TABLE sent_activity;\n\nDROP TABLE received_activity;\n\nCREATE UNIQUE INDEX idx_activity_ap_id ON activity (ap_id);\n\n"
  },
  {
    "path": "migrations/2023-07-11-084714_receive_activity_table/up.sql",
    "content": "-- outgoing activities, need to be stored to be later server over http\n-- we change data column from jsonb to json for decreased size\n-- https://stackoverflow.com/a/22910602\nCREATE TABLE sent_activity (\n    id bigserial PRIMARY KEY,\n    ap_id text UNIQUE NOT NULL,\n    data json NOT NULL,\n    sensitive boolean NOT NULL,\n    published timestamp NOT NULL DEFAULT now()\n);\n\n-- incoming activities, we only need the id to avoid processing the same activity multiple times\nCREATE TABLE received_activity (\n    id bigserial PRIMARY KEY,\n    ap_id text UNIQUE NOT NULL,\n    published timestamp NOT NULL DEFAULT now()\n);\n\n-- copy sent activities to new table. only copy last 100k for faster migration\nINSERT INTO sent_activity (ap_id, data, sensitive, published)\nSELECT\n    ap_id,\n    data,\n    sensitive,\n    published\nFROM\n    activity\nWHERE\n    local = TRUE\nORDER BY\n    id DESC\nLIMIT 100000;\n\n-- copy received activities to new table. only last 1m for faster migration\nINSERT INTO received_activity (ap_id, published)\nSELECT\n    ap_id,\n    published\nFROM\n    activity\nWHERE\n    local = FALSE\nORDER BY\n    id DESC\nLIMIT 1000000;\n\nDROP TABLE activity;\n\n"
  },
  {
    "path": "migrations/2023-07-14-154840_add_optimized_indexes_published/down.sql",
    "content": "-- Drop the new indexes\nDROP INDEX idx_post_aggregates_featured_local_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_post_aggregates_featured_community_most_comments;\n\nDROP INDEX idx_comment_aggregates_hot;\n\nDROP INDEX idx_comment_aggregates_score;\n\n-- Add the old ones back in\n-- featured_local\nCREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC);\n\n-- featured_community\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC);\n\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC);\n\nCREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-14-154840_add_optimized_indexes_published/up.sql",
    "content": "-- Drop the old indexes\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_comment_aggregates_hot;\n\nDROP INDEX idx_comment_aggregates_score;\n\n-- Add a published desc, to the end of the hot and active ranks\n-- Add missing most comments index\nCREATE INDEX idx_post_aggregates_featured_local_most_comments ON post_aggregates (featured_local DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (featured_community DESC, comments DESC, published DESC);\n\n-- featured_local\nCREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC, published DESC);\n\n-- featured_community\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC, published DESC);\n\n-- Fixing some comment aggregates ones\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, published DESC);\n\nCREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-14-215339_aggregates_nonzero_indexes/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP INDEX idx_community_aggregates_nonzero_hotrank;\n\nDROP INDEX idx_comment_aggregates_nonzero_hotrank;\n\nDROP INDEX idx_post_aggregates_nonzero_hotrank;\n\n"
  },
  {
    "path": "migrations/2023-07-14-215339_aggregates_nonzero_indexes/up.sql",
    "content": "-- Your SQL goes here\nCREATE INDEX idx_community_aggregates_nonzero_hotrank ON community_aggregates (published)\nWHERE\n    hot_rank != 0;\n\nCREATE INDEX idx_comment_aggregates_nonzero_hotrank ON comment_aggregates (published)\nWHERE\n    hot_rank != 0;\n\nCREATE INDEX idx_post_aggregates_nonzero_hotrank ON post_aggregates (published DESC)\nWHERE\n    hot_rank != 0 OR hot_rank_active != 0;\n\n"
  },
  {
    "path": "migrations/2023-07-18-082614_post_aggregates_community_id/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro)\n            VALUES (NEW.id, NEW.published, NEW.published, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nALTER TABLE post_aggregates\n    DROP COLUMN community_id,\n    DROP COLUMN creator_id;\n\n"
  },
  {
    "path": "migrations/2023-07-18-082614_post_aggregates_community_id/up.sql",
    "content": "-- Your SQL goes here\nALTER TABLE post_aggregates\n    ADD COLUMN community_id integer REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN creator_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id)\n            VALUES (NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nUPDATE\n    post_aggregates\nSET\n    community_id = post.community_id,\n    creator_id = post.creator_id\nFROM\n    post\nWHERE\n    post.id = post_aggregates.post_id;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN community_id SET NOT NULL,\n    ALTER COLUMN creator_id SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-07-19-163511_comment_sort_hot_rank_then_score/down.sql",
    "content": "DROP INDEX idx_comment_aggregates_hot, idx_comment_aggregates_score;\n\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, published DESC);\n\nCREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-19-163511_comment_sort_hot_rank_then_score/up.sql",
    "content": "-- Alter the comment_aggregates hot sort to sort by score after hot_rank.\n-- Reason being, is that hot_ranks go to zero after a few days,\n-- and then comments should be sorted by score, not published.\nDROP INDEX idx_comment_aggregates_hot, idx_comment_aggregates_score;\n\nCREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, score DESC);\n\n-- Remove published from this sort, its pointless\nCREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC);\n\n"
  },
  {
    "path": "migrations/2023-07-24-232635_trigram-index/down.sql",
    "content": "DROP INDEX idx_comment_content_trigram;\n\nDROP INDEX idx_post_trigram;\n\nDROP INDEX idx_person_trigram;\n\nDROP INDEX idx_community_trigram;\n\nDROP EXTENSION pg_trgm;\n\n"
  },
  {
    "path": "migrations/2023-07-24-232635_trigram-index/up.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS pg_trgm;\n\nCREATE INDEX IF NOT EXISTS idx_comment_content_trigram ON comment USING gin (content gin_trgm_ops);\n\nCREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops);\n\nCREATE INDEX IF NOT EXISTS idx_person_trigram ON person USING gin (name gin_trgm_ops, display_name gin_trgm_ops);\n\nCREATE INDEX IF NOT EXISTS idx_community_trigram ON community USING gin (name gin_trgm_ops, title gin_trgm_ops);\n\n"
  },
  {
    "path": "migrations/2023-07-26-000217_create_controversial_indexes/down.sql",
    "content": "-- Update comment_aggregates_score trigger function to exclude controversy_rank update\nCREATE OR REPLACE FUNCTION comment_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            ca.comment_id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to comment because that comment may not exist anymore\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        FROM\n            comment c\n        WHERE\n            ca.comment_id = c.id\n            AND ca.comment_id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Update post_aggregates_score trigger function to exclude controversy_rank update\nCREATE OR REPLACE FUNCTION post_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Drop the indexes\nDROP INDEX IF EXISTS idx_post_aggregates_featured_local_controversy;\n\nDROP INDEX IF EXISTS idx_post_aggregates_featured_community_controversy;\n\nDROP INDEX IF EXISTS idx_comment_aggregates_controversy;\n\n-- Remove the added columns from the tables\nALTER TABLE post_aggregates\n    DROP COLUMN controversy_rank;\n\nALTER TABLE comment_aggregates\n    DROP COLUMN controversy_rank;\n\n-- Remove function\nDROP FUNCTION controversy_rank (numeric, numeric);\n\n"
  },
  {
    "path": "migrations/2023-07-26-000217_create_controversial_indexes/up.sql",
    "content": "-- Need to add immutable to the controversy_rank function in order to index by it\n-- Controversy Rank:\n--      if downvotes <= 0 or upvotes <= 0:\n--          0\n--      else:\n--          (upvotes + downvotes) * min(upvotes, downvotes) / max(upvotes, downvotes)\nCREATE OR REPLACE FUNCTION controversy_rank (upvotes numeric, downvotes numeric)\n    RETURNS float\n    AS $$\nBEGIN\n    IF downvotes <= 0 OR upvotes <= 0 THEN\n        RETURN 0;\n    ELSE\n        RETURN (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN\n            downvotes::float / upvotes::float\n        ELSE\n            upvotes::float / downvotes::float\n        END;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE;\n\n-- Aggregates\nALTER TABLE post_aggregates\n    ADD COLUMN controversy_rank float NOT NULL DEFAULT 0;\n\nALTER TABLE comment_aggregates\n    ADD COLUMN controversy_rank float NOT NULL DEFAULT 0;\n\n-- Populate them initially\n-- Note: After initial population, these are updated with vote triggers\nUPDATE\n    post_aggregates\nSET\n    controversy_rank = controversy_rank (upvotes::numeric, downvotes::numeric);\n\nUPDATE\n    comment_aggregates\nSET\n    controversy_rank = controversy_rank (upvotes::numeric, downvotes::numeric);\n\n-- Create single column indexes\nCREATE INDEX idx_post_aggregates_featured_local_controversy ON post_aggregates (featured_local DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (featured_community DESC, controversy_rank DESC);\n\nCREATE INDEX idx_comment_aggregates_controversy ON comment_aggregates (controversy_rank DESC);\n\n-- Update post_aggregates_score trigger function to include controversy_rank update\nCREATE OR REPLACE FUNCTION post_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- Update comment_aggregates_score trigger function to include controversy_rank update\nCREATE OR REPLACE FUNCTION comment_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        WHERE\n            ca.comment_id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to comment because that comment may not exist anymore\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        FROM\n            comment c\n        WHERE\n            ca.comment_id = c.id\n            AND ca.comment_id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-07-26-222023_site-aggregates-one/down.sql",
    "content": "CREATE OR REPLACE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-07-26-222023_site-aggregates-one/up.sql",
    "content": "CREATE OR REPLACE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table.\n    -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests\n    IF (TG_OP = 'INSERT') AND NOT EXISTS (\n    SELECT\n        id\n    FROM\n        site_aggregates\n    LIMIT 1) THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nDELETE FROM site_aggregates a\nWHERE NOT EXISTS (\n        SELECT\n            id\n        FROM\n            local_site s\n        WHERE\n            s.site_id = a.site_id);\n\n"
  },
  {
    "path": "migrations/2023-07-27-134652_remove-expensive-broken-trigger/down.sql",
    "content": "CREATE OR REPLACE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the comment gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = cd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(cl.score)) AS score\n                -- User join because comments could be empty\n            FROM\n                person u\n            LEFT JOIN comment c ON u.id = c.creator_id\n                AND c.deleted = 'f'\n                AND c.removed = 'f'\n        LEFT JOIN comment_like cl ON c.id = cl.comment_id\n    GROUP BY\n        u.id) cd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n        -- If the post gets deleted, the score calculation trigger won't fire,\n        -- so you need to re-calculate\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = pd.score\n        FROM (\n            SELECT\n                u.id,\n                coalesce(0, sum(pl.score)) AS score\n                -- User join because posts could be empty\n            FROM\n                person u\n            LEFT JOIN post p ON u.id = p.creator_id\n                AND p.deleted = 'f'\n                AND p.removed = 'f'\n        LEFT JOIN post_like pl ON p.id = pl.post_id\n    GROUP BY\n        u.id) pd\n    WHERE\n        ua.person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            comment c,\n            post p\n        WHERE\n            p.id = c.post_id\n            AND p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-07-27-134652_remove-expensive-broken-trigger/up.sql",
    "content": "CREATE OR REPLACE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            post p\n        WHERE\n            p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-08-01-101826_admin_flag_local_user/down.sql",
    "content": "ALTER TABLE person\n    ADD COLUMN admin boolean DEFAULT FALSE NOT NULL;\n\nUPDATE\n    person\nSET\n    admin = TRUE\nFROM\n    local_user\nWHERE\n    local_user.person_id = person.id\n    AND local_user.admin;\n\nALTER TABLE local_user\n    DROP COLUMN admin;\n\nCREATE INDEX idx_person_admin ON person (admin)\nWHERE\n    admin;\n\n"
  },
  {
    "path": "migrations/2023-08-01-101826_admin_flag_local_user/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN admin boolean DEFAULT FALSE NOT NULL;\n\nUPDATE\n    local_user\nSET\n    admin = TRUE\nFROM\n    person\nWHERE\n    local_user.person_id = person.id\n    AND person.admin;\n\nALTER TABLE person\n    DROP COLUMN admin;\n\n"
  },
  {
    "path": "migrations/2023-08-01-115243_persistent-activity-queue/down.sql",
    "content": "ALTER TABLE sent_activity\n    DROP COLUMN send_inboxes,\n    DROP COLUMN send_community_followers_of,\n    DROP COLUMN send_all_instances,\n    DROP COLUMN actor_apub_id,\n    DROP COLUMN actor_type;\n\nDROP TYPE actor_type_enum;\n\nDROP TABLE federation_queue_state;\n\nDROP INDEX idx_community_follower_published;\n\n"
  },
  {
    "path": "migrations/2023-08-01-115243_persistent-activity-queue/up.sql",
    "content": "CREATE TYPE actor_type_enum AS enum (\n    'site',\n    'community',\n    'person'\n);\n\n-- actor_apub_id only null for old entries before this migration\nALTER TABLE sent_activity\n    ADD COLUMN send_inboxes text[] NOT NULL DEFAULT '{}', -- list of specific inbox urls\n    ADD COLUMN send_community_followers_of integer DEFAULT NULL,\n    ADD COLUMN send_all_instances boolean NOT NULL DEFAULT FALSE,\n    ADD COLUMN actor_type actor_type_enum NOT NULL DEFAULT 'person',\n    ADD COLUMN actor_apub_id text DEFAULT NULL;\n\nALTER TABLE sent_activity\n    ALTER COLUMN send_inboxes DROP DEFAULT,\n    ALTER COLUMN send_community_followers_of DROP DEFAULT,\n    ALTER COLUMN send_all_instances DROP DEFAULT,\n    ALTER COLUMN actor_type DROP DEFAULT,\n    ALTER COLUMN actor_apub_id DROP DEFAULT;\n\nCREATE TABLE federation_queue_state (\n    id serial PRIMARY KEY,\n    instance_id integer NOT NULL UNIQUE REFERENCES instance (id),\n    last_successful_id bigint NOT NULL,\n    fail_count integer NOT NULL,\n    last_retry timestamptz NOT NULL\n);\n\n-- for incremental fetches of followers\nCREATE INDEX idx_community_follower_published ON community_follower (published);\n\n"
  },
  {
    "path": "migrations/2023-08-02-144930_password-reset-token/down.sql",
    "content": "ALTER TABLE password_reset_request RENAME COLUMN token TO token_encrypted;\n\n"
  },
  {
    "path": "migrations/2023-08-02-144930_password-reset-token/up.sql",
    "content": "ALTER TABLE password_reset_request RENAME COLUMN token_encrypted TO token;\n\n"
  },
  {
    "path": "migrations/2023-08-02-174444_fix-timezones/down.sql",
    "content": "SET timezone TO utc;\n\nALTER TABLE community_moderator\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community_follower\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE person_ban\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community_person_ban\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community_person_ban\n    ALTER COLUMN expires TYPE timestamp\n    USING expires;\n\nALTER TABLE person\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE person\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE person\n    ALTER COLUMN last_refreshed_at TYPE timestamp\n    USING last_refreshed_at;\n\nALTER TABLE person\n    ALTER COLUMN ban_expires TYPE timestamp\n    USING ban_expires;\n\nALTER TABLE post_like\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE post_saved\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE post_read\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE comment_like\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE comment_saved\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE comment\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE comment\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN expires TYPE timestamp\n    USING expires;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN expires TYPE timestamp\n    USING expires;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_ban\n    ALTER COLUMN expires TYPE timestamp\n    USING expires;\n\nALTER TABLE mod_ban\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_add_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE mod_add\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE person_mention\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE mod_feature_post\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE password_reset_request\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE private_message\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE private_message\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE sent_activity\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE received_activity\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE community\n    ALTER COLUMN last_refreshed_at TYPE timestamp\n    USING last_refreshed_at;\n\nALTER TABLE post\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE post\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE comment_report\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE comment_report\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE post_report\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE post_report\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN newest_comment_time_necro TYPE timestamp\n    USING newest_comment_time_necro;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN newest_comment_time TYPE timestamp\n    USING newest_comment_time;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community_block\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE community_aggregates\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE mod_transfer_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE person_block\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE local_user\n    ALTER COLUMN validator_time TYPE timestamp\n    USING validator_time;\n\nALTER TABLE admin_purge_person\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE email_verification\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE admin_purge_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE admin_purge_post\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE admin_purge_comment\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE registration_application\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE mod_hide_community\n    ALTER COLUMN when_ TYPE timestamp\n    USING when_;\n\nALTER TABLE site\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE site\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE site\n    ALTER COLUMN last_refreshed_at TYPE timestamp\n    USING last_refreshed_at;\n\nALTER TABLE comment_reply\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE person_post_aggregates\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE private_message_report\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE private_message_report\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE local_site\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE local_site\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE federation_allowlist\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE federation_allowlist\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE federation_blocklist\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE federation_blocklist\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE local_site_rate_limit\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE local_site_rate_limit\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE person_follower\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE tagline\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE tagline\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE custom_emoji\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE custom_emoji\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE instance\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nALTER TABLE instance\n    ALTER COLUMN updated TYPE timestamp\n    USING updated;\n\nALTER TABLE captcha_answer\n    ALTER COLUMN published TYPE timestamp\n    USING published;\n\nDROP FUNCTION hot_rank;\n\nCREATE FUNCTION hot_rank (score numeric, published timestamp without time zone)\n    RETURNS integer\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600;\nBEGIN\n    IF (hours_diff > 0) THEN\n        RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer;\n    ELSE\n        RETURN 0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n"
  },
  {
    "path": "migrations/2023-08-02-174444_fix-timezones/up.sql",
    "content": "DROP FUNCTION IF EXISTS hot_rank CASCADE;\n\nSET timezone = 'UTC';\n\n--  Allow ALTER TABLE ... SET DATA TYPE changing between timestamp and timestamptz to avoid a table rewrite when the session time zone is UTC (Noah Misch)\n-- In the UTC time zone, these two data types are binary compatible.\nALTER TABLE community_moderator\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community_follower\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE person_ban\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community_person_ban\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community_person_ban\n    ALTER COLUMN expires TYPE timestamptz\n    USING expires;\n\nALTER TABLE person\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE person\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE person\n    ALTER COLUMN last_refreshed_at TYPE timestamptz\n    USING last_refreshed_at;\n\nALTER TABLE person\n    ALTER COLUMN ban_expires TYPE timestamptz\n    USING ban_expires;\n\nALTER TABLE post_like\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE post_saved\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE post_read\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE comment_like\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE comment_saved\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE comment\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE comment\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN expires TYPE timestamptz\n    USING expires;\n\nALTER TABLE mod_remove_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN expires TYPE timestamptz\n    USING expires;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_ban\n    ALTER COLUMN expires TYPE timestamptz\n    USING expires;\n\nALTER TABLE mod_ban\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_add_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE mod_add\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE person_mention\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE mod_feature_post\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE password_reset_request\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE private_message\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE private_message\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE sent_activity\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE received_activity\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE community\n    ALTER COLUMN last_refreshed_at TYPE timestamptz\n    USING last_refreshed_at;\n\nALTER TABLE post\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE post\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE comment_report\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE comment_report\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE post_report\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE post_report\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN newest_comment_time_necro TYPE timestamptz\n    USING newest_comment_time_necro;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN newest_comment_time TYPE timestamptz\n    USING newest_comment_time;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community_block\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE community_aggregates\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE mod_transfer_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE person_block\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE local_user\n    ALTER COLUMN validator_time TYPE timestamptz\n    USING validator_time;\n\nALTER TABLE admin_purge_person\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE email_verification\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE admin_purge_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE admin_purge_post\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE admin_purge_comment\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE registration_application\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE mod_hide_community\n    ALTER COLUMN when_ TYPE timestamptz\n    USING when_;\n\nALTER TABLE site\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE site\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE site\n    ALTER COLUMN last_refreshed_at TYPE timestamptz\n    USING last_refreshed_at;\n\nALTER TABLE comment_reply\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE person_post_aggregates\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE private_message_report\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE private_message_report\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE local_site\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE local_site\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE federation_allowlist\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE federation_allowlist\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE federation_blocklist\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE federation_blocklist\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE local_site_rate_limit\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE local_site_rate_limit\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE person_follower\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE tagline\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE tagline\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE custom_emoji\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE custom_emoji\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE instance\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\nALTER TABLE instance\n    ALTER COLUMN updated TYPE timestamptz\n    USING updated;\n\nALTER TABLE captcha_answer\n    ALTER COLUMN published TYPE timestamptz\n    USING published;\n\n-- same as before just with time zone argument\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS integer\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    IF (hours_diff > 0) THEN\n        RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer;\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n"
  },
  {
    "path": "migrations/2023-08-08-163911_add_post_listing_mode_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN post_listing_mode;\n\nDROP TYPE post_listing_mode_enum;\n\n"
  },
  {
    "path": "migrations/2023-08-08-163911_add_post_listing_mode_setting/up.sql",
    "content": "CREATE TYPE post_listing_mode_enum AS enum (\n    'List',\n    'Card',\n    'SmallCard'\n);\n\nALTER TABLE local_user\n    ADD COLUMN post_listing_mode post_listing_mode_enum DEFAULT 'List' NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-08-09-101305_user_instance_block/down.sql",
    "content": "DROP TABLE instance_block;\n\nALTER TABLE post_aggregates\n    DROP COLUMN instance_id;\n\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id)\n            VALUES (NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-08-09-101305_user_instance_block/up.sql",
    "content": "CREATE TABLE instance_block (\n    id serial PRIMARY KEY,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz NOT NULL DEFAULT now(),\n    UNIQUE (person_id, instance_id)\n);\n\nALTER TABLE post_aggregates\n    ADD COLUMN instance_id integer REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id)\n        SELECT\n            NEW.id,\n            NEW.published,\n            NEW.published,\n            NEW.published,\n            NEW.community_id,\n            NEW.creator_id,\n            community.instance_id\n        FROM\n            community\n        WHERE\n            NEW.community_id = community.id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nUPDATE\n    post_aggregates\nSET\n    instance_id = community.instance_id\nFROM\n    post\n    JOIN community ON post.community_id = community.id\nWHERE\n    post.id = post_aggregates.post_id;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN instance_id SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-08-23-182533_scaled_rank/down.sql",
    "content": "DROP FUNCTION scaled_rank;\n\nALTER TABLE community_aggregates\n    ALTER COLUMN hot_rank TYPE integer,\n    ALTER COLUMN hot_rank SET DEFAULT 1728;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN hot_rank TYPE integer,\n    ALTER COLUMN hot_rank SET DEFAULT 1728;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN hot_rank TYPE integer,\n    ALTER COLUMN hot_rank SET DEFAULT 1728,\n    ALTER COLUMN hot_rank_active TYPE integer,\n    ALTER COLUMN hot_rank_active SET DEFAULT 1728;\n\n-- Change back to integer version\nDROP FUNCTION hot_rank (numeric, published timestamp with time zone);\n\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS integer\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    IF (hours_diff > 0) THEN\n        RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer;\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\nALTER TABLE post_aggregates\n    DROP COLUMN scaled_rank;\n\n-- The following code is necessary because postgres can't remove\n-- a single enum value.\nALTER TABLE local_user\n    ALTER default_sort_type DROP DEFAULT;\n\nUPDATE\n    local_user\nSET\n    default_sort_type = 'Hot'\nWHERE\n    default_sort_type = 'Scaled';\n\n-- rename the old enum\nALTER TYPE sort_type_enum RENAME TO sort_type_enum__;\n\n-- create the new enum\nCREATE TYPE sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'TopDay',\n    'TopWeek',\n    'TopMonth',\n    'TopYear',\n    'TopAll',\n    'MostComments',\n    'NewComments',\n    'TopHour',\n    'TopSixHour',\n    'TopTwelveHour',\n    'TopThreeMonths',\n    'TopSixMonths',\n    'TopNineMonths'\n);\n\n-- alter all your enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_sort_type TYPE sort_type_enum\n    USING default_sort_type::text::sort_type_enum;\n\nALTER TABLE local_user\n    ALTER default_sort_type SET DEFAULT 'Active';\n\n-- drop the old enum\nDROP TYPE sort_type_enum__;\n\n-- Remove int to float conversions that were automatically added to index filters\nDROP INDEX idx_comment_aggregates_nonzero_hotrank, idx_community_aggregates_nonzero_hotrank, idx_post_aggregates_nonzero_hotrank;\n\nCREATE INDEX idx_community_aggregates_nonzero_hotrank ON community_aggregates (published)\nWHERE\n    hot_rank != 0;\n\nCREATE INDEX idx_comment_aggregates_nonzero_hotrank ON comment_aggregates (published)\nWHERE\n    hot_rank != 0;\n\nCREATE INDEX idx_post_aggregates_nonzero_hotrank ON post_aggregates (published DESC)\nWHERE\n    hot_rank != 0 OR hot_rank_active != 0;\n\n"
  },
  {
    "path": "migrations/2023-08-23-182533_scaled_rank/up.sql",
    "content": "-- Change hot ranks and functions from an int to a float\nALTER TABLE community_aggregates\n    ALTER COLUMN hot_rank TYPE float,\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN hot_rank TYPE float,\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN hot_rank TYPE float,\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728,\n    ALTER COLUMN hot_rank_active TYPE float,\n    ALTER COLUMN hot_rank_active SET DEFAULT 0.1728;\n\nDROP FUNCTION hot_rank (numeric, published timestamp with time zone);\n\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS float\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    -- 24 * 7 = 168, so after a week, it will default to 0.\n    IF (hours_diff > 0 AND hours_diff < 168) THEN\n        RETURN log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8);\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0.0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n-- The new scaled rank function\nCREATE OR REPLACE FUNCTION scaled_rank (score numeric, published timestamp with time zone, users_active_month numeric)\n    RETURNS float\n    AS $$\nBEGIN\n    -- Add 2 to avoid divide by zero errors\n    -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621\n    -- There may need to be a scale factor multiplied to users_active_month, to make\n    -- the log curve less pronounced. This can be tuned in the future.\n    RETURN (hot_rank (score, published) / log(2 + users_active_month));\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\nALTER TABLE post_aggregates\n    ADD COLUMN scaled_rank float NOT NULL DEFAULT 0.3621;\n\nUPDATE\n    post_aggregates\nSET\n    scaled_rank = 0\nWHERE\n    hot_rank = 0\n    OR hot_rank_active = 0;\n\nCREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (featured_community DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_scaled ON post_aggregates (featured_local DESC, scaled_rank DESC, published DESC);\n\n-- We forgot to add the controversial sort type\nALTER TYPE sort_type_enum\n    ADD VALUE 'Controversial';\n\n-- Add the Scaled enum\nALTER TYPE sort_type_enum\n    ADD VALUE 'Scaled';\n\n"
  },
  {
    "path": "migrations/2023-08-29-183053_add_listing_type_moderator_view/down.sql",
    "content": "ALTER TABLE local_user\n    ALTER default_listing_type DROP DEFAULT;\n\nALTER TABLE local_site\n    ALTER default_post_listing_type DROP DEFAULT;\n\nUPDATE\n    local_user\nSET\n    default_listing_type = 'Local'\nWHERE\n    default_listing_type = 'ModeratorView';\n\nUPDATE\n    local_site\nSET\n    default_post_listing_type = 'Local'\nWHERE\n    default_post_listing_type = 'ModeratorView';\n\n-- rename the old enum\nALTER TYPE listing_type_enum RENAME TO listing_type_enum__;\n\n-- create the new enum\nCREATE TYPE listing_type_enum AS ENUM (\n    'All',\n    'Local',\n    'Subscribed'\n);\n\n-- alter all your enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type TYPE listing_type_enum\n    USING default_listing_type::text::listing_type_enum;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type TYPE listing_type_enum\n    USING default_post_listing_type::text::listing_type_enum;\n\n-- Add back in the default\nALTER TABLE local_user\n    ALTER default_listing_type SET DEFAULT 'Local';\n\nALTER TABLE local_site\n    ALTER default_post_listing_type SET DEFAULT 'Local';\n\n-- drop the old enum\nDROP TYPE listing_type_enum__;\n\n"
  },
  {
    "path": "migrations/2023-08-29-183053_add_listing_type_moderator_view/up.sql",
    "content": "-- Update the listing_type_enum\nALTER TYPE listing_type_enum\n    ADD VALUE 'ModeratorView';\n\n"
  },
  {
    "path": "migrations/2023-08-31-205559_add_image_upload/down.sql",
    "content": "DROP TABLE image_upload;\n\n"
  },
  {
    "path": "migrations/2023-08-31-205559_add_image_upload/up.sql",
    "content": "CREATE TABLE image_upload (\n    id serial PRIMARY KEY,\n    local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    pictrs_alias text NOT NULL UNIQUE,\n    pictrs_delete_token text NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL\n);\n\nCREATE INDEX idx_image_upload_local_user_id ON image_upload (local_user_id);\n\n"
  },
  {
    "path": "migrations/2023-09-01-112158_auto_resolve_report/down.sql",
    "content": "DROP TRIGGER IF EXISTS post_removed_resolve_reports ON mod_remove_post;\n\nDROP FUNCTION IF EXISTS post_removed_resolve_reports;\n\nDROP TRIGGER IF EXISTS comment_removed_resolve_reports ON mod_remove_comment;\n\nDROP FUNCTION IF EXISTS comment_removed_resolve_reports;\n\n"
  },
  {
    "path": "migrations/2023-09-01-112158_auto_resolve_report/up.sql",
    "content": "-- Automatically resolve all reports for a given post once it is marked as removed\nCREATE OR REPLACE FUNCTION post_removed_resolve_reports ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_report\n    SET\n        resolved = TRUE,\n        resolver_id = NEW.mod_person_id,\n        updated = now()\n    WHERE\n        post_report.post_id = NEW.post_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER post_removed_resolve_reports\n    AFTER INSERT ON mod_remove_post\n    FOR EACH ROW\n    WHEN (NEW.removed)\n    EXECUTE PROCEDURE post_removed_resolve_reports ();\n\n-- Same when comment is marked as removed\nCREATE OR REPLACE FUNCTION comment_removed_resolve_reports ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        comment_report\n    SET\n        resolved = TRUE,\n        resolver_id = NEW.mod_person_id,\n        updated = now()\n    WHERE\n        comment_report.comment_id = NEW.comment_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER comment_removed_resolve_reports\n    AFTER INSERT ON mod_remove_comment\n    FOR EACH ROW\n    WHEN (NEW.removed)\n    EXECUTE PROCEDURE comment_removed_resolve_reports ();\n\n"
  },
  {
    "path": "migrations/2023-09-07-215546_post-queries-efficient/down.sql",
    "content": "DROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_controversy;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_scaled;\n\nDROP INDEX idx_post_aggregates_featured_community_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (featured_community DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (featured_community DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (featured_community DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (featured_community DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC, published DESC);\n\nDROP INDEX idx_post_aggregates_community_active;\n\nDROP INDEX idx_post_aggregates_community_controversy;\n\nDROP INDEX idx_post_aggregates_community_hot;\n\nDROP INDEX idx_post_aggregates_community_scaled;\n\nDROP INDEX idx_post_aggregates_community_most_comments;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_community_published;\n\nDROP INDEX idx_post_aggregates_community_score;\n\n"
  },
  {
    "path": "migrations/2023-09-07-215546_post-queries-efficient/up.sql",
    "content": "-- these indices are used for single-community filtering and for the followed-communities (front-page) query\n-- basically one index per Sort\n-- index name is truncated to 63 chars so abbreviate a bit\nCREATE INDEX idx_post_aggregates_community_active ON post_aggregates (community_id, featured_local DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_controversy ON post_aggregates (community_id, featured_local DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_community_hot ON post_aggregates (community_id, featured_local DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_scaled ON post_aggregates (community_id, featured_local DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_most_comments ON post_aggregates (community_id, featured_local DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time ON post_aggregates (community_id, featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON post_aggregates (community_id, featured_local DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_community_published ON post_aggregates (community_id, featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_score ON post_aggregates (community_id, featured_local DESC, score DESC, published DESC);\n\n-- these indices are used for \"per-community\" filtering\n-- these indices weren't really useful because whenever the query filters by featured_community it also filters by community_id, so prepend that to all these indexes\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_controversy;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_scaled;\n\nDROP INDEX idx_post_aggregates_featured_community_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (community_id, featured_community DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (community_id, featured_community DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (community_id, featured_community DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (community_id, featured_community DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (community_id, featured_community DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (community_id, featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (community_id, featured_community DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (community_id, featured_community DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (community_id, featured_community DESC, score DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2023-09-11-110040_rework-2fa-setup/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN totp_2fa_url text;\n\nALTER TABLE local_user\n    DROP COLUMN totp_2fa_enabled;\n\n"
  },
  {
    "path": "migrations/2023-09-11-110040_rework-2fa-setup/up.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN totp_2fa_url;\n\nALTER TABLE local_user\n    ADD COLUMN totp_2fa_enabled boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2023-09-12-194850_add_federation_worker_index/down.sql",
    "content": "DROP INDEX idx_person_local_instance;\n\n"
  },
  {
    "path": "migrations/2023-09-12-194850_add_federation_worker_index/up.sql",
    "content": "CREATE INDEX idx_person_local_instance ON person (local DESC, instance_id);\n\n"
  },
  {
    "path": "migrations/2023-09-18-141700_login-token/down.sql",
    "content": "DROP TABLE login_token;\n\nALTER TABLE local_user\n    ADD COLUMN validator_time timestamptz NOT NULL DEFAULT now();\n\n"
  },
  {
    "path": "migrations/2023-09-18-141700_login-token/up.sql",
    "content": "CREATE TABLE login_token (\n    id serial PRIMARY KEY,\n    token text NOT NULL UNIQUE,\n    user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz NOT NULL DEFAULT now(),\n    ip text,\n    user_agent text\n);\n\nCREATE INDEX idx_login_token_user_token ON login_token (user_id, token);\n\n-- not needed anymore as we invalidate login tokens on password change\nALTER TABLE local_user\n    DROP COLUMN validator_time;\n\n"
  },
  {
    "path": "migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN show_new_post_notifs boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql",
    "content": "-- this setting is unused with websocket gone\nALTER TABLE local_user\n    DROP COLUMN show_new_post_notifs;\n\n"
  },
  {
    "path": "migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql",
    "content": "ALTER TABLE local_site_rate_limit\n    DROP COLUMN import_user_settings;\n\nALTER TABLE local_site_rate_limit\n    DROP COLUMN import_user_settings_per_second;\n\n"
  },
  {
    "path": "migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql",
    "content": "ALTER TABLE local_site_rate_limit\n    ADD COLUMN import_user_settings int NOT NULL DEFAULT 1;\n\nALTER TABLE local_site_rate_limit\n    ADD COLUMN import_user_settings_per_second int NOT NULL DEFAULT 86400;\n\n"
  },
  {
    "path": "migrations/2023-10-02-145002_community_followers_count_federated/down.sql",
    "content": "CREATE OR REPLACE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers + 1\n        WHERE\n            community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers - 1\n        WHERE\n            community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-10-02-145002_community_followers_count_federated/up.sql",
    "content": "-- The subscriber count should only be updated for local communities. For remote\n-- communities it is read over federation from the origin instance.\nCREATE OR REPLACE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers + 1\n        FROM\n            community\n        WHERE\n            community.id = community_id\n            AND community.local\n            AND community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers - 1\n        FROM\n            community\n        WHERE\n            community.id = community_id\n            AND community.local\n            AND community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-10-06-133405_add_keyboard_navigation_setting/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN enable_keyboard_navigation;\n\n"
  },
  {
    "path": "migrations/2023-10-06-133405_add_keyboard_navigation_setting/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN enable_keyboard_navigation boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-10-13-175712_allow_animated_avatars/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN enable_animated_images;\n\n"
  },
  {
    "path": "migrations/2023-10-13-175712_allow_animated_avatars/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN enable_animated_images boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-10-17-181800_drop_remove_community_expires/down.sql",
    "content": "ALTER TABLE mod_remove_community\n    ADD COLUMN expires timestamptz;\n\n"
  },
  {
    "path": "migrations/2023-10-17-181800_drop_remove_community_expires/up.sql",
    "content": "ALTER TABLE mod_remove_community\n    DROP COLUMN expires;\n\n"
  },
  {
    "path": "migrations/2023-10-23-184941_hot_rank_greatest_fix/down.sql",
    "content": "CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS float\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    -- 24 * 7 = 168, so after a week, it will default to 0.\n    IF (hours_diff > 0 AND hours_diff < 168) THEN\n        RETURN log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8);\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0.0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n"
  },
  {
    "path": "migrations/2023-10-23-184941_hot_rank_greatest_fix/up.sql",
    "content": "-- The hot_rank algorithm currently uses greatest(1, score + 3)\n-- This greatest of 1 incorrect because log10(1) is zero,\n-- so it will push negative-voted comments / posts to the bottom, IE hot_rank = 0\n-- The update_scheduled_ranks will never recalculate them, because it ignores content\n-- with hot_rank = 0\nCREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS float\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    -- 24 * 7 = 168, so after a week, it will default to 0.\n    IF (hours_diff > 0 AND hours_diff < 168) THEN\n        -- Use greatest(2,score), so that the hot_rank will be positive and not ignored.\n        RETURN log(greatest (2, score + 2)) / power((hours_diff + 2), 1.8);\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0.0;\n    END IF;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\n"
  },
  {
    "path": "migrations/2023-10-24-030352_change_primary_keys_and_remove_some_id_columns/down.sql",
    "content": "ALTER TABLE captcha_answer\n    ADD UNIQUE (uuid),\n    DROP CONSTRAINT captcha_answer_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE comment_aggregates\n    ADD UNIQUE (comment_id),\n    DROP CONSTRAINT comment_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_comment_like_person ON comment_like (person_id);\n\nALTER TABLE comment_like\n    ADD UNIQUE (comment_id, person_id),\n    DROP CONSTRAINT comment_like_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_comment_saved_person_id ON comment_saved (person_id);\n\nALTER TABLE comment_saved\n    ADD UNIQUE (comment_id, person_id),\n    DROP CONSTRAINT comment_saved_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE community_aggregates\n    ADD UNIQUE (community_id),\n    DROP CONSTRAINT community_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_community_block_person ON community_block (person_id);\n\nALTER TABLE community_block\n    ADD UNIQUE (person_id, community_id),\n    DROP CONSTRAINT community_block_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_community_follower_person ON community_follower (person_id);\n\nALTER TABLE community_follower\n    ADD UNIQUE (community_id, person_id),\n    DROP CONSTRAINT community_follower_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE community_language\n    ADD UNIQUE (community_id, language_id),\n    DROP CONSTRAINT community_language_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_community_moderator_person ON community_moderator (person_id);\n\nALTER TABLE community_moderator\n    ADD UNIQUE (community_id, person_id),\n    DROP CONSTRAINT community_moderator_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE community_person_ban\n    ADD UNIQUE (community_id, person_id),\n    DROP CONSTRAINT community_person_ban_pkey,\n    ADD COLUMN id serial PRIMARY KEY CONSTRAINT community_user_ban_id_not_null NOT NULL;\n\nALTER TABLE custom_emoji_keyword\n    ADD UNIQUE (custom_emoji_id, keyword),\n    DROP CONSTRAINT custom_emoji_keyword_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE federation_allowlist\n    ADD UNIQUE (instance_id),\n    DROP CONSTRAINT federation_allowlist_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE federation_blocklist\n    ADD UNIQUE (instance_id),\n    DROP CONSTRAINT federation_blocklist_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE federation_queue_state\n    ADD UNIQUE (instance_id),\n    DROP CONSTRAINT federation_queue_state_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE image_upload\n    ADD UNIQUE (pictrs_alias),\n    DROP CONSTRAINT image_upload_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE instance_block\n    ADD UNIQUE (person_id, instance_id),\n    DROP CONSTRAINT instance_block_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE local_site_rate_limit\n    ADD UNIQUE (local_site_id),\n    DROP CONSTRAINT local_site_rate_limit_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE local_user_language\n    ADD UNIQUE (local_user_id, language_id),\n    DROP CONSTRAINT local_user_language_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE login_token\n    ADD UNIQUE (token),\n    DROP CONSTRAINT login_token_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE person_aggregates\n    ADD UNIQUE (person_id),\n    DROP CONSTRAINT person_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY CONSTRAINT user_aggregates_id_not_null NOT NULL;\n\nALTER TABLE person_ban\n    ADD UNIQUE (person_id),\n    DROP CONSTRAINT person_ban_pkey,\n    ADD COLUMN id serial PRIMARY KEY CONSTRAINT user_ban_id_not_null NOT NULL;\n\nALTER TABLE person_block\n    ADD UNIQUE (person_id, target_id),\n    DROP CONSTRAINT person_block_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE person_follower\n    ADD UNIQUE (follower_id, person_id),\n    DROP CONSTRAINT person_follower_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE person_post_aggregates\n    ADD UNIQUE (person_id, post_id),\n    DROP CONSTRAINT person_post_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE post_aggregates\n    ADD UNIQUE (post_id),\n    DROP CONSTRAINT post_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE INDEX idx_post_like_person ON post_like (person_id);\n\nALTER TABLE post_like\n    ADD UNIQUE (post_id, person_id),\n    DROP CONSTRAINT post_like_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE post_read\n    ADD UNIQUE (post_id, person_id),\n    DROP CONSTRAINT post_read_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE received_activity\n    ADD UNIQUE (ap_id),\n    DROP CONSTRAINT received_activity_pkey,\n    ADD COLUMN id bigserial PRIMARY KEY;\n\nCREATE INDEX idx_post_saved_person_id ON post_saved (person_id);\n\nALTER TABLE post_saved\n    ADD UNIQUE (post_id, person_id),\n    DROP CONSTRAINT post_saved_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE site_aggregates\n    DROP CONSTRAINT site_aggregates_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nALTER TABLE site_language\n    ADD UNIQUE (site_id, language_id),\n    DROP CONSTRAINT site_language_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nCREATE OR REPLACE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table.\n    -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests\n    IF (TG_OP = 'INSERT') AND NOT EXISTS (\n    SELECT\n        id\n    FROM\n        site_aggregates\n    LIMIT 1) THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-10-24-030352_change_primary_keys_and_remove_some_id_columns/up.sql",
    "content": "ALTER TABLE captcha_answer\n    DROP COLUMN id,\n    ADD PRIMARY KEY (uuid),\n    DROP CONSTRAINT captcha_answer_uuid_key;\n\nALTER TABLE comment_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (comment_id),\n    DROP CONSTRAINT comment_aggregates_comment_id_key;\n\nALTER TABLE comment_like\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, comment_id),\n    DROP CONSTRAINT comment_like_comment_id_person_id_key;\n\nDROP INDEX idx_comment_like_person;\n\nALTER TABLE comment_saved\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, comment_id),\n    DROP CONSTRAINT comment_saved_comment_id_person_id_key;\n\nDROP INDEX idx_comment_saved_person_id;\n\nALTER TABLE community_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (community_id),\n    DROP CONSTRAINT community_aggregates_community_id_key;\n\nALTER TABLE community_block\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, community_id),\n    DROP CONSTRAINT community_block_person_id_community_id_key;\n\nDROP INDEX idx_community_block_person;\n\nALTER TABLE community_follower\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, community_id),\n    DROP CONSTRAINT community_follower_community_id_person_id_key;\n\nDROP INDEX idx_community_follower_person;\n\nALTER TABLE community_language\n    DROP COLUMN id,\n    ADD PRIMARY KEY (community_id, language_id),\n    DROP CONSTRAINT community_language_community_id_language_id_key;\n\nALTER TABLE community_moderator\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, community_id),\n    DROP CONSTRAINT community_moderator_community_id_person_id_key;\n\nDROP INDEX idx_community_moderator_person;\n\nALTER TABLE community_person_ban\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, community_id),\n    DROP CONSTRAINT community_person_ban_community_id_person_id_key;\n\nALTER TABLE custom_emoji_keyword\n    DROP COLUMN id,\n    ADD PRIMARY KEY (custom_emoji_id, keyword),\n    DROP CONSTRAINT custom_emoji_keyword_custom_emoji_id_keyword_key;\n\nALTER TABLE federation_allowlist\n    DROP COLUMN id,\n    ADD PRIMARY KEY (instance_id),\n    DROP CONSTRAINT federation_allowlist_instance_id_key;\n\nALTER TABLE federation_blocklist\n    DROP COLUMN id,\n    ADD PRIMARY KEY (instance_id),\n    DROP CONSTRAINT federation_blocklist_instance_id_key;\n\nALTER TABLE federation_queue_state\n    DROP COLUMN id,\n    ADD PRIMARY KEY (instance_id),\n    DROP CONSTRAINT federation_queue_state_instance_id_key;\n\nALTER TABLE image_upload\n    DROP COLUMN id,\n    ADD PRIMARY KEY (pictrs_alias),\n    DROP CONSTRAINT image_upload_pictrs_alias_key;\n\nALTER TABLE instance_block\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, instance_id),\n    DROP CONSTRAINT instance_block_person_id_instance_id_key;\n\nALTER TABLE local_site_rate_limit\n    DROP COLUMN id,\n    ADD PRIMARY KEY (local_site_id),\n    DROP CONSTRAINT local_site_rate_limit_local_site_id_key;\n\nALTER TABLE local_user_language\n    DROP COLUMN id,\n    ADD PRIMARY KEY (local_user_id, language_id),\n    DROP CONSTRAINT local_user_language_local_user_id_language_id_key;\n\nALTER TABLE login_token\n    DROP COLUMN id,\n    ADD PRIMARY KEY (token),\n    DROP CONSTRAINT login_token_token_key;\n\n-- Delete duplicates which can exist because of missing `UNIQUE` constraint\nDELETE FROM person_aggregates AS a USING (\n    SELECT\n        min(id) AS id,\n        person_id\n    FROM\n        person_aggregates\n    GROUP BY\n        person_id\n    HAVING\n        count(*) > 1) AS b\nWHERE\n    a.person_id = b.person_id\n    AND a.id != b.id;\n\nALTER TABLE person_aggregates\n    DROP CONSTRAINT IF EXISTS person_aggregates_person_id_key;\n\nALTER TABLE person_aggregates\n    ADD UNIQUE (person_id);\n\nALTER TABLE person_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id),\n    DROP CONSTRAINT person_aggregates_person_id_key;\n\nALTER TABLE person_ban\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id),\n    DROP CONSTRAINT person_ban_person_id_key;\n\nALTER TABLE person_block\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, target_id),\n    DROP CONSTRAINT person_block_person_id_target_id_key;\n\nALTER TABLE person_follower\n    DROP COLUMN id,\n    ADD PRIMARY KEY (follower_id, person_id),\n    DROP CONSTRAINT person_follower_follower_id_person_id_key;\n\nALTER TABLE person_post_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, post_id),\n    DROP CONSTRAINT person_post_aggregates_person_id_post_id_key;\n\nALTER TABLE post_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (post_id),\n    DROP CONSTRAINT post_aggregates_post_id_key;\n\nALTER TABLE post_like\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, post_id),\n    DROP CONSTRAINT post_like_post_id_person_id_key;\n\nDROP INDEX idx_post_like_person;\n\nALTER TABLE post_read\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, post_id),\n    DROP CONSTRAINT post_read_post_id_person_id_key;\n\nALTER TABLE post_saved\n    DROP COLUMN id,\n    ADD PRIMARY KEY (person_id, post_id),\n    DROP CONSTRAINT post_saved_post_id_person_id_key;\n\nDROP INDEX idx_post_saved_person_id;\n\nALTER TABLE received_activity\n    DROP COLUMN id,\n    ADD PRIMARY KEY (ap_id),\n    DROP CONSTRAINT received_activity_ap_id_key;\n\n-- Delete duplicates which can exist because of missing `UNIQUE` constraint\nDELETE FROM site_aggregates AS a USING (\n    SELECT\n        min(id) AS id,\n        site_id\n    FROM\n        site_aggregates\n    GROUP BY\n        site_id\n    HAVING\n        count(*) > 1) AS b\nWHERE\n    a.site_id = b.site_id\n    AND a.id != b.id;\n\nALTER TABLE site_aggregates\n    DROP COLUMN id,\n    ADD PRIMARY KEY (site_id);\n\nALTER TABLE site_language\n    DROP COLUMN id,\n    ADD PRIMARY KEY (site_id, language_id),\n    DROP CONSTRAINT site_language_site_id_language_id_key;\n\n-- Change functions to not use the removed columns\nCREATE OR REPLACE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table.\n    -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests\n    IF (TG_OP = 'INSERT') AND NOT EXISTS (\n    SELECT\n        *\n    FROM\n        site_aggregates\n    LIMIT 1) THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2023-10-24-131607_proxy_links/down.sql",
    "content": "DROP TABLE remote_image;\n\nALTER TABLE local_image RENAME TO image_upload;\n\n"
  },
  {
    "path": "migrations/2023-10-24-131607_proxy_links/up.sql",
    "content": "CREATE TABLE remote_image (\n    id serial PRIMARY KEY,\n    link text NOT NULL UNIQUE,\n    published timestamptz DEFAULT now() NOT NULL\n);\n\nALTER TABLE image_upload RENAME TO local_image;\n\n"
  },
  {
    "path": "migrations/2023-10-24-183747_autocollapse_bot_comments/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN collapse_bot_comments;\n\n"
  },
  {
    "path": "migrations/2023-10-24-183747_autocollapse_bot_comments/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN collapse_bot_comments boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-10-27-142514_post_url_content_type/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN url_content_type;\n\n"
  },
  {
    "path": "migrations/2023-10-27-142514_post_url_content_type/up.sql",
    "content": "ALTER TABLE post\n    ADD COLUMN url_content_type text;\n\n"
  },
  {
    "path": "migrations/2023-11-01-223740_federation-published/down.sql",
    "content": "ALTER TABLE federation_queue_state\n    DROP COLUMN last_successful_published_time,\n    ALTER COLUMN last_successful_id SET NOT NULL,\n    ALTER COLUMN last_retry SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-11-01-223740_federation-published/up.sql",
    "content": "ALTER TABLE federation_queue_state\n    ADD COLUMN last_successful_published_time timestamptz NULL,\n    ALTER COLUMN last_successful_id DROP NOT NULL,\n    ALTER COLUMN last_retry DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2023-11-02-120140_apub-signed-fetch/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN federation_signed_fetch;\n\n"
  },
  {
    "path": "migrations/2023-11-02-120140_apub-signed-fetch/up.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN federation_signed_fetch boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2023-11-07-135409_inbox_unique/down.sql",
    "content": "ALTER TABLE person\n    ADD CONSTRAINT idx_person_inbox_url UNIQUE (inbox_url);\n\nALTER TABLE community\n    ADD CONSTRAINT idx_community_inbox_url UNIQUE (inbox_url);\n\nUPDATE\n    site\nSET\n    inbox_url = inbox_query.inbox\nFROM (\n    SELECT\n        format('https://%s/site_inbox', DOMAIN) AS inbox\n    FROM\n        instance,\n        site,\n        local_site\n    WHERE\n        instance.id = site.instance_id\n        AND local_site.id = site.id) AS inbox_query,\n    instance,\n    local_site\nWHERE\n    instance.id = site.instance_id\n    AND local_site.id = site.id;\n\n"
  },
  {
    "path": "migrations/2023-11-07-135409_inbox_unique/up.sql",
    "content": "-- drop unique constraints for inbox columns\nALTER TABLE person\n    DROP CONSTRAINT idx_person_inbox_url;\n\nALTER TABLE community\n    DROP CONSTRAINT idx_community_inbox_url;\n\n-- change site inbox path from /inbox to /site_inbox\n-- we dont have any way here to set the correct protocol (http or https) according to tls_enabled, or set\n-- the correct port in case of debugging\nUPDATE\n    site\nSET\n    inbox_url = inbox_query.inbox\nFROM (\n    SELECT\n        format('https://%s/inbox', DOMAIN) AS inbox\n    FROM\n        instance,\n        site,\n        local_site\n    WHERE\n        instance.id = site.instance_id\n        AND local_site.id = site.id) AS inbox_query,\n    instance,\n    local_site\nWHERE\n    instance.id = site.instance_id\n    AND local_site.id = site.id;\n\n"
  },
  {
    "path": "migrations/2023-11-22-194806_low_rank_defaults/down.sql",
    "content": "ALTER TABLE community_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.1728,\n    ALTER COLUMN hot_rank_active SET DEFAULT 0.1728,\n    ALTER COLUMN scaled_rank SET DEFAULT 0.3621;\n\n"
  },
  {
    "path": "migrations/2023-11-22-194806_low_rank_defaults/up.sql",
    "content": "-- Change the hot_ranks to a miniscule number, so that new / fetched content\n-- won't crowd out existing content.\n--\n-- They must be non-zero, in order for them to be picked up by the hot_ranks updater.\n-- See https://github.com/LemmyNet/lemmy/issues/4178\nALTER TABLE community_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001;\n\nALTER TABLE comment_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001;\n\nALTER TABLE post_aggregates\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001,\n    ALTER COLUMN hot_rank_active SET DEFAULT 0.0001,\n    ALTER COLUMN scaled_rank SET DEFAULT 0.0001;\n\n"
  },
  {
    "path": "migrations/2023-12-06-180359_edit_active_users/down.sql",
    "content": "CREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person u ON c.creator_id = u.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person u ON p.creator_id = u.id\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND u.local = TRUE\n            AND pe.bot_account = FALSE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2023-12-06-180359_edit_active_users/up.sql",
    "content": "-- Edit community aggregates to include voters as active users\nCREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        pl.person_id,\n        p.community_id\n    FROM\n        post_like pl\n            INNER JOIN post p ON pl.post_id = p.id\n            INNER JOIN person pe ON pl.person_id = pe.id\n        WHERE\n            pl.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        cl.person_id,\n        p.community_id\n    FROM\n        comment_like cl\n            INNER JOIN post p ON cl.post_id = p.id\n            INNER JOIN person pe ON cl.person_id = pe.id\n        WHERE\n            cl.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\n-- Edit site aggregates to include voters and people who have read posts as active users\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            pl.person_id\n        FROM\n            post_like pl\n            INNER JOIN person pe ON pl.person_id = pe.id\n        WHERE\n            pl.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            cl.person_id\n        FROM\n            comment_like cl\n            INNER JOIN person pe ON cl.person_id = pe.id\n        WHERE\n            cl.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2023-12-19-210053_tolerable-batch-insert-speed/down.sql",
    "content": "CREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id)\n        SELECT\n            NEW.id,\n            NEW.published,\n            NEW.published,\n            NEW.published,\n            NEW.community_id,\n            NEW.creator_id,\n            community.instance_id\n        FROM\n            community\n        WHERE\n            NEW.community_id = community.id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM post_aggregates\n        WHERE post_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER post_aggregates_post\n    AFTER INSERT OR DELETE ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE post_aggregates_post ();\n\nCREATE OR REPLACE TRIGGER community_aggregates_post_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_post_count ();\n\nDROP FUNCTION IF EXISTS community_aggregates_post_count_insert CASCADE;\n\nDROP FUNCTION IF EXISTS community_aggregates_post_update CASCADE;\n\nDROP FUNCTION IF EXISTS site_aggregates_post_update CASCADE;\n\nDROP FUNCTION IF EXISTS person_aggregates_post_insert CASCADE;\n\nCREATE OR REPLACE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER site_aggregates_post_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_insert ();\n\nCREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'http://changeme.invalid/' || substr(md5(random()::text), 0, 25);\n$$;\n\nCREATE OR REPLACE TRIGGER person_aggregates_post_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_count ();\n\nDROP SEQUENCE IF EXISTS changeme_seq;\n\n"
  },
  {
    "path": "migrations/2023-12-19-210053_tolerable-batch-insert-speed/up.sql",
    "content": "-- Change triggers to run once per statement instead of once per row\n-- post_aggregates_post trigger doesn't need to handle deletion because the post_id column has ON DELETE CASCADE\nCREATE OR REPLACE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id)\n    SELECT\n        id,\n        published,\n        published,\n        published,\n        community_id,\n        creator_id,\n        (\n            SELECT\n                community.instance_id\n            FROM\n                community\n            WHERE\n                community.id = community_id\n            LIMIT 1)\nFROM\n    new_post;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION community_aggregates_post_count_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        community_aggregates\n    SET\n        posts = posts + post_group.count\n    FROM (\n        SELECT\n            community_id,\n            count(*)\n        FROM\n            new_post\n        GROUP BY\n            community_id) post_group\nWHERE\n    community_aggregates.community_id = post_group.community_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION person_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        person_aggregates\n    SET\n        post_count = post_count + post_group.count\n    FROM (\n        SELECT\n            creator_id,\n            count(*)\n        FROM\n            new_post\n        GROUP BY\n            creator_id) post_group\nWHERE\n    person_aggregates.person_id = post_group.creator_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER post_aggregates_post\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE PROCEDURE post_aggregates_post ();\n\n-- Don't run old trigger for insert\nCREATE OR REPLACE TRIGGER community_aggregates_post_count\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE community_aggregates_post_count ();\n\nCREATE OR REPLACE TRIGGER community_aggregates_post_count_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE PROCEDURE community_aggregates_post_count_insert ();\n\nCREATE OR REPLACE FUNCTION site_aggregates_post_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        posts = posts + (\n            SELECT\n                count(*)\n            FROM\n                new_post)\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER site_aggregates_post_update\n    AFTER UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN (NEW.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_post_update ();\n\nCREATE OR REPLACE TRIGGER site_aggregates_post_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE PROCEDURE site_aggregates_post_insert ();\n\nCREATE OR REPLACE TRIGGER person_aggregates_post_count\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE PROCEDURE person_aggregates_post_count ();\n\nCREATE OR REPLACE TRIGGER person_aggregates_post_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE PROCEDURE person_aggregates_post_insert ();\n\n-- Avoid running hash function and random number generation for default ap_id\nCREATE SEQUENCE IF NOT EXISTS changeme_seq AS bigint CYCLE;\n\nCREATE OR REPLACE FUNCTION generate_unique_changeme ()\n    RETURNS text\n    LANGUAGE sql\n    AS $$\n    SELECT\n        'http://changeme.invalid/seq/' || nextval('changeme_seq')::text;\n$$;\n\n"
  },
  {
    "path": "migrations/2023-12-22-040137_make-mixed-sorting-directions-work-with-tuple-comparison/down.sql",
    "content": "DROP INDEX idx_post_aggregates_community_published_asc, idx_post_aggregates_featured_community_published_asc, idx_post_aggregates_featured_local_published_asc, idx_post_aggregates_published_asc;\n\nDROP FUNCTION reverse_timestamp_sort (t timestamp with time zone);\n\n"
  },
  {
    "path": "migrations/2023-12-22-040137_make-mixed-sorting-directions-work-with-tuple-comparison/up.sql",
    "content": "CREATE FUNCTION reverse_timestamp_sort (t timestamp with time zone)\n    RETURNS bigint\n    AS $$\nBEGIN\n    RETURN (-1000000 * EXTRACT(EPOCH FROM t))::bigint;\nEND;\n$$\nLANGUAGE plpgsql\nIMMUTABLE PARALLEL SAFE;\n\nCREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_published_asc ON public.post_aggregates USING btree (reverse_timestamp_sort (published) DESC);\n\n"
  },
  {
    "path": "migrations/2024-01-02-094916_site-name-not-unique/down.sql",
    "content": "ALTER TABLE site\n    ADD CONSTRAINT site_name_key UNIQUE (name);\n\n"
  },
  {
    "path": "migrations/2024-01-02-094916_site-name-not-unique/up.sql",
    "content": "ALTER TABLE site\n    DROP CONSTRAINT site_name_key;\n\n"
  },
  {
    "path": "migrations/2024-01-05-213000_community_aggregates_add_local_subscribers/down.sql",
    "content": "ALTER TABLE community_aggregates\n    DROP COLUMN subscribers_local;\n\n-- old function from migrations/2023-10-02-145002_community_followers_count_federated/up.sql\n-- The subscriber count should only be updated for local communities. For remote\n-- communities it is read over federation from the origin instance.\nCREATE OR REPLACE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers + 1\n        FROM\n            community\n        WHERE\n            community.id = community_id\n            AND community.local\n            AND community_id = NEW.community_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates\n        SET\n            subscribers = subscribers - 1\n        FROM\n            community\n        WHERE\n            community.id = community_id\n            AND community.local\n            AND community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nDROP TRIGGER IF EXISTS delete_follow_before_person ON person;\n\nDROP FUNCTION IF EXISTS delete_follow_before_person;\n\n"
  },
  {
    "path": "migrations/2024-01-05-213000_community_aggregates_add_local_subscribers/up.sql",
    "content": "-- Couldn't find a way to put subscribers_local right after subscribers except recreating the table.\nALTER TABLE community_aggregates\n    ADD COLUMN subscribers_local bigint NOT NULL DEFAULT 0;\n\n-- update initial value\n-- update by counting local persons who follow communities.\nWITH follower_counts AS (\n    SELECT\n        community_id,\n        count(*) AS local_sub_count\n    FROM\n        community_follower cf\n        JOIN person p ON p.id = cf.person_id\n    WHERE\n        p.local = TRUE\n    GROUP BY\n        community_id)\nUPDATE\n    community_aggregates ca\nSET\n    subscribers_local = local_sub_count\nFROM\n    follower_counts\nWHERE\n    ca.community_id = follower_counts.community_id;\n\n-- subscribers should be updated only when a local community is followed by a local or remote person\n-- subscribers_local should be updated only when a local person follows a local or remote community\nCREATE OR REPLACE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            subscribers = subscribers + community.local::int,\n            subscribers_local = subscribers_local + person.local::int\n        FROM\n            community\n            LEFT JOIN person ON person.id = NEW.person_id\n        WHERE\n            community.id = NEW.community_id\n            AND community.id = ca.community_id\n            AND person.local IS NOT NULL;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            subscribers = subscribers - community.local::int,\n            subscribers_local = subscribers_local - person.local::int\n        FROM\n            community\n            LEFT JOIN person ON person.id = OLD.person_id\n        WHERE\n            community.id = OLD.community_id\n            AND community.id = ca.community_id\n            AND person.local IS NOT NULL;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\n-- to be able to join person on the trigger above, we need to run it before the person is deleted: https://github.com/LemmyNet/lemmy/pull/4166#issuecomment-1874095856\nCREATE FUNCTION delete_follow_before_person ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    DELETE FROM community_follower AS c\n    WHERE c.person_id = OLD.id;\n    RETURN OLD;\nEND;\n$$;\n\nCREATE TRIGGER delete_follow_before_person\n    BEFORE DELETE ON person\n    FOR EACH ROW\n    EXECUTE FUNCTION delete_follow_before_person ();\n\n"
  },
  {
    "path": "migrations/2024-01-15-100133_local-only-community/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN visibility;\n\nDROP TYPE community_visibility;\n\n"
  },
  {
    "path": "migrations/2024-01-15-100133_local-only-community/up.sql",
    "content": "CREATE TYPE community_visibility AS enum (\n    'Public',\n    'LocalOnly'\n);\n\nALTER TABLE community\n    ADD COLUMN visibility community_visibility NOT NULL DEFAULT 'Public';\n\n"
  },
  {
    "path": "migrations/2024-01-22-105746_lemmynsfw-changes/down.sql",
    "content": "ALTER TABLE site\n    DROP COLUMN content_warning;\n\nALTER TABLE local_site\n    DROP COLUMN default_post_listing_mode;\n\n"
  },
  {
    "path": "migrations/2024-01-22-105746_lemmynsfw-changes/up.sql",
    "content": "ALTER TABLE site\n    ADD COLUMN content_warning text;\n\nALTER TABLE local_site\n    ADD COLUMN default_post_listing_mode post_listing_mode_enum NOT NULL DEFAULT 'List';\n\n"
  },
  {
    "path": "migrations/2024-01-25-151400_remove_auto_resolve_report_trigger/down.sql",
    "content": "-- Automatically resolve all reports for a given post once it is marked as removed\nCREATE OR REPLACE FUNCTION post_removed_resolve_reports ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_report\n    SET\n        resolved = TRUE,\n        resolver_id = NEW.mod_person_id,\n        updated = now()\n    WHERE\n        post_report.post_id = NEW.post_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER post_removed_resolve_reports\n    AFTER INSERT ON mod_remove_post\n    FOR EACH ROW\n    WHEN (NEW.removed)\n    EXECUTE PROCEDURE post_removed_resolve_reports ();\n\n-- Same when comment is marked as removed\nCREATE OR REPLACE FUNCTION comment_removed_resolve_reports ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        comment_report\n    SET\n        resolved = TRUE,\n        resolver_id = NEW.mod_person_id,\n        updated = now()\n    WHERE\n        comment_report.comment_id = NEW.comment_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE OR REPLACE TRIGGER comment_removed_resolve_reports\n    AFTER INSERT ON mod_remove_comment\n    FOR EACH ROW\n    WHEN (NEW.removed)\n    EXECUTE PROCEDURE comment_removed_resolve_reports ();\n\n"
  },
  {
    "path": "migrations/2024-01-25-151400_remove_auto_resolve_report_trigger/up.sql",
    "content": "DROP TRIGGER IF EXISTS post_removed_resolve_reports ON mod_remove_post;\n\nDROP FUNCTION IF EXISTS post_removed_resolve_reports;\n\nDROP TRIGGER IF EXISTS comment_removed_resolve_reports ON mod_remove_comment;\n\nDROP FUNCTION IF EXISTS comment_removed_resolve_reports;\n\n"
  },
  {
    "path": "migrations/2024-02-12-211114_add_vote_display_mode_setting/down.sql",
    "content": "DROP TABLE local_user_vote_display_mode;\n\n"
  },
  {
    "path": "migrations/2024-02-12-211114_add_vote_display_mode_setting/up.sql",
    "content": "-- Create an extra table to hold local user vote display settings\n-- Score and Upvote percentage are turned on by default.\nCREATE TABLE local_user_vote_display_mode (\n    local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    score boolean DEFAULT TRUE NOT NULL,\n    upvotes boolean DEFAULT FALSE NOT NULL,\n    downvotes boolean DEFAULT FALSE NOT NULL,\n    upvote_percentage boolean DEFAULT TRUE NOT NULL,\n    PRIMARY KEY (local_user_id)\n);\n\n-- Insert rows for every local user\nINSERT INTO local_user_vote_display_mode (local_user_id)\nSELECT\n    id\nFROM\n    local_user;\n\n"
  },
  {
    "path": "migrations/2024-02-15-171358_default_instance_sort_type/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN default_sort_type;\n\n"
  },
  {
    "path": "migrations/2024-02-15-171358_default_instance_sort_type/up.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN default_sort_type sort_type_enum DEFAULT 'Active' NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-02-24-034523_replaceable-schema/down.sql",
    "content": "DROP SCHEMA IF EXISTS r CASCADE;\n\nDROP INDEX idx_site_aggregates_1_row_only;\n\nCREATE FUNCTION comment_aggregates_comment ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO comment_aggregates (comment_id, published)\n            VALUES (NEW.id, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM comment_aggregates\n        WHERE comment_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION comment_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        WHERE\n            ca.comment_id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to comment because that comment may not exist anymore\n        UPDATE\n            comment_aggregates ca\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        FROM\n            comment c\n        WHERE\n            ca.comment_id = c.id\n            AND ca.comment_id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION community_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments + 1\n        FROM\n            post p\n        WHERE\n            p.id = NEW.post_id\n            AND ca.community_id = p.community_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            comments = comments - 1\n        FROM\n            post p\n        WHERE\n            p.id = OLD.post_id\n            AND ca.community_id = p.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION community_aggregates_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO community_aggregates (community_id, published)\n            VALUES (NEW.id, NEW.published);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM community_aggregates\n        WHERE community_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION community_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts + 1\n        WHERE\n            community_id = NEW.community_id;\n        IF (TG_OP = 'UPDATE') THEN\n            -- Post was restored, so restore comment counts as well\n            UPDATE\n                community_aggregates ca\n            SET\n                posts = coalesce(cd.posts, 0),\n                comments = coalesce(cd.comments, 0)\n            FROM (\n                SELECT\n                    c.id,\n                    count(DISTINCT p.id) AS posts,\n                    count(DISTINCT ct.id) AS comments\n                FROM\n                    community c\n                LEFT JOIN post p ON c.id = p.community_id\n                    AND p.deleted = 'f'\n                    AND p.removed = 'f'\n            LEFT JOIN comment ct ON p.id = ct.post_id\n                AND ct.deleted = 'f'\n                AND ct.removed = 'f'\n        WHERE\n            c.id = NEW.community_id\n        GROUP BY\n            c.id) cd\n        WHERE\n            ca.community_id = NEW.community_id;\n        END IF;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            community_aggregates\n        SET\n            posts = posts - 1\n        WHERE\n            community_id = OLD.community_id;\n        -- Update the counts if the post got deleted\n        UPDATE\n            community_aggregates ca\n        SET\n            posts = coalesce(cd.posts, 0),\n            comments = coalesce(cd.comments, 0)\n        FROM (\n            SELECT\n                c.id,\n                count(DISTINCT p.id) AS posts,\n                count(DISTINCT ct.id) AS comments\n            FROM\n                community c\n            LEFT JOIN post p ON c.id = p.community_id\n                AND p.deleted = 'f'\n                AND p.removed = 'f'\n        LEFT JOIN comment ct ON p.id = ct.post_id\n            AND ct.deleted = 'f'\n            AND ct.removed = 'f'\n    WHERE\n        c.id = OLD.community_id\n    GROUP BY\n        c.id) cd\n    WHERE\n        ca.community_id = OLD.community_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION community_aggregates_post_count_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        community_aggregates\n    SET\n        posts = posts + post_group.count\n    FROM (\n        SELECT\n            community_id,\n            count(*)\n        FROM\n            new_post\n        GROUP BY\n            community_id) post_group\nWHERE\n    community_aggregates.community_id = post_group.community_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION community_aggregates_subscriber_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            subscribers = subscribers + community.local::int,\n            subscribers_local = subscribers_local + person.local::int\n        FROM\n            community\n            LEFT JOIN person ON person.id = NEW.person_id\n        WHERE\n            community.id = NEW.community_id\n            AND community.id = ca.community_id\n            AND person.local IS NOT NULL;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            community_aggregates ca\n        SET\n            subscribers = subscribers - community.local::int,\n            subscribers_local = subscribers_local - person.local::int\n        FROM\n            community\n            LEFT JOIN person ON person.id = OLD.person_id\n        WHERE\n            community.id = OLD.community_id\n            AND community.id = ca.community_id\n            AND person.local IS NOT NULL;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION delete_follow_before_person ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    DELETE FROM community_follower AS c\n    WHERE c.person_id = OLD.id;\n    RETURN OLD;\nEND;\n$$;\n\nCREATE FUNCTION person_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            comment_count = comment_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION person_aggregates_comment_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = comment_score + NEW.score\n        FROM\n            comment c\n        WHERE\n            ua.person_id = c.creator_id\n            AND c.id = NEW.comment_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates ua\n        SET\n            comment_score = comment_score - OLD.score\n        FROM\n            comment c\n        WHERE\n            ua.person_id = c.creator_id\n            AND c.id = OLD.comment_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION person_aggregates_person ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        INSERT INTO person_aggregates (person_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM person_aggregates\n        WHERE person_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION person_aggregates_post_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count + 1\n        WHERE\n            person_id = NEW.creator_id;\n    ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            person_aggregates\n        SET\n            post_count = post_count - 1\n        WHERE\n            person_id = OLD.creator_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION person_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        person_aggregates\n    SET\n        post_count = post_count + post_group.count\n    FROM (\n        SELECT\n            creator_id,\n            count(*)\n        FROM\n            new_post\n        GROUP BY\n            creator_id) post_group\nWHERE\n    person_aggregates.person_id = post_group.creator_id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION person_aggregates_post_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        -- Need to get the post creator, not the voter\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = post_score + NEW.score\n        FROM\n            post p\n        WHERE\n            ua.person_id = p.creator_id\n            AND p.id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        UPDATE\n            person_aggregates ua\n        SET\n            post_score = post_score - OLD.score\n        FROM\n            post p\n        WHERE\n            ua.person_id = p.creator_id\n            AND p.id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_comment_count ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Check for post existence - it may not exist anymore\n    IF TG_OP = 'INSERT' OR EXISTS (\n        SELECT\n            1\n        FROM\n            post p\n        WHERE\n            p.id = OLD.post_id) THEN\n        IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n            UPDATE\n                post_aggregates pa\n            SET\n                comments = comments + 1\n            WHERE\n                pa.post_id = NEW.post_id;\n        ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n            UPDATE\n                post_aggregates pa\n            SET\n                comments = comments - 1\n            WHERE\n                pa.post_id = OLD.post_id;\n        END IF;\n    END IF;\n    IF TG_OP = 'INSERT' THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time = NEW.published\n        WHERE\n            pa.post_id = NEW.post_id;\n        -- A 2 day necro-bump limit\n        UPDATE\n            post_aggregates pa\n        SET\n            newest_comment_time_necro = NEW.published\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = NEW.post_id\n            -- Fix issue with being able to necro-bump your own post\n            AND NEW.creator_id != p.creator_id\n            AND pa.published > ('now'::timestamp - '2 days'::interval);\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_featured_community ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        featured_community = NEW.featured_community\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_featured_local ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        post_aggregates pa\n    SET\n        featured_local = NEW.featured_local\n    WHERE\n        pa.post_id = NEW.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_post ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id)\n    SELECT\n        id,\n        published,\n        published,\n        published,\n        community_id,\n        creator_id,\n        (\n            SELECT\n                community.instance_id\n            FROM\n                community\n            WHERE\n                community.id = community_id\n            LIMIT 1)\nFROM\n    new_post;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION post_aggregates_score ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score + NEW.score,\n            upvotes = CASE WHEN NEW.score = 1 THEN\n                upvotes + 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN NEW.score = -1 THEN\n                downvotes + 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        WHERE\n            pa.post_id = NEW.post_id;\n    ELSIF (TG_OP = 'DELETE') THEN\n        -- Join to post because that post may not exist anymore\n        UPDATE\n            post_aggregates pa\n        SET\n            score = score - OLD.score,\n            upvotes = CASE WHEN OLD.score = 1 THEN\n                upvotes - 1\n            ELSE\n                upvotes\n            END,\n            downvotes = CASE WHEN OLD.score = -1 THEN\n                downvotes - 1\n            ELSE\n                downvotes\n            END,\n            controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN\n                    1\n                ELSE\n                    0\n                END::numeric)\n        FROM\n            post p\n        WHERE\n            pa.post_id = p.id\n            AND pa.post_id = OLD.post_id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_comment_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            comments = comments - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_comment_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            comments = comments + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_community_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            communities = communities - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_community_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            communities = communities + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_person_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- Join to site since the creator might not be there anymore\n    UPDATE\n        site_aggregates sa\n    SET\n        users = users - 1\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_person_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates\n    SET\n        users = users + 1;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_post_delete ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts - 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_post_insert ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    UPDATE\n        site_aggregates sa\n    SET\n        posts = posts + (\n            SELECT\n                count(*)\n            FROM\n                new_post)\n    FROM\n        site s\n    WHERE\n        sa.site_id = s.id;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_post_update ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN\n        UPDATE\n            site_aggregates sa\n        SET\n            posts = posts + 1\n        FROM\n            site s\n        WHERE\n            sa.site_id = s.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION site_aggregates_site ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table.\n    -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests\n    IF (TG_OP = 'INSERT') AND NOT EXISTS (\n    SELECT\n        *\n    FROM\n        site_aggregates\n    LIMIT 1) THEN\n        INSERT INTO site_aggregates (site_id)\n            VALUES (NEW.id);\n    ELSIF (TG_OP = 'DELETE') THEN\n        DELETE FROM site_aggregates\n        WHERE site_id = OLD.id;\n    END IF;\n    RETURN NULL;\nEND\n$$;\n\nCREATE FUNCTION was_removed_or_deleted (tg_op text, old record, new record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'INSERT') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND OLD.deleted = 'f'\n        AND OLD.removed = 'f'\n        AND (NEW.deleted = 't'\n            OR NEW.removed = 't');\nEND\n$$;\n\nCREATE FUNCTION was_restored_or_created (tg_op text, old record, new record)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF (TG_OP = 'DELETE') THEN\n        RETURN FALSE;\n    END IF;\n    IF (TG_OP = 'INSERT') THEN\n        RETURN TRUE;\n    END IF;\n    RETURN TG_OP = 'UPDATE'\n        AND NEW.deleted = 'f'\n        AND NEW.removed = 'f'\n        AND (OLD.deleted = 't'\n            OR OLD.removed = 't');\nEND\n$$;\n\nCREATE TRIGGER comment_aggregates_comment\n    AFTER INSERT OR DELETE ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION comment_aggregates_comment ();\n\nCREATE TRIGGER comment_aggregates_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE FUNCTION comment_aggregates_score ();\n\nCREATE TRIGGER community_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION community_aggregates_comment_count ();\n\nCREATE TRIGGER community_aggregates_community\n    AFTER INSERT OR DELETE ON community\n    FOR EACH ROW\n    EXECUTE FUNCTION community_aggregates_community ();\n\nCREATE TRIGGER community_aggregates_post_count\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE FUNCTION community_aggregates_post_count ();\n\nCREATE TRIGGER community_aggregates_post_count_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION community_aggregates_post_count_insert ();\n\nCREATE TRIGGER community_aggregates_subscriber_count\n    AFTER INSERT OR DELETE ON community_follower\n    FOR EACH ROW\n    EXECUTE FUNCTION community_aggregates_subscriber_count ();\n\nCREATE TRIGGER delete_follow_before_person\n    BEFORE DELETE ON person\n    FOR EACH ROW\n    EXECUTE FUNCTION delete_follow_before_person ();\n\nCREATE TRIGGER person_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION person_aggregates_comment_count ();\n\nCREATE TRIGGER person_aggregates_comment_score\n    AFTER INSERT OR DELETE ON comment_like\n    FOR EACH ROW\n    EXECUTE FUNCTION person_aggregates_comment_score ();\n\nCREATE TRIGGER person_aggregates_person\n    AFTER INSERT OR DELETE ON person\n    FOR EACH ROW\n    EXECUTE FUNCTION person_aggregates_person ();\n\nCREATE TRIGGER person_aggregates_post_count\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    EXECUTE FUNCTION person_aggregates_post_count ();\n\nCREATE TRIGGER person_aggregates_post_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION person_aggregates_post_insert ();\n\nCREATE TRIGGER person_aggregates_post_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE FUNCTION person_aggregates_post_score ();\n\nCREATE TRIGGER post_aggregates_comment_count\n    AFTER INSERT OR DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    EXECUTE FUNCTION post_aggregates_comment_count ();\n\nCREATE TRIGGER post_aggregates_featured_community\n    AFTER UPDATE ON post\n    FOR EACH ROW\n    WHEN ((old.featured_community IS DISTINCT FROM new.featured_community))\n    EXECUTE FUNCTION post_aggregates_featured_community ();\n\nCREATE TRIGGER post_aggregates_featured_local\n    AFTER UPDATE ON post\n    FOR EACH ROW\n    WHEN ((old.featured_local IS DISTINCT FROM new.featured_local))\n    EXECUTE FUNCTION post_aggregates_featured_local ();\n\nCREATE TRIGGER post_aggregates_post\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION post_aggregates_post ();\n\nCREATE TRIGGER post_aggregates_score\n    AFTER INSERT OR DELETE ON post_like\n    FOR EACH ROW\n    EXECUTE FUNCTION post_aggregates_score ();\n\nCREATE TRIGGER site_aggregates_comment_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    WHEN ((old.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_comment_delete ();\n\nCREATE TRIGGER site_aggregates_comment_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON comment\n    FOR EACH ROW\n    WHEN ((new.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_comment_insert ();\n\nCREATE TRIGGER site_aggregates_community_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON community\n    FOR EACH ROW\n    WHEN (OLD.local = TRUE)\n    EXECUTE PROCEDURE site_aggregates_community_delete ();\n\nCREATE TRIGGER site_aggregates_community_insert\n    AFTER INSERT OR UPDATE OF removed,\n    deleted ON community\n    FOR EACH ROW\n    WHEN ((new.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_community_insert ();\n\nCREATE TRIGGER site_aggregates_person_delete\n    AFTER DELETE ON person\n    FOR EACH ROW\n    WHEN ((old.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_person_delete ();\n\nCREATE TRIGGER site_aggregates_person_insert\n    AFTER INSERT ON person\n    FOR EACH ROW\n    WHEN ((new.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_person_insert ();\n\nCREATE TRIGGER site_aggregates_post_delete\n    AFTER DELETE OR UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN ((old.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_post_delete ();\n\nCREATE TRIGGER site_aggregates_post_insert\n    AFTER INSERT ON post REFERENCING NEW TABLE AS new_post\n    FOR EACH STATEMENT\n    EXECUTE FUNCTION site_aggregates_post_insert ();\n\nCREATE TRIGGER site_aggregates_post_update\n    AFTER UPDATE OF removed,\n    deleted ON post\n    FOR EACH ROW\n    WHEN ((new.local = TRUE))\n    EXECUTE FUNCTION site_aggregates_post_update ();\n\nCREATE TRIGGER site_aggregates_site\n    AFTER INSERT OR DELETE ON site\n    FOR EACH ROW\n    EXECUTE FUNCTION site_aggregates_site ();\n\n-- Rank functions\nCREATE FUNCTION controversy_rank (upvotes numeric, downvotes numeric)\n    RETURNS double precision\n    LANGUAGE plpgsql\n    IMMUTABLE\n    AS $$\nBEGIN\n    IF downvotes <= 0 OR upvotes <= 0 THEN\n        RETURN 0;\n    ELSE\n        RETURN (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN\n            downvotes::float / upvotes::float\n        ELSE\n            upvotes::float / downvotes::float\n        END;\n    END IF;\nEND;\n$$;\n\nCREATE FUNCTION hot_rank (score numeric, published timestamp with time zone)\n    RETURNS double precision\n    LANGUAGE plpgsql\n    IMMUTABLE PARALLEL SAFE\n    AS $$\nDECLARE\n    hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600;\nBEGIN\n    -- 24 * 7 = 168, so after a week, it will default to 0.\n    IF (hours_diff > 0 AND hours_diff < 168) THEN\n        -- Use greatest(2,score), so that the hot_rank will be positive and not ignored.\n        RETURN log(greatest (2, score + 2)) / power((hours_diff + 2), 1.8);\n    ELSE\n        -- if the post is from the future, set hot score to 0. otherwise you can game the post to\n        -- always be on top even with only 1 vote by setting it to the future\n        RETURN 0.0;\n    END IF;\nEND;\n$$;\n\nCREATE FUNCTION scaled_rank (score numeric, published timestamp with time zone, users_active_month numeric)\n    RETURNS double precision\n    LANGUAGE plpgsql\n    IMMUTABLE PARALLEL SAFE\n    AS $$\nBEGIN\n    -- Add 2 to avoid divide by zero errors\n    -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621\n    -- There may need to be a scale factor multiplied to users_active_month, to make\n    -- the log curve less pronounced. This can be tuned in the future.\n    RETURN (hot_rank (score, published) / log(2 + users_active_month));\nEND;\n$$;\n\n-- Don't defer constraints\nALTER TABLE comment_aggregates\n    ALTER CONSTRAINT comment_aggregates_comment_id_fkey NOT DEFERRABLE;\n\nALTER TABLE community_aggregates\n    ALTER CONSTRAINT community_aggregates_community_id_fkey NOT DEFERRABLE;\n\nALTER TABLE person_aggregates\n    ALTER CONSTRAINT person_aggregates_person_id_fkey NOT DEFERRABLE;\n\nALTER TABLE post_aggregates\n    ALTER CONSTRAINT post_aggregates_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT post_aggregates_creator_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT post_aggregates_instance_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT post_aggregates_post_id_fkey NOT DEFERRABLE;\n\nALTER TABLE site_aggregates\n    ALTER CONSTRAINT site_aggregates_site_id_fkey NOT DEFERRABLE;\n\n"
  },
  {
    "path": "migrations/2024-02-24-034523_replaceable-schema/up.sql",
    "content": "CREATE UNIQUE INDEX idx_site_aggregates_1_row_only ON site_aggregates ((TRUE));\n\n-- Drop functions and use `CASCADE` to drop the triggers that use them\nDROP FUNCTION comment_aggregates_comment, comment_aggregates_score, community_aggregates_comment_count, community_aggregates_community, community_aggregates_post_count, community_aggregates_post_count_insert, community_aggregates_subscriber_count, delete_follow_before_person, person_aggregates_comment_count, person_aggregates_comment_score, person_aggregates_person, person_aggregates_post_count, person_aggregates_post_insert, person_aggregates_post_score, post_aggregates_comment_count, post_aggregates_featured_community, post_aggregates_featured_local, post_aggregates_post, post_aggregates_score, site_aggregates_comment_delete, site_aggregates_comment_insert, site_aggregates_community_delete, site_aggregates_community_insert, site_aggregates_person_delete, site_aggregates_person_insert, site_aggregates_post_delete, site_aggregates_post_insert, site_aggregates_post_update, site_aggregates_site, was_removed_or_deleted, was_restored_or_created CASCADE;\n\n-- Drop rank functions\nDROP FUNCTION controversy_rank, scaled_rank, hot_rank;\n\n-- Defer constraints\nALTER TABLE comment_aggregates\n    ALTER CONSTRAINT comment_aggregates_comment_id_fkey INITIALLY DEFERRED;\n\nALTER TABLE community_aggregates\n    ALTER CONSTRAINT community_aggregates_community_id_fkey INITIALLY DEFERRED;\n\nALTER TABLE person_aggregates\n    ALTER CONSTRAINT person_aggregates_person_id_fkey INITIALLY DEFERRED;\n\nALTER TABLE post_aggregates\n    ALTER CONSTRAINT post_aggregates_community_id_fkey INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_creator_id_fkey INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_instance_id_fkey INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_post_id_fkey INITIALLY DEFERRED;\n\nALTER TABLE site_aggregates\n    ALTER CONSTRAINT site_aggregates_site_id_fkey INITIALLY DEFERRED;\n\n-- Fix values that might be incorrect because of the old triggers\nUPDATE\n    post_aggregates\nSET\n    featured_local = post.featured_local,\n    featured_community = post.featured_community\nFROM\n    post\nWHERE\n    post_aggregates.post_id = post.id\n    AND (post_aggregates.featured_local,\n        post_aggregates.featured_community) != (post.featured_local,\n        post.featured_community);\n\nUPDATE\n    community_aggregates\nSET\n    comments = counted.comments\nFROM (\n    SELECT\n        community_id,\n        count(*) AS comments\n    FROM\n        comment,\n        LATERAL (\n            SELECT\n                *\n            FROM\n                post\n            WHERE\n                post.id = comment.post_id\n            LIMIT 1) AS post\n    WHERE\n        NOT (comment.deleted\n            OR comment.removed\n            OR post.deleted\n            OR post.removed)\n    GROUP BY\n        community_id) AS counted\nWHERE\n    community_aggregates.community_id = counted.community_id\n    AND community_aggregates.comments != counted.comments;\n\nUPDATE\n    site_aggregates\nSET\n    communities = (\n        SELECT\n            count(*)\n        FROM\n            community\n        WHERE\n            local);\n\n"
  },
  {
    "path": "migrations/2024-02-27-204628_add_post_alt_text/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN alt_text;\n\n"
  },
  {
    "path": "migrations/2024-02-27-204628_add_post_alt_text/up.sql",
    "content": "ALTER TABLE post\n    ADD COLUMN alt_text text;\n\n"
  },
  {
    "path": "migrations/2024-02-28-144211_hide_posts/down.sql",
    "content": "DROP TABLE post_hide;\n\n"
  },
  {
    "path": "migrations/2024-02-28-144211_hide_posts/up.sql",
    "content": "CREATE TABLE post_hide (\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamp with time zone NOT NULL DEFAULT now(),\n    PRIMARY KEY (person_id, post_id)\n);\n\n"
  },
  {
    "path": "migrations/2024-03-06-104706_local_image_user_opt/down.sql",
    "content": "ALTER TABLE local_image\n    ADD CONSTRAINT image_upload_local_user_id_not_null NOT NULL local_user_id;\n\n"
  },
  {
    "path": "migrations/2024-03-06-104706_local_image_user_opt/up.sql",
    "content": "ALTER TABLE local_image\n    ALTER COLUMN local_user_id DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-03-06-201637_url_blocklist/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE local_site_url_blocklist;\n\n"
  },
  {
    "path": "migrations/2024-03-06-201637_url_blocklist/up.sql",
    "content": "CREATE TABLE local_site_url_blocklist (\n    id serial NOT NULL PRIMARY KEY,\n    url text NOT NULL UNIQUE,\n    published timestamp with time zone NOT NULL DEFAULT now(),\n    updated timestamp with time zone\n);\n\n"
  },
  {
    "path": "migrations/2024-04-05-153647_alter_vote_display_mode_defaults/down.sql",
    "content": "ALTER TABLE local_user_vote_display_mode\n    DROP COLUMN score,\n    ADD COLUMN score boolean DEFAULT TRUE NOT NULL,\n    DROP COLUMN upvotes,\n    ADD COLUMN upvotes boolean DEFAULT FALSE NOT NULL,\n    DROP COLUMN downvotes,\n    ADD COLUMN downvotes boolean DEFAULT FALSE NOT NULL,\n    DROP COLUMN upvote_percentage,\n    ADD COLUMN upvote_percentage boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-04-05-153647_alter_vote_display_mode_defaults/up.sql",
    "content": "-- Based on a poll, update the local_user_vote_display_mode defaults to:\n-- Upvotes + Downvotes\n-- Rather than\n-- Score + upvote_percentage\nALTER TABLE local_user_vote_display_mode\n    DROP COLUMN score,\n    ADD COLUMN score boolean DEFAULT FALSE NOT NULL,\n    DROP COLUMN upvotes,\n    ADD COLUMN upvotes boolean DEFAULT TRUE NOT NULL,\n    DROP COLUMN downvotes,\n    ADD COLUMN downvotes boolean DEFAULT TRUE NOT NULL,\n    DROP COLUMN upvote_percentage,\n    ADD COLUMN upvote_percentage boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-04-15-105932_community_followers_url_optional/down.sql",
    "content": "ALTER TABLE community\n    ALTER COLUMN followers_url SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-04-15-105932_community_followers_url_optional/up.sql",
    "content": "ALTER TABLE community\n    ALTER COLUMN followers_url DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2024-04-23-020604_add_post_id_index/down.sql",
    "content": "DROP INDEX idx_post_aggregates_community_active;\n\nDROP INDEX idx_post_aggregates_community_controversy;\n\nDROP INDEX idx_post_aggregates_community_hot;\n\nDROP INDEX idx_post_aggregates_community_most_comments;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_community_published;\n\nDROP INDEX idx_post_aggregates_community_published_asc;\n\nDROP INDEX idx_post_aggregates_community_scaled;\n\nDROP INDEX idx_post_aggregates_community_score;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_controversy;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necr;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\nDROP INDEX idx_post_aggregates_featured_community_published_asc;\n\nDROP INDEX idx_post_aggregates_featured_community_scaled;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_local_controversy;\n\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_local_published;\n\nDROP INDEX idx_post_aggregates_featured_local_published_asc;\n\nDROP INDEX idx_post_aggregates_featured_local_scaled;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nCREATE INDEX idx_post_aggregates_community_active ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_controversy ON public.post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_community_hot ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_most_comments ON public.post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_community_published ON public.post_aggregates USING btree (community_id, featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, public.reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_community_scaled ON public.post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_community_score ON public.post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_controversy ON public.post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_most_comments ON public.post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necr ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (community_id, featured_community DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, public.reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_scaled ON public.post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_controversy ON public.post_aggregates USING btree (featured_local DESC, controversy_rank DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_most_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, public.reverse_timestamp_sort (published) DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_scaled ON public.post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC, published DESC);\n\n"
  },
  {
    "path": "migrations/2024-04-23-020604_add_post_id_index/up.sql",
    "content": "-- Add , post_id DESC to all these\nDROP INDEX idx_post_aggregates_community_active;\n\nDROP INDEX idx_post_aggregates_community_controversy;\n\nDROP INDEX idx_post_aggregates_community_hot;\n\nDROP INDEX idx_post_aggregates_community_most_comments;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_community_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_community_published;\n\nDROP INDEX idx_post_aggregates_community_published_asc;\n\nDROP INDEX idx_post_aggregates_community_scaled;\n\nDROP INDEX idx_post_aggregates_community_score;\n\nDROP INDEX idx_post_aggregates_featured_community_active;\n\nDROP INDEX idx_post_aggregates_featured_community_controversy;\n\nDROP INDEX idx_post_aggregates_featured_community_hot;\n\nDROP INDEX idx_post_aggregates_featured_community_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necr;\n\nDROP INDEX idx_post_aggregates_featured_community_published;\n\nDROP INDEX idx_post_aggregates_featured_community_published_asc;\n\nDROP INDEX idx_post_aggregates_featured_community_scaled;\n\nDROP INDEX idx_post_aggregates_featured_community_score;\n\nDROP INDEX idx_post_aggregates_featured_local_active;\n\nDROP INDEX idx_post_aggregates_featured_local_controversy;\n\nDROP INDEX idx_post_aggregates_featured_local_hot;\n\nDROP INDEX idx_post_aggregates_featured_local_most_comments;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time;\n\nDROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro;\n\nDROP INDEX idx_post_aggregates_featured_local_published;\n\nDROP INDEX idx_post_aggregates_featured_local_published_asc;\n\nDROP INDEX idx_post_aggregates_featured_local_scaled;\n\nDROP INDEX idx_post_aggregates_featured_local_score;\n\nCREATE INDEX idx_post_aggregates_community_active ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_controversy ON public.post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_hot ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_most_comments ON public.post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_published ON public.post_aggregates USING btree (community_id, featured_local DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_scaled ON public.post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_community_score ON public.post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_controversy ON public.post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_most_comments ON public.post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necr ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (community_id, featured_community DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_scaled ON public.post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_controversy ON public.post_aggregates USING btree (featured_local DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_most_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_scaled ON public.post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC, published DESC, post_id DESC);\n\n"
  },
  {
    "path": "migrations/2024-05-04-140749_separate_triggers/down.sql",
    "content": "SELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2024-05-04-140749_separate_triggers/up.sql",
    "content": "-- This migration exists to trigger re-execution of replaceable_schema\nSELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2024-05-05-162540_add_image_detail_table/down.sql",
    "content": "ALTER TABLE remote_image\n    ADD UNIQUE (link),\n    DROP CONSTRAINT remote_image_pkey,\n    ADD COLUMN id serial PRIMARY KEY;\n\nDROP TABLE image_details;\n\n"
  },
  {
    "path": "migrations/2024-05-05-162540_add_image_detail_table/up.sql",
    "content": "-- Drop the id column from the remote_image table, just use link\nALTER TABLE remote_image\n    DROP COLUMN id,\n    ADD PRIMARY KEY (link),\n    DROP CONSTRAINT remote_image_link_key;\n\n-- No good way to do references here unfortunately, unless we combine the images tables\n-- The link should be the URL, not the pictrs_alias, to allow joining from post.thumbnail_url\nCREATE TABLE image_details (\n    link text PRIMARY KEY,\n    width integer NOT NULL,\n    height integer NOT NULL,\n    content_type text NOT NULL\n);\n\n"
  },
  {
    "path": "migrations/2024-06-17-160323_fix_post_aggregates_featured_local/down.sql",
    "content": "SELECT\n;\n\n"
  },
  {
    "path": "migrations/2024-06-17-160323_fix_post_aggregates_featured_local/up.sql",
    "content": "-- Fix rows that were not updated because of the old incorrect trigger\nUPDATE\n    post_aggregates\nSET\n    featured_local = post.featured_local\nFROM\n    post\nWHERE\n    post.id = post_aggregates.post_id\n    AND post.featured_local != post_aggregates.featured_local;\n\n"
  },
  {
    "path": "migrations/2024-06-24-000000_ap_id_triggers/down.sql",
    "content": "ALTER TABLE comment\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE post\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE private_message\n    ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme ();\n\n"
  },
  {
    "path": "migrations/2024-06-24-000000_ap_id_triggers/up.sql",
    "content": "ALTER TABLE comment\n    ALTER COLUMN ap_id DROP DEFAULT;\n\nALTER TABLE post\n    ALTER COLUMN ap_id DROP DEFAULT;\n\nALTER TABLE private_message\n    ALTER COLUMN ap_id DROP DEFAULT;\n\n"
  },
  {
    "path": "migrations/2024-07-01-014711_exponential_controversy/down.sql",
    "content": "UPDATE\n    post_aggregates\nSET\n    controversy_rank = CASE WHEN downvotes <= 0\n        OR upvotes <= 0 THEN\n        0\n    ELSE\n        (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN\n            downvotes::float / upvotes::float\n        ELSE\n            upvotes::float / downvotes::float\n        END\n    END\nWHERE\n    upvotes > 0\n    AND downvotes > 0;\n\n"
  },
  {
    "path": "migrations/2024-07-01-014711_exponential_controversy/up.sql",
    "content": "UPDATE\n    post_aggregates\nSET\n    controversy_rank = (upvotes + downvotes) ^ CASE WHEN upvotes > downvotes THEN\n        downvotes::float / upvotes::float\n    ELSE\n        upvotes::float / downvotes::float\n    END\nWHERE\n    upvotes > 0\n    AND downvotes > 0\n    -- a number divided by itself is 1, and `* 1` does the same thing as `^ 1`\n    AND upvotes != downvotes;\n\n"
  },
  {
    "path": "migrations/2024-08-03-155932_increase_post_url_max_length/down.sql",
    "content": "ALTER TABLE post\n    ALTER COLUMN url TYPE varchar(512);\n\nANALYZE post (url);\n\n"
  },
  {
    "path": "migrations/2024-08-03-155932_increase_post_url_max_length/up.sql",
    "content": "-- Change the post url max limit to 2000\n-- From here: https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers#417184\nALTER TABLE post\n    ALTER COLUMN url TYPE varchar(2000);\n\nANALYZE post (url);\n\n"
  },
  {
    "path": "migrations/2024-11-12-090437_move-triggers/down.sql",
    "content": "-- Edit community aggregates to include voters as active users\nCREATE OR REPLACE FUNCTION community_aggregates_activity (i text)\n    RETURNS TABLE (\n        count_ bigint,\n        community_id_ integer)\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    RETURN QUERY\n    SELECT\n        count(*),\n        community_id\n    FROM (\n        SELECT\n            c.creator_id,\n            p.community_id\n        FROM\n            comment c\n            INNER JOIN post p ON c.post_id = p.id\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        p.creator_id,\n        p.community_id\n    FROM\n        post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        pl.person_id,\n        p.community_id\n    FROM\n        post_like pl\n            INNER JOIN post p ON pl.post_id = p.id\n            INNER JOIN person pe ON pl.person_id = pe.id\n        WHERE\n            pl.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE\n    UNION\n    SELECT\n        cl.person_id,\n        p.community_id\n    FROM\n        comment_like cl\n            INNER JOIN post p ON cl.post_id = p.id\n            INNER JOIN person pe ON cl.person_id = pe.id\n        WHERE\n            cl.published > ('now'::timestamp - i::interval)\n            AND pe.bot_account = FALSE) a\nGROUP BY\n    community_id;\nEND;\n$$;\n\n-- Edit site aggregates to include voters and people who have read posts as active users\nCREATE OR REPLACE FUNCTION site_aggregates_activity (i text)\n    RETURNS integer\n    LANGUAGE plpgsql\n    AS $$\nDECLARE\n    count_ integer;\nBEGIN\n    SELECT\n        count(*) INTO count_\n    FROM (\n        SELECT\n            c.creator_id\n        FROM\n            comment c\n            INNER JOIN person pe ON c.creator_id = pe.id\n        WHERE\n            c.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            p.creator_id\n        FROM\n            post p\n            INNER JOIN person pe ON p.creator_id = pe.id\n        WHERE\n            p.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            pl.person_id\n        FROM\n            post_like pl\n            INNER JOIN person pe ON pl.person_id = pe.id\n        WHERE\n            pl.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE\n        UNION\n        SELECT\n            cl.person_id\n        FROM\n            comment_like cl\n            INNER JOIN person pe ON cl.person_id = pe.id\n        WHERE\n            cl.published > ('now'::timestamp - i::interval)\n            AND pe.local = TRUE\n            AND pe.bot_account = FALSE) a;\n    RETURN count_;\nEND;\n$$;\n\n"
  },
  {
    "path": "migrations/2024-11-12-090437_move-triggers/up.sql",
    "content": "DROP FUNCTION community_aggregates_activity, site_aggregates_activity CASCADE;\n\n"
  },
  {
    "path": "migrations/2025-01-10-135505_donation-dialog/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN last_donation_notification;\n\n"
  },
  {
    "path": "migrations/2025-01-10-135505_donation-dialog/up.sql",
    "content": "-- Generate new column last_donation_notification with default value at random time in the\n-- past year (so that users dont see it all at the same time after instance upgrade).\nALTER TABLE local_user\n    ADD COLUMN last_donation_notification timestamptz NOT NULL DEFAULT (now() - (random() * (interval '12 months')));\n\n"
  },
  {
    "path": "migrations/2025-02-11-131045_ban-remove-content-pm/down.sql",
    "content": "ALTER TABLE private_message\n    DROP COLUMN removed;\n\n"
  },
  {
    "path": "migrations/2025-02-11-131045_ban-remove-content-pm/up.sql",
    "content": "ALTER TABLE private_message\n    ADD COLUMN removed bool NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-02-24-173152_search-alt-text-of-posts/down.sql",
    "content": "DROP INDEX idx_post_trigram;\n\nCREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops);\n\n"
  },
  {
    "path": "migrations/2025-02-24-173152_search-alt-text-of-posts/up.sql",
    "content": "DROP INDEX idx_post_trigram;\n\nCREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops, alt_text gin_trgm_ops);\n\n"
  },
  {
    "path": "migrations/2025-03-07-094522_enable_english_for_all/down.sql",
    "content": "SELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2025-03-07-094522_enable_english_for_all/up.sql",
    "content": "-- enable english for all users on instances with all languages enabled.\n-- Fix for https://github.com/LemmyNet/lemmy/pull/5485\nDO $$\nBEGIN\n    IF (\n        SELECT\n            count(*)\n        FROM\n            site_language\n                INNER JOIN local_site ON site_language.site_id = local_site.site_id) = 184 THEN\n        INSERT INTO local_user_language (local_user_id, language_id)\n        SELECT\n            local_user_id,\n            37\n        FROM\n            local_user_language\n        GROUP BY\n            local_user_id\n        HAVING\n            NOT (37 = ANY (array_agg(language_id)));\n    END IF;\nEND\n$$\n"
  },
  {
    "path": "migrations/2025-04-07-100344_registration-rate-limit/down.sql",
    "content": "ALTER TABLE local_site_rate_limit\n    ALTER register SET DEFAULT 3;\n\nUPDATE\n    local_site_rate_limit\nSET\n    register = 3\nWHERE\n    register = 10;\n\n"
  },
  {
    "path": "migrations/2025-04-07-100344_registration-rate-limit/up.sql",
    "content": "ALTER TABLE local_site_rate_limit\n    ALTER register SET DEFAULT 10;\n\nUPDATE\n    local_site_rate_limit\nSET\n    register = 10\nWHERE\n    register = 3;\n\n"
  },
  {
    "path": "migrations/2025-05-15-154113_missing_post_indexes/down.sql",
    "content": "DROP INDEX idx_post_read_post;\n\nDROP INDEX idx_post_hide_post;\n\nDROP INDEX idx_post_saved_post;\n\n"
  },
  {
    "path": "migrations/2025-05-15-154113_missing_post_indexes/up.sql",
    "content": "CREATE INDEX idx_post_read_post ON post_read (post_id);\n\nCREATE INDEX idx_post_hide_post ON post_hide (post_id);\n\nCREATE INDEX idx_post_saved_post ON post_saved (post_id);\n\n"
  },
  {
    "path": "migrations/2025-07-29-152742_add_indexes_for_aggregates_activity/down.sql",
    "content": "DROP INDEX idx_post_published, idx_post_like_published, idx_comment_like_published;\n\n"
  },
  {
    "path": "migrations/2025-07-29-152742_add_indexes_for_aggregates_activity/up.sql",
    "content": "-- These actually increased query time, but they prevent more postgres workers from being launched, and so should free up locks.\nCREATE INDEX idx_post_published ON post (published);\n\nCREATE INDEX idx_post_like_published ON post_like (published);\n\nCREATE INDEX idx_comment_like_published ON comment_like (published);\n\n"
  },
  {
    "path": "migrations/2025-07-29-152743_post-aggregates-creator-community-indexes/down.sql",
    "content": "DROP INDEX idx_post_aggregates_creator, idx_post_aggregates_community;\n\n"
  },
  {
    "path": "migrations/2025-07-29-152743_post-aggregates-creator-community-indexes/up.sql",
    "content": "CREATE INDEX idx_post_aggregates_creator ON post_aggregates (creator_id);\n\nCREATE INDEX idx_post_aggregates_community ON post_aggregates (community_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000000_enable_private_messages/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN enable_private_messages;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000000_enable_private_messages/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN enable_private_messages boolean DEFAULT TRUE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000002_error_if_code_migrations_needed/down.sql",
    "content": "SELECT\n;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000002_error_if_code_migrations_needed/up.sql",
    "content": "-- https://github.com/LemmyNet/lemmy/pull/5710\n-- Uncomment to test:\n-- ALTER TABLE site DROP COLUMN instance_id; INSERT INTO site (name, public_key) VALUES ('', '');\nDO $$\nBEGIN\n    IF EXISTS (\n        SELECT\n        FROM ((\n                SELECT\n                    id\n                FROM\n                    person\n                WHERE\n                    actor_id LIKE 'http://changeme%'\n                    OR (local\n                        AND public_key = ''))\n            UNION ALL (\n                SELECT\n                    id\n                FROM\n                    community\n                WHERE\n                    actor_id LIKE 'http://changeme%'\n                    OR (local\n                        AND public_key = ''))\n            UNION ALL (\n                SELECT\n                    id\n                FROM\n                    post\n                WHERE\n                    thumbnail_url NOT LIKE 'http%'\n                    OR (local\n                        AND ap_id LIKE 'http://changeme%'))\n            UNION ALL (\n                SELECT\n                    id\n                FROM\n                    comment\n                WHERE\n                    ap_id LIKE 'http://changeme%'\n                    AND local)\n            UNION ALL (\n                SELECT\n                    id\n                FROM\n                    private_message\n                WHERE\n                    ap_id LIKE 'http://changeme%'\n                    AND local)\n            UNION ALL (\n                SELECT\n                    id\n                FROM\n                    site\n                WHERE\n                    public_key = '')) AS broken_rows) THEN\n    RAISE 'Unstable upgrade: Youre on too old a version of lemmy. Upgrade to 0.19.0 first.';\nEND IF;\nEND\n$$;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000003_remove_show_scores_column/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN show_scores boolean NOT NULL DEFAULT TRUE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000003_remove_show_scores_column/up.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN show_scores;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000004_custom_emoji_tagline_changes/down.sql",
    "content": "ALTER TABLE custom_emoji\n    ADD COLUMN local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE;\n\nUPDATE\n    custom_emoji\nSET\n    local_site_id = (\n        SELECT\n            site_id\n        FROM\n            local_site\n        LIMIT 1);\n\nALTER TABLE custom_emoji\n    ALTER COLUMN local_site_id SET NOT NULL;\n\nALTER TABLE tagline\n    ADD COLUMN local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE;\n\nUPDATE\n    tagline\nSET\n    local_site_id = (\n        SELECT\n            site_id\n        FROM\n            local_site\n        LIMIT 1);\n\nALTER TABLE tagline\n    ALTER COLUMN local_site_id SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000004_custom_emoji_tagline_changes/up.sql",
    "content": "ALTER TABLE custom_emoji\n    DROP COLUMN local_site_id;\n\nALTER TABLE tagline\n    DROP COLUMN local_site_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000005_drop-enable-nsfw/down.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN enable_nsfw boolean NOT NULL DEFAULT TRUE;\n\nUPDATE\n    local_site\nSET\n    enable_nsfw = CASE WHEN site.content_warning IS NULL THEN\n        FALSE\n    ELSE\n        TRUE\n    END\nFROM\n    site\nWHERE\n    -- only local site has private key\n    site.private_key IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000005_drop-enable-nsfw/up.sql",
    "content": "-- if site has enable_nsfw, set a default content warning\nUPDATE\n    site\nSET\n    content_warning = CASE WHEN local_site.enable_nsfw THEN\n        'NSFW'\n    ELSE\n        NULL\n    END\nFROM\n    local_site\n    -- only local site has private key\nWHERE\n    private_key IS NOT NULL\n    -- dont overwrite existing content warning\n    AND content_warning IS NOT NULL;\n\nALTER TABLE local_site\n    DROP enable_nsfw;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000006_default_comment_sort_type/down.sql",
    "content": "-- This file should undo anything in `up.sql`\n-- Rename the post sort enum\nALTER TYPE post_sort_type_enum RENAME TO sort_type_enum;\n\n-- Rename the default post sort columns\nALTER TABLE local_user RENAME COLUMN default_post_sort_type TO default_sort_type;\n\nALTER TABLE local_site RENAME COLUMN default_post_sort_type TO default_sort_type;\n\n-- Create the comment sort type enum\nALTER TABLE local_user\n    DROP COLUMN default_comment_sort_type;\n\nALTER TABLE local_site\n    DROP COLUMN default_comment_sort_type;\n\n-- Drop the comment enum\nDROP TYPE comment_sort_type_enum;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000006_default_comment_sort_type/up.sql",
    "content": "-- Rename the post sort enum\nALTER TYPE sort_type_enum RENAME TO post_sort_type_enum;\n\n-- Rename the default post sort columns\nALTER TABLE local_user RENAME COLUMN default_sort_type TO default_post_sort_type;\n\nALTER TABLE local_site RENAME COLUMN default_sort_type TO default_post_sort_type;\n\n-- Create the comment sort type enum\nCREATE TYPE comment_sort_type_enum AS ENUM (\n    'Hot',\n    'Top',\n    'New',\n    'Old',\n    'Controversial'\n);\n\n-- Add the new default comment sort columns to local_user and local_site\nALTER TABLE local_user\n    ADD COLUMN default_comment_sort_type comment_sort_type_enum NOT NULL DEFAULT 'Hot';\n\nALTER TABLE local_site\n    ADD COLUMN default_comment_sort_type comment_sort_type_enum NOT NULL DEFAULT 'Hot';\n\n"
  },
  {
    "path": "migrations/2025-08-01-000007_schedule-post/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN scheduled_publish_time;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000007_schedule-post/up.sql",
    "content": "ALTER TABLE post\n    ADD COLUMN scheduled_publish_time timestamptz;\n\nCREATE INDEX idx_post_scheduled_publish_time ON post (scheduled_publish_time);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000008_create_oauth_provider/down.sql",
    "content": "DROP TABLE oauth_account;\n\nDROP TABLE oauth_provider;\n\nALTER TABLE local_site\n    DROP COLUMN oauth_registration;\n\nALTER TABLE local_user\n    ALTER COLUMN password_encrypted SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000008_create_oauth_provider/up.sql",
    "content": "ALTER TABLE local_user\n    ALTER COLUMN password_encrypted DROP NOT NULL;\n\nCREATE TABLE oauth_provider (\n    id serial PRIMARY KEY,\n    display_name text NOT NULL,\n    issuer text NOT NULL,\n    authorization_endpoint text NOT NULL,\n    token_endpoint text NOT NULL,\n    userinfo_endpoint text NOT NULL,\n    id_claim text NOT NULL,\n    client_id text NOT NULL UNIQUE,\n    client_secret text NOT NULL,\n    scopes text NOT NULL,\n    auto_verify_email boolean DEFAULT TRUE NOT NULL,\n    account_linking_enabled boolean DEFAULT FALSE NOT NULL,\n    enabled boolean DEFAULT TRUE NOT NULL,\n    published timestamp with time zone DEFAULT now() NOT NULL,\n    updated timestamp with time zone\n);\n\nALTER TABLE local_site\n    ADD COLUMN oauth_registration boolean DEFAULT TRUE NOT NULL;\n\nCREATE TABLE oauth_account (\n    local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    oauth_provider_id int REFERENCES oauth_provider ON UPDATE CASCADE ON DELETE RESTRICT NOT NULL,\n    oauth_user_id text NOT NULL,\n    published timestamp with time zone DEFAULT now() NOT NULL,\n    updated timestamp with time zone,\n    UNIQUE (oauth_provider_id, oauth_user_id),\n    PRIMARY KEY (oauth_provider_id, local_user_id)\n);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000009_add_federation_vote_rejection/down.sql",
    "content": "-- Add back the enable_downvotes column\nALTER TABLE local_site\n    ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL;\n\n-- regenerate their values (from post_downvotes alone)\nWITH subquery AS (\n    SELECT\n        post_downvotes,\n        CASE WHEN post_downvotes = 'Disable'::federation_mode_enum THEN\n            FALSE\n        ELSE\n            TRUE\n        END\n    FROM\n        local_site)\nUPDATE\n    local_site\nSET\n    enable_downvotes = subquery.case\nFROM\n    subquery;\n\n-- Drop the new columns\nALTER TABLE local_site\n    DROP COLUMN post_upvotes,\n    DROP COLUMN post_downvotes,\n    DROP COLUMN comment_upvotes,\n    DROP COLUMN comment_downvotes;\n\nDROP TYPE federation_mode_enum;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000009_add_federation_vote_rejection/up.sql",
    "content": "-- This removes the simple enable_downvotes setting, in favor of an\n-- expanded federation mode type for post/comment up/downvotes.\n-- Create the federation mode enum\nCREATE TYPE federation_mode_enum AS ENUM (\n    'All',\n    'Local',\n    'Disable'\n);\n\n-- Add the new columns\nALTER TABLE local_site\n    ADD COLUMN post_upvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL,\n    ADD COLUMN post_downvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL,\n    ADD COLUMN comment_upvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL,\n    ADD COLUMN comment_downvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL;\n\n-- Copy over the enable_downvotes into the post and comment downvote settings\nWITH subquery AS (\n    SELECT\n        enable_downvotes,\n        CASE WHEN enable_downvotes = TRUE THEN\n            'All'::federation_mode_enum\n        ELSE\n            'Disable'::federation_mode_enum\n        END\n    FROM\n        local_site)\nUPDATE\n    local_site\nSET\n    post_downvotes = subquery.case,\n    comment_downvotes = subquery.case\nFROM\n    subquery;\n\n-- Drop the enable_downvotes column\nALTER TABLE local_site\n    DROP COLUMN enable_downvotes;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000010_remove_auto_expand/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN auto_expand boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000010_remove_auto_expand/up.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN auto_expand;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000011_add_short_community_description/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN description;\n\nALTER TABLE community RENAME COLUMN sidebar TO description;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000011_add_short_community_description/up.sql",
    "content": "-- Renaming description to sidebar\nALTER TABLE community RENAME COLUMN description TO sidebar;\n\n-- Adding a short description column\nALTER TABLE community\n    ADD COLUMN description varchar(150);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000012_no-individual-inboxes/down.sql",
    "content": "ALTER TABLE person\n    ADD COLUMN shared_inbox_url varchar(255);\n\nALTER TABLE person RENAME CONSTRAINT person_shared_inbox_url_not_null TO user__inbox_url_not_null;\n\nALTER TABLE community\n    DROP CONSTRAINT community_shared_inbox_url_not_null;\n\nALTER TABLE community\n    ADD COLUMN shared_inbox_url varchar(255),\n    ALTER COLUMN inbox_url SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000012_no-individual-inboxes/up.sql",
    "content": "-- replace value of inbox_url with shared_inbox_url and the drop shared inbox\nUPDATE\n    person\nSET\n    shared_inbox_url = inbox_url\nWHERE\n    shared_inbox_url IS NULL;\n\nALTER TABLE person\n    DROP COLUMN inbox_url,\n    ALTER COLUMN shared_inbox_url SET NOT NULL,\n    ALTER COLUMN shared_inbox_url SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE person RENAME COLUMN shared_inbox_url TO inbox_url;\n\nUPDATE\n    community\nSET\n    shared_inbox_url = inbox_url\nWHERE\n    shared_inbox_url IS NULL;\n\nALTER TABLE community\n    DROP COLUMN inbox_url,\n    ALTER COLUMN shared_inbox_url SET NOT NULL,\n    ALTER COLUMN shared_inbox_url SET DEFAULT generate_unique_changeme ();\n\nALTER TABLE community RENAME COLUMN shared_inbox_url TO inbox_url;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000013_comment-vote-remote-postid/down.sql",
    "content": "ALTER TABLE comment_like\n    ADD COLUMN post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE;\n\nUPDATE\n    comment_like\nSET\n    post_id = comment.post_id\nFROM\n    comment\nWHERE\n    comment_id = comment.id;\n\nALTER TABLE comment_like\n    ALTER COLUMN post_id SET NOT NULL;\n\nCREATE INDEX idx_comment_like_post ON comment_like (post_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000013_comment-vote-remote-postid/up.sql",
    "content": "ALTER TABLE comment_like\n    DROP post_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000014_private-community/down.sql",
    "content": "-- Remove private visibility\nALTER TYPE community_visibility RENAME TO community_visibility__;\n\nCREATE TYPE community_visibility AS enum (\n    'Public',\n    'LocalOnly'\n);\n\nALTER TABLE community\n    ALTER COLUMN visibility DROP DEFAULT;\n\nALTER TABLE community\n    ALTER COLUMN visibility TYPE community_visibility\n    USING visibility::text::community_visibility;\n\nALTER TABLE community\n    ALTER COLUMN visibility SET DEFAULT 'Public';\n\nDROP TYPE community_visibility__;\n\n-- Revert community follower changes\nCREATE OR REPLACE FUNCTION convert_follower_state (s community_follower_state)\n    RETURNS bool\n    LANGUAGE sql\n    AS $$\n    SELECT\n        CASE WHEN s = 'Pending' THEN\n            TRUE\n        ELSE\n            FALSE\n        END\n$$;\n\nALTER TABLE community_follower\n    ALTER COLUMN state TYPE bool\n    USING convert_follower_state (state);\n\nDROP FUNCTION convert_follower_state;\n\nALTER TABLE community_follower\n    ALTER COLUMN state SET DEFAULT FALSE;\n\nALTER TABLE community_follower RENAME COLUMN state TO pending;\n\nDROP TYPE community_follower_state;\n\nALTER TABLE community_follower\n    DROP COLUMN approver_id;\n\nALTER TABLE ONLY local_site\n    ALTER COLUMN federation_signed_fetch SET DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000014_private-community/up.sql",
    "content": "ALTER TYPE community_visibility\n    ADD value 'Private';\n\n-- Change `community_follower.pending` to `state` enum\nCREATE TYPE community_follower_state AS enum (\n    'Accepted',\n    'Pending',\n    'ApprovalRequired'\n);\n\nALTER TABLE community_follower\n    ALTER COLUMN pending DROP DEFAULT;\n\nCREATE OR REPLACE FUNCTION convert_follower_state (b bool)\n    RETURNS community_follower_state\n    LANGUAGE sql\n    IMMUTABLE PARALLEL SAFE\n    AS $$\n    SELECT\n        CASE WHEN b = TRUE THEN\n            'Pending'::community_follower_state\n        ELSE\n            'Accepted'::community_follower_state\n        END\n$$;\n\nALTER TABLE community_follower\n    ALTER COLUMN pending TYPE community_follower_state\n    USING convert_follower_state (pending);\n\nDROP FUNCTION convert_follower_state;\n\nALTER TABLE community_follower RENAME COLUMN pending TO state;\n\n-- Add column for mod who approved the private community follower\n-- Dont use foreign key here, otherwise joining to person table doesnt work easily\nALTER TABLE community_follower\n    ADD COLUMN approver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Enable signed fetch, necessary to fetch content in private communities\nALTER TABLE ONLY local_site\n    ALTER COLUMN federation_signed_fetch SET DEFAULT TRUE;\n\nUPDATE\n    local_site\nSET\n    federation_signed_fetch = TRUE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000015_add_mark_fetched_posts_as_read/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN auto_mark_fetched_posts_as_read;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000015_add_mark_fetched_posts_as_read/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN auto_mark_fetched_posts_as_read boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000016_smoosh-tables-together/down.sql",
    "content": "-- For each new actions table, create tables that are dropped in up.sql, and insert into them\nCREATE TABLE comment_saved (\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT comment_saved_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, comment_id)\n);\n\nINSERT INTO comment_saved (person_id, comment_id, published)\nSELECT\n    person_id,\n    comment_id,\n    saved\nFROM\n    comment_actions\nWHERE\n    saved IS NOT NULL;\n\nCREATE TABLE community_block (\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    PRIMARY KEY (person_id, community_id)\n);\n\nINSERT INTO community_block (person_id, community_id, published)\nSELECT\n    person_id,\n    community_id,\n    blocked\nFROM\n    community_actions\nWHERE\n    blocked IS NOT NULL;\n\nCREATE TABLE community_person_ban (\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz DEFAULT now(),\n    expires timestamptz,\n    CONSTRAINT community_user_ban_published_not_null NOT NULL published,\n    CONSTRAINT community_user_ban_community_id_not_null NOT NULL community_id,\n    CONSTRAINT community_user_ban_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, community_id)\n);\n\nINSERT INTO community_person_ban (community_id, person_id, published, expires)\nSELECT\n    community_id,\n    person_id,\n    received_ban,\n    ban_expires\nFROM\n    community_actions\nWHERE\n    received_ban IS NOT NULL;\n\nCREATE TABLE community_moderator (\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT community_moderator_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, community_id)\n);\n\nINSERT INTO community_moderator (community_id, person_id, published)\nSELECT\n    community_id,\n    person_id,\n    became_moderator\nFROM\n    community_actions\nWHERE\n    became_moderator IS NOT NULL;\n\nCREATE TABLE person_block (\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    target_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    PRIMARY KEY (person_id, target_id)\n);\n\nINSERT INTO person_block (person_id, target_id, published)\nSELECT\n    person_id,\n    target_id,\n    blocked\nFROM\n    person_actions\nWHERE\n    blocked IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS person_post_aggregates (\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read_comments bigint DEFAULT 0 NOT NULL,\n    published timestamptz NOT NULL DEFAULT now(),\n    PRIMARY KEY (person_id, post_id)\n);\n\nINSERT INTO person_post_aggregates (person_id, post_id, read_comments, published)\nSELECT\n    person_id,\n    post_id,\n    read_comments_amount,\n    read_comments\nFROM\n    post_actions\nWHERE\n    read_comments IS NOT NULL;\n\nCREATE TABLE post_hide (\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    PRIMARY KEY (person_id, post_id)\n);\n\nINSERT INTO post_hide (post_id, person_id, published)\nSELECT\n    post_id,\n    person_id,\n    hidden\nFROM\n    post_actions\nWHERE\n    hidden IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS post_like (\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    score smallint NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT post_like_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, post_id)\n);\n\nINSERT INTO post_like (post_id, person_id, score, published)\nSELECT\n    post_id,\n    person_id,\n    CASE WHEN vote_is_upvote THEN\n        1\n    ELSE\n        -1\n    END,\n    liked\nFROM\n    post_actions\nWHERE\n    liked IS NOT NULL;\n\nCREATE TABLE post_saved (\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT post_saved_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, post_id)\n);\n\nINSERT INTO post_saved (post_id, person_id, published)\nSELECT\n    post_id,\n    person_id,\n    saved\nFROM\n    post_actions\nWHERE\n    saved IS NOT NULL;\n\n-- Do the opposite of the `ALTER TABLE` commands in up.sql\nDELETE FROM comment_actions\nWHERE liked IS NULL;\n\nDELETE FROM community_actions\nWHERE followed IS NULL;\n\nDELETE FROM instance_actions\nWHERE blocked IS NULL;\n\nDELETE FROM person_actions\nWHERE followed IS NULL;\n\nDELETE FROM post_actions\nWHERE read IS NULL;\n\nCREATE TABLE IF NOT EXISTS comment_like (\n    comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    score smallint NOT NULL,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT comment_like_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, comment_id)\n);\n\nINSERT INTO comment_like (comment_id, person_id, score, published)\nSELECT\n    comment_id,\n    person_id,\n    CASE WHEN vote_is_upvote THEN\n        1\n    ELSE\n        -1\n    END,\n    liked\nFROM\n    comment_actions\nWHERE\n    liked IS NOT NULL;\n\nALTER TABLE community_actions RENAME TO community_follower;\n\nALTER TABLE instance_actions RENAME TO instance_block;\n\nALTER TABLE person_actions RENAME TO person_follower;\n\nCREATE TABLE IF NOT EXISTS post_read (\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz DEFAULT now() NOT NULL,\n    CONSTRAINT post_read_user_id_not_null NOT NULL person_id,\n    PRIMARY KEY (person_id, post_id)\n);\n\nINSERT INTO post_read (post_id, person_id, published)\nSELECT\n    post_id,\n    person_id,\n    read\nFROM\n    post_actions\nWHERE\n    read IS NOT NULL;\n\nALTER TABLE community_follower RENAME COLUMN followed TO published;\n\nALTER TABLE community_follower RENAME COLUMN follow_state TO state;\n\nALTER TABLE community_follower RENAME COLUMN follow_approver_id TO approver_id;\n\nALTER TABLE instance_block RENAME COLUMN blocked TO published;\n\nALTER TABLE person_follower RENAME COLUMN person_id TO follower_id;\n\nALTER TABLE person_follower RENAME COLUMN target_id TO person_id;\n\nALTER TABLE person_follower RENAME COLUMN followed TO published;\n\nALTER TABLE person_follower RENAME COLUMN follow_pending TO pending;\n\nALTER TABLE community_follower\n    DROP CONSTRAINT community_actions_pkey,\n    DROP CONSTRAINT community_actions_check_followed,\n    DROP CONSTRAINT community_actions_check_received_ban,\n    DROP CONSTRAINT community_actions_community_id_not_null,\n    ADD CONSTRAINT community_actions_pkey PRIMARY KEY (person_id, community_id),\n    ALTER COLUMN community_id SET NOT NULL,\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN published SET DEFAULT now(),\n    ADD CONSTRAINT community_follower_pending_not_null NOT NULL state,\n    DROP COLUMN blocked,\n    DROP COLUMN became_moderator,\n    DROP COLUMN received_ban,\n    DROP COLUMN ban_expires;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_actions_person_id_not_null TO community_follower_user_id_not_null;\n\nALTER TABLE instance_block\n    DROP CONSTRAINT instance_actions_pkey,\n    DROP CONSTRAINT instance_actions_instance_id_not_null,\n    DROP CONSTRAINT instance_actions_person_id_not_null,\n    ADD CONSTRAINT instance_actions_pkey PRIMARY KEY (person_id, instance_id),\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN instance_id SET NOT NULL,\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN published SET DEFAULT now();\n\nALTER TABLE person_follower\n    DROP CONSTRAINT person_actions_pkey,\n    DROP CONSTRAINT person_actions_check_followed,\n    DROP CONSTRAINT person_actions_person_id_not_null,\n    DROP CONSTRAINT person_actions_target_id_not_null,\n    ADD CONSTRAINT person_actions_pkey PRIMARY KEY (follower_id, person_id),\n    ALTER COLUMN follower_id SET NOT NULL,\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN published SET DEFAULT now(),\n    ALTER COLUMN pending SET NOT NULL,\n    DROP COLUMN blocked;\n\n-- Rename associated stuff\nALTER INDEX community_actions_pkey RENAME TO community_follower_pkey;\n\nALTER INDEX idx_community_actions_community RENAME TO idx_community_follower_community;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_actions_community_id_fkey TO community_follower_community_id_fkey;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_actions_person_id_fkey TO community_follower_person_id_fkey;\n\nALTER TABLE community_follower RENAME CONSTRAINT community_actions_follow_approver_id_fkey TO community_follower_approver_id_fkey;\n\nALTER INDEX instance_actions_pkey RENAME TO instance_block_pkey;\n\nALTER TABLE instance_block RENAME CONSTRAINT instance_actions_instance_id_fkey TO instance_block_instance_id_fkey;\n\nALTER TABLE instance_block RENAME CONSTRAINT instance_actions_person_id_fkey TO instance_block_person_id_fkey;\n\nALTER INDEX person_actions_pkey RENAME TO person_follower_pkey;\n\nALTER TABLE person_follower RENAME CONSTRAINT person_actions_target_id_fkey TO person_follower_person_id_fkey;\n\nALTER TABLE person_follower RENAME CONSTRAINT person_actions_person_id_fkey TO person_follower_follower_id_fkey;\n\n-- Rename idx_community_actions_followed and remove filter\nCREATE INDEX idx_community_follower_published ON community_follower (published);\n\nDROP INDEX idx_community_actions_followed;\n\n-- Move indexes back to their original tables\nCREATE INDEX idx_comment_saved_comment ON comment_saved (comment_id);\n\nCREATE INDEX idx_comment_saved_person ON comment_saved (person_id);\n\nCREATE INDEX idx_community_block_community ON community_block (community_id);\n\nCREATE INDEX idx_community_moderator_community ON community_moderator (community_id);\n\nCREATE INDEX idx_community_moderator_published ON community_moderator (published);\n\nCREATE INDEX idx_person_block_person ON person_block (person_id);\n\nCREATE INDEX idx_person_block_target ON person_block (target_id);\n\nCREATE INDEX IF NOT EXISTS idx_person_post_aggregates_person ON person_post_aggregates (person_id);\n\nCREATE INDEX IF NOT EXISTS idx_person_post_aggregates_post ON person_post_aggregates (post_id);\n\nCREATE INDEX IF NOT EXISTS idx_post_like_post ON post_like (post_id);\n\nCREATE INDEX idx_comment_like_comment ON comment_like (comment_id);\n\nCREATE INDEX idx_post_hide_post ON post_hide (post_id);\n\nCREATE INDEX idx_post_read_post ON post_read (post_id);\n\nCREATE INDEX idx_post_saved_post ON post_saved (post_id);\n\nCREATE INDEX idx_post_like_published ON post_like (published);\n\nCREATE INDEX idx_comment_like_published ON comment_like (published);\n\nDROP INDEX idx_person_actions_person, idx_person_actions_target, idx_post_actions_person, idx_post_actions_post;\n\n-- Drop `NOT NULL` indexes of columns that still exist\nDROP INDEX idx_comment_actions_liked_not_null, idx_community_actions_followed_not_null, idx_person_actions_followed_not_null, idx_post_actions_read_not_null, idx_instance_actions_blocked_not_null, idx_comment_actions_person, idx_community_actions_person, idx_instance_actions_instance, idx_instance_actions_person;\n\n-- Drop statistics of columns that still exist\nDROP statistics comment_actions_liked_stat, community_actions_followed_stat, person_actions_followed_stat;\n\nDROP TABLE comment_actions, post_actions;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000016_smoosh-tables-together/up.sql",
    "content": "-- Consolidates  all the old tables like post_read, post_like, into post_actions, to reduce joins and increase performance.\n-- This creates the tables:\n-- post_actions, comment_actions, community_actions, instance_actions, and person_actions.\n--\n-- comment_actions\nCREATE TABLE comment_actions AS\nSELECT\n    max(liked) AS liked,\n    max(saved) AS saved,\n    person_id,\n    comment_id,\n    max(like_score) = 1 AS vote_is_upvote -- `null = 1` returns null\nFROM (\n    SELECT\n        person_id,\n        comment_id,\n        score AS like_score,\n        published AS liked,\n        NULL::timestamptz AS saved\n    FROM\n        comment_like\n    UNION ALL\n    SELECT\n        person_id,\n        comment_id,\n        NULL::int,\n        NULL::timestamptz,\n        published\n    FROM\n        comment_saved)\nGROUP BY\n    person_id,\n    comment_id;\n\n-- Drop the tables\nDROP TABLE comment_saved, comment_like;\n\n-- Add the constraints\nALTER TABLE comment_actions\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN comment_id SET NOT NULL,\n    ADD PRIMARY KEY (person_id, comment_id),\n    ADD CONSTRAINT comment_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT comment_actions_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT comment_actions_check_liked CHECK (((liked IS NULL) = (vote_is_upvote IS NULL)));\n\n-- Create new indexes, with `OR` being used to allow `IS NOT NULL` filters in queries to use either column in\n-- a group (e.g. `liked IS NOT NULL` and `vote_is_upvote IS NOT NULL` both work)\nCREATE INDEX idx_comment_actions_person ON comment_actions (person_id);\n\nCREATE INDEX idx_comment_actions_comment ON comment_actions (comment_id);\n\nCREATE INDEX idx_comment_actions_liked_not_null ON comment_actions (person_id, comment_id)\nWHERE\n    liked IS NOT NULL OR vote_is_upvote IS NOT NULL;\n\nCREATE INDEX idx_comment_actions_saved_not_null ON comment_actions (person_id, comment_id)\nWHERE\n    saved IS NOT NULL;\n\n-- Here's an SO link on merges, but this turned out to be slower than a\n-- disabled triggers + disabled primary key + full union select + insert with group by\n-- SO link on merges: https://stackoverflow.com/a/74066614/1655478\nCREATE TABLE post_actions AS\nSELECT\n    max(read) AS read,\n    max(read_comments) AS read_comments,\n    max(saved) AS saved,\n    max(liked) AS liked,\n    max(hidden) AS hidden,\n    person_id,\n    post_id,\n    cast(max(read_comments_amount) AS int) AS read_comments_amount,\n    max(like_score) = 1 AS vote_is_upvote -- `null = 1` returns null\nFROM (\n    SELECT\n        person_id,\n        post_id,\n        published AS read,\n        NULL::timestamptz AS read_comments,\n        NULL::int AS read_comments_amount,\n        NULL::timestamptz AS saved,\n        NULL::timestamptz AS liked,\n        NULL::int AS like_score,\n        NULL::timestamptz AS hidden\n    FROM\n        post_read\n    UNION ALL\n    SELECT\n        person_id,\n        post_id,\n        NULL::timestamptz,\n        published,\n        read_comments,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::int,\n        NULL::timestamptz\n    FROM\n        person_post_aggregates\n    UNION ALL\n    SELECT\n        person_id,\n        post_id,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::int,\n        published,\n        NULL::timestamptz,\n        NULL::int,\n        NULL::timestamptz\n    FROM\n        post_saved\n    UNION ALL\n    SELECT\n        person_id,\n        post_id,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::int,\n        NULL::timestamptz,\n        published,\n        score,\n        NULL::timestamptz\n    FROM\n        post_like\n    UNION ALL\n    SELECT\n        person_id,\n        post_id,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::int,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::int,\n        published\n    FROM\n        post_hide)\nGROUP BY\n    person_id,\n    post_id;\n\n-- Drop the tables\nDROP TABLE post_read, person_post_aggregates, post_like, post_saved, post_hide;\n\n-- Add the constraints\nALTER TABLE post_actions\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN post_id SET NOT NULL,\n    ADD PRIMARY KEY (person_id, post_id),\n    ADD CONSTRAINT post_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT post_actions_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT post_actions_check_liked CHECK (((liked IS NULL) = (vote_is_upvote IS NULL))),\n    ADD CONSTRAINT post_actions_check_read_comments CHECK (((read_comments IS NULL) = (read_comments_amount IS NULL)));\n\n-- Create indexes\nCREATE INDEX idx_post_actions_person ON post_actions (person_id);\n\nCREATE INDEX idx_post_actions_post ON post_actions (post_id);\n\nCREATE INDEX idx_post_actions_read_not_null ON post_actions (person_id, post_id)\nWHERE\n    read IS NOT NULL;\n\nCREATE INDEX idx_post_actions_read_comments_not_null ON post_actions (person_id, post_id)\nWHERE\n    read_comments IS NOT NULL OR read_comments_amount IS NOT NULL;\n\nCREATE INDEX idx_post_actions_saved_not_null ON post_actions (person_id, post_id)\nWHERE\n    saved IS NOT NULL;\n\nCREATE INDEX idx_post_actions_liked_not_null ON post_actions (person_id, post_id)\nWHERE\n    liked IS NOT NULL OR vote_is_upvote IS NOT NULL;\n\nCREATE INDEX idx_post_actions_hidden_not_null ON post_actions (person_id, post_id)\nWHERE\n    hidden IS NOT NULL;\n\n-- community_actions\nCREATE TABLE community_actions AS\nSELECT\n    max(followed) AS followed,\n    max(blocked) AS blocked,\n    max(became_moderator) AS became_moderator,\n    max(received_ban) AS received_ban,\n    max(ban_expires) AS ban_expires,\n    person_id,\n    community_id,\n    max(follow_state) AS follow_state,\n    max(follow_approver_id) AS follow_approver_id\nFROM (\n    SELECT\n        person_id,\n        community_id,\n        published AS followed,\n        state AS follow_state,\n        approver_id AS follow_approver_id,\n        NULL::timestamptz AS blocked,\n        NULL::timestamptz AS became_moderator,\n        NULL::timestamptz AS received_ban,\n        NULL::timestamptz AS ban_expires\n    FROM\n        community_follower\n    UNION ALL\n    SELECT\n        person_id,\n        community_id,\n        NULL::timestamptz,\n        NULL::community_follower_state,\n        NULL::int,\n        published,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        NULL::timestamptz\n    FROM\n        community_block\n    UNION ALL\n    SELECT\n        person_id,\n        community_id,\n        NULL::timestamptz,\n        NULL::community_follower_state,\n        NULL::int,\n        NULL::timestamptz,\n        published,\n        NULL::timestamptz,\n        NULL::timestamptz\n    FROM\n        community_moderator\n    UNION ALL\n    SELECT\n        person_id,\n        community_id,\n        NULL::timestamptz,\n        NULL::community_follower_state,\n        NULL::int,\n        NULL::timestamptz,\n        NULL::timestamptz,\n        published,\n        expires\n    FROM\n        community_person_ban)\nGROUP BY\n    person_id,\n    community_id;\n\n-- Drop the old tables\nDROP TABLE community_follower, community_block, community_moderator, community_person_ban;\n\n-- Add the constraints\nALTER TABLE community_actions\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN community_id SET NOT NULL,\n    ADD PRIMARY KEY (person_id, community_id),\n    ADD CONSTRAINT community_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT community_actions_follow_approver_id_fkey FOREIGN KEY (follow_approver_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT community_actions_community_id_fkey FOREIGN KEY (community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT community_actions_check_followed CHECK ((((followed IS NULL) = (follow_state IS NULL)) AND (NOT ((followed IS NULL) AND (follow_approver_id IS NOT NULL))))),\n    ADD CONSTRAINT community_actions_check_received_ban CHECK ((NOT ((received_ban IS NULL) AND (ban_expires IS NOT NULL))));\n\n-- Create indexes\nCREATE INDEX idx_community_actions_person ON community_actions (person_id);\n\nCREATE INDEX idx_community_actions_community ON community_actions (community_id);\n\nCREATE INDEX idx_community_actions_followed ON community_actions (followed)\nWHERE\n    followed IS NOT NULL;\n\nCREATE INDEX idx_community_actions_followed_not_null ON community_actions (person_id, community_id)\nWHERE\n    followed IS NOT NULL OR follow_state IS NOT NULL;\n\nCREATE INDEX idx_community_actions_became_moderator ON community_actions (became_moderator)\nWHERE\n    became_moderator IS NOT NULL;\n\nCREATE INDEX idx_community_actions_became_moderator_not_null ON community_actions (person_id, community_id)\nWHERE\n    became_moderator IS NOT NULL;\n\nCREATE INDEX idx_community_actions_blocked_not_null ON community_actions (person_id, community_id)\nWHERE\n    blocked IS NOT NULL;\n\nCREATE INDEX idx_community_actions_received_ban_not_null ON community_actions (person_id, community_id)\nWHERE\n    received_ban IS NOT NULL;\n\n-- instance_actions\nCREATE TABLE instance_actions AS\nSELECT\n    published AS blocked,\n    person_id,\n    instance_id\nFROM\n    instance_block;\n\nDROP TABLE instance_block;\n\n-- Add the constraints\nALTER TABLE instance_actions\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN instance_id SET NOT NULL,\n    ADD PRIMARY KEY (person_id, instance_id),\n    ADD CONSTRAINT instance_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT instance_actions_instance_id_fkey FOREIGN KEY (instance_id) REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- This index is currently redundant because instance_actions only has 1 action type, but inconsistency\n-- with other tables would make it harder to do everything correctly when adding another action type\nCREATE INDEX idx_instance_actions_person ON instance_actions (person_id);\n\nCREATE INDEX idx_instance_actions_instance ON instance_actions (instance_id);\n\nCREATE INDEX idx_instance_actions_blocked_not_null ON instance_actions (person_id, instance_id)\nWHERE\n    blocked IS NOT NULL;\n\n-- person_actions\nCREATE TABLE person_actions AS\nSELECT\n    max(followed) AS followed,\n    max(blocked) AS blocked,\n    person_id,\n    target_id,\n    cast(max(follow_pending) AS boolean) AS follow_pending\nFROM (\n    SELECT\n        follower_id AS person_id,\n        person_id AS target_id,\n        published AS followed,\n        pending::int AS follow_pending,\n        NULL::timestamptz AS blocked\n    FROM\n        person_follower\n    UNION ALL\n    SELECT\n        person_id,\n        target_id,\n        NULL::timestamptz,\n        NULL::int,\n        published\n    FROM\n        person_block)\nGROUP BY\n    person_id,\n    target_id;\n\n-- add primary key, foreign keys, and not nulls\nALTER TABLE person_actions\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN target_id SET NOT NULL,\n    ADD PRIMARY KEY (person_id, target_id),\n    ADD CONSTRAINT person_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_actions_target_id_fkey FOREIGN KEY (target_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_actions_check_followed CHECK (((followed IS NULL) = (follow_pending IS NULL)));\n\nDROP TABLE person_block, person_follower;\n\nCREATE INDEX idx_person_actions_person ON person_actions (person_id);\n\nCREATE INDEX idx_person_actions_target ON person_actions (target_id);\n\nCREATE INDEX idx_person_actions_followed_not_null ON person_actions (person_id, target_id)\nWHERE\n    followed IS NOT NULL OR follow_pending IS NOT NULL;\n\nCREATE INDEX idx_person_actions_blocked_not_null ON person_actions (person_id, target_id)\nWHERE\n    blocked IS NOT NULL;\n\n-- Create new statistics for more accurate estimations of how much of an index will be read (e.g. for\n-- `(liked, like_score)`, the query planner might othewise assume that `(TRUE, FALSE)` and `(TRUE, TRUE)`\n-- are equally likely when only `(TRUE, TRUE)` is possible, which would make it severely underestimate\n-- the efficiency of using the index)\nCREATE statistics comment_actions_liked_stat ON (liked IS NULL), (vote_is_upvote IS NULL)\nFROM comment_actions;\n\nCREATE statistics community_actions_followed_stat ON (followed IS NULL), (follow_state IS NULL)\nFROM community_actions;\n\nCREATE statistics person_actions_followed_stat ON (followed IS NULL), (follow_pending IS NULL)\nFROM person_actions;\n\nCREATE statistics post_actions_read_comments_stat ON (read_comments IS NULL), (read_comments_amount IS NULL)\nFROM post_actions;\n\nCREATE statistics post_actions_liked_stat ON (liked IS NULL), (vote_is_upvote IS NULL), (post_id IS NULL)\nFROM post_actions;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000017_forbid_diesel_cli/down.sql",
    "content": "DROP FUNCTION forbid_diesel_cli CASCADE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000017_forbid_diesel_cli/up.sql",
    "content": "-- This trigger prevents using the Diesel CLI to run or revert migrations, so the custom migration runner\n-- can drop and recreate the `r` schema for new migrations.\n--\n-- This migration being seperate from the next migration (created in the same PR) guarantees that the\n-- Diesel CLI will fail to bring the number of pending migrations to 0, which is one of the conditions\n-- required to skip running replaceable_schema.\n--\n-- If the Diesel CLI could run or revert migrations, this scenario would be possible:\n--\n-- Run `diesel migration redo` when the newest migration has a new table with triggers. End up with triggers\n-- being dropped and not replaced because triggers are created outside of up.sql. The custom migration runner\n-- sees that there are no pending migrations and the value in the `previously_run_sql` trigger is correct, so\n-- it doesn't rebuild the `r` schema. There is now incorrect behavior but no error messages.\nCREATE FUNCTION forbid_diesel_cli ()\n    RETURNS TRIGGER\n    LANGUAGE plpgsql\n    AS $$\nBEGIN\n    IF NOT EXISTS (\n        SELECT\n        FROM\n            pg_locks\n        WHERE (locktype, pid, objid) = ('advisory', pg_backend_pid(), 0)) THEN\nRAISE 'migrations must be managed using lemmy_server instead of diesel CLI';\nEND IF;\n    RETURN NULL;\nEND;\n$$;\n\nCREATE TRIGGER forbid_diesel_cli\n    BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON __diesel_schema_migrations\n    EXECUTE FUNCTION forbid_diesel_cli ();\n\n"
  },
  {
    "path": "migrations/2025-08-01-000018_custom_migration_runner/down.sql",
    "content": "DROP TABLE previously_run_sql;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000018_custom_migration_runner/up.sql",
    "content": "DROP SCHEMA IF EXISTS r CASCADE;\n\nCREATE TABLE previously_run_sql (\n    -- For compatibility with Diesel\n    id boolean PRIMARY KEY,\n    -- Too big to be used as primary key\n    content text NOT NULL\n);\n\nINSERT INTO previously_run_sql (id, content)\n    VALUES (TRUE, '');\n\n"
  },
  {
    "path": "migrations/2025-08-01-000019_add_report_count/down.sql",
    "content": "ALTER TABLE post_aggregates\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count;\n\nALTER TABLE comment_aggregates\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000019_add_report_count/up.sql",
    "content": "-- Adding report_count and unresolved_report_count\n-- to the post and comment aggregate tables\nALTER TABLE post_aggregates\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n-- Disable the triggers temporarily\nALTER TABLE post_aggregates DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'post_aggregates');\n\n-- Update the historical counts\n-- Posts\nUPDATE\n    post_aggregates AS a\nSET\n    report_count = cnt.count\nFROM (\n    SELECT\n        post_id,\n        count(*) AS count\n    FROM\n        post_report\n    GROUP BY\n        post_id) cnt\nWHERE\n    a.post_id = cnt.post_id;\n\n-- The unresolved\nUPDATE\n    post_aggregates AS a\nSET\n    unresolved_report_count = cnt.count\nFROM (\n    SELECT\n        post_id,\n        count(*) AS count\n    FROM\n        post_report\n    WHERE\n        resolved = 'f'\n    GROUP BY\n        post_id) cnt\nWHERE\n    a.post_id = cnt.post_id;\n\n-- Re-enable triggers after upserts\nALTER TABLE post_aggregates ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'post_aggregates');\n\n-- reindex\nREINDEX TABLE post_aggregates;\n\nALTER TABLE comment_aggregates\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n-- Disable the triggers temporarily\nALTER TABLE comment_aggregates DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'comment_aggregates');\n\n-- Comments\nUPDATE\n    comment_aggregates AS a\nSET\n    report_count = cnt.count\nFROM (\n    SELECT\n        comment_id,\n        count(*) AS count\n    FROM\n        comment_report\n    GROUP BY\n        comment_id) cnt\nWHERE\n    a.comment_id = cnt.comment_id;\n\n-- The unresolved\nUPDATE\n    comment_aggregates AS a\nSET\n    unresolved_report_count = cnt.count\nFROM (\n    SELECT\n        comment_id,\n        count(*) AS count\n    FROM\n        comment_report\n    WHERE\n        resolved = 'f'\n    GROUP BY\n        comment_id) cnt\nWHERE\n    a.comment_id = cnt.comment_id;\n\n-- Re-enable triggers after upserts\nALTER TABLE comment_aggregates ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'comment_aggregates');\n\n-- reindex\nREINDEX TABLE comment_aggregates;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000020_oauth_pkce/down.sql",
    "content": "ALTER TABLE oauth_provider\n    DROP COLUMN use_pkce;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000020_oauth_pkce/up.sql",
    "content": "ALTER TABLE oauth_provider\n    ADD COLUMN use_pkce boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000021_add_blurhash_to_image_details/down.sql",
    "content": "ALTER TABLE image_details\n    DROP COLUMN blurhash;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000021_add_blurhash_to_image_details/up.sql",
    "content": "-- Add a blurhash column for image_details\nALTER TABLE image_details\n-- Supposed to be 20-30 chars, use 50 to be safe\n    ADD COLUMN blurhash varchar(50);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000022_instance-block-mod-log/down.sql",
    "content": "ALTER TABLE federation_blocklist\n    DROP expires;\n\nDROP TABLE admin_block_instance;\n\nDROP TABLE admin_allow_instance;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000022_instance-block-mod-log/up.sql",
    "content": "ALTER TABLE federation_blocklist\n    ADD COLUMN expires timestamptz;\n\nCREATE TABLE admin_block_instance (\n    id serial PRIMARY KEY,\n    instance_id int NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_person_id int NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    blocked bool NOT NULL,\n    reason text,\n    expires timestamptz,\n    when_ timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE admin_allow_instance (\n    id serial PRIMARY KEY,\n    instance_id int NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_person_id int NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    allowed bool NOT NULL,\n    reason text,\n    when_ timestamptz NOT NULL DEFAULT now()\n);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000023_add_report_combined_table/down.sql",
    "content": "DROP TABLE report_combined;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000023_add_report_combined_table/up.sql",
    "content": "-- Creates combined tables for\n-- Reports: (comment, post, and private_message)\nCREATE TABLE report_combined (\n    id serial PRIMARY KEY,\n    published timestamptz NOT NULL,\n    post_report_id int UNIQUE REFERENCES post_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    comment_report_id int UNIQUE REFERENCES comment_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    private_message_report_id int UNIQUE REFERENCES private_message_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    -- Make sure only one of the columns is not null\n    CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id) = 1)\n);\n\nCREATE INDEX idx_report_combined_published ON report_combined (published DESC, id DESC);\n\nCREATE INDEX idx_report_combined_published_asc ON report_combined (reverse_timestamp_sort (published) DESC, id DESC);\n\n-- Updating the history\nINSERT INTO report_combined (published, post_report_id, comment_report_id, private_message_report_id)\nSELECT\n    published,\n    id,\n    NULL::int,\n    NULL::int\nFROM\n    post_report\nUNION ALL\nSELECT\n    published,\n    NULL::int,\n    id,\n    NULL::int\nFROM\n    comment_report\nUNION ALL\nSELECT\n    published,\n    NULL::int,\n    NULL::int,\n    id\nFROM\n    private_message_report;\n\nALTER TABLE report_combined\n    ALTER CONSTRAINT report_combined_post_report_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT report_combined_comment_report_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT report_combined_private_message_report_id_fkey NOT DEFERRABLE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000024_add_person_content_combined_table/down.sql",
    "content": "DROP TABLE person_content_combined, person_saved_combined;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000024_add_person_content_combined_table/up.sql",
    "content": "-- Creates combined tables for\n-- person_content: (comment, post)\n-- person_saved: (comment, post)\nCREATE TABLE person_content_combined AS\nSELECT\n    published,\n    creator_id,\n    id AS post_id,\n    NULL::int AS comment_id\nFROM\n    post\nUNION ALL\nSELECT\n    published,\n    creator_id,\n    NULL::int,\n    id\nFROM\n    comment;\n\n-- Add the constraints\nALTER TABLE person_content_combined\n    ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN creator_id SET NOT NULL,\n    ADD CONSTRAINT person_content_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_content_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_content_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD UNIQUE (post_id),\n    ADD UNIQUE (comment_id),\n    ADD CONSTRAINT person_content_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1);\n\nCREATE INDEX idx_person_content_combined_creator_published ON person_content_combined (creator_id, published DESC, id DESC);\n\n-- This is for local_users only\nCREATE TABLE person_saved_combined AS\nSELECT\n    pa.saved AS saved,\n    pa.person_id AS person_id,\n    p.creator_id AS creator_id,\n    pa.post_id AS post_id,\n    NULL::int AS comment_id\nFROM\n    post_actions pa,\n    local_user lu,\n    post p\nWHERE\n    pa.person_id = lu.person_id\n    AND pa.saved IS NOT NULL\n    AND pa.post_id = p.id\nUNION ALL\nSELECT\n    ca.saved,\n    ca.person_id,\n    c.creator_id AS creator_id,\n    NULL::int,\n    ca.comment_id\nFROM\n    comment_actions ca,\n    local_user lu,\n    comment c\nWHERE\n    ca.person_id = lu.person_id\n    AND ca.saved IS NOT NULL\n    AND ca.comment_id = c.id;\n\n-- Add the constraints\nALTER TABLE person_saved_combined\n    ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n    ALTER COLUMN saved SET NOT NULL,\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN creator_id SET NOT NULL,\n    ADD CONSTRAINT person_saved_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_saved_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_saved_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_saved_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_saved_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1),\n    ADD UNIQUE (person_id, post_id),\n    ADD UNIQUE (person_id, comment_id);\n\nCREATE INDEX idx_person_saved_combined_person_saved ON person_saved_combined (person_id, saved DESC, id DESC);\n\nCREATE INDEX idx_person_saved_combined_person ON person_saved_combined (person_id);\n\nCREATE INDEX idx_person_saved_combined_creator ON person_saved_combined (creator_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000025_add_modlog_combined_table/down.sql",
    "content": "DROP TABLE modlog_combined;\n\n-- Rename the columns back to when_\nALTER TABLE admin_allow_instance RENAME COLUMN published TO when_;\n\nALTER TABLE admin_block_instance RENAME COLUMN published TO when_;\n\nALTER TABLE admin_purge_comment RENAME COLUMN published TO when_;\n\nALTER TABLE admin_purge_community RENAME COLUMN published TO when_;\n\nALTER TABLE admin_purge_person RENAME COLUMN published TO when_;\n\nALTER TABLE admin_purge_post RENAME COLUMN published TO when_;\n\nALTER TABLE mod_add RENAME COLUMN published TO when_;\n\nALTER TABLE mod_add_community RENAME COLUMN published TO when_;\n\nALTER TABLE mod_ban RENAME COLUMN published TO when_;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN published TO when_;\n\nALTER TABLE mod_feature_post RENAME COLUMN published TO when_;\n\nALTER TABLE mod_hide_community RENAME COLUMN published TO when_;\n\nALTER TABLE mod_lock_post RENAME COLUMN published TO when_;\n\nALTER TABLE mod_remove_comment RENAME COLUMN published TO when_;\n\nALTER TABLE mod_remove_community RENAME COLUMN published TO when_;\n\nALTER TABLE mod_remove_post RENAME COLUMN published TO when_;\n\nALTER TABLE mod_transfer_community RENAME COLUMN published TO when_;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000025_add_modlog_combined_table/up.sql",
    "content": "-- First, rename all the when_ columns on the modlog to published\nALTER TABLE admin_allow_instance RENAME COLUMN when_ TO published;\n\nALTER TABLE admin_block_instance RENAME COLUMN when_ TO published;\n\nALTER TABLE admin_purge_comment RENAME COLUMN when_ TO published;\n\nALTER TABLE admin_purge_community RENAME COLUMN when_ TO published;\n\nALTER TABLE admin_purge_person RENAME COLUMN when_ TO published;\n\nALTER TABLE admin_purge_post RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_add RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_add_community RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_ban RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_feature_post RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_hide_community RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_lock_post RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_remove_comment RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_remove_community RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_remove_post RENAME COLUMN when_ TO published;\n\nALTER TABLE mod_transfer_community RENAME COLUMN when_ TO published;\n\n-- Creates combined tables for\n-- modlog: (17 tables)\n-- admin_allow_instance\n-- admin_block_instance\n-- admin_purge_comment\n-- admin_purge_community\n-- admin_purge_person\n-- admin_purge_post\n-- mod_add\n-- mod_add_community\n-- mod_ban\n-- mod_ban_from_community\n-- mod_feature_post\n-- mod_hide_community\n-- mod_lock_post\n-- mod_remove_comment\n-- mod_remove_community\n-- mod_remove_post\n-- mod_transfer_community\nCREATE TABLE modlog_combined (\n    id serial PRIMARY KEY,\n    published timestamptz NOT NULL,\n    admin_allow_instance_id int UNIQUE REFERENCES admin_allow_instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    admin_block_instance_id int UNIQUE REFERENCES admin_block_instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    admin_purge_comment_id int UNIQUE REFERENCES admin_purge_comment ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    admin_purge_community_id int UNIQUE REFERENCES admin_purge_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    admin_purge_person_id int UNIQUE REFERENCES admin_purge_person ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    admin_purge_post_id int UNIQUE REFERENCES admin_purge_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_add_id int UNIQUE REFERENCES mod_add ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_add_community_id int UNIQUE REFERENCES mod_add_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_ban_id int UNIQUE REFERENCES mod_ban ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_ban_from_community_id int UNIQUE REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_feature_post_id int UNIQUE REFERENCES mod_feature_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_hide_community_id int UNIQUE REFERENCES mod_hide_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_lock_post_id int UNIQUE REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_remove_comment_id int UNIQUE REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_remove_community_id int UNIQUE REFERENCES mod_remove_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_remove_post_id int UNIQUE REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    mod_transfer_community_id int UNIQUE REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE\n);\n\n-- Updating the history\n-- Not doing a union all here, because there's way too many null columns\nINSERT INTO modlog_combined (published, admin_allow_instance_id)\nSELECT\n    published,\n    id\nFROM\n    admin_allow_instance;\n\nINSERT INTO modlog_combined (published, admin_block_instance_id)\nSELECT\n    published,\n    id\nFROM\n    admin_block_instance;\n\nINSERT INTO modlog_combined (published, admin_purge_comment_id)\nSELECT\n    published,\n    id\nFROM\n    admin_purge_comment;\n\nINSERT INTO modlog_combined (published, admin_purge_community_id)\nSELECT\n    published,\n    id\nFROM\n    admin_purge_community;\n\nINSERT INTO modlog_combined (published, admin_purge_person_id)\nSELECT\n    published,\n    id\nFROM\n    admin_purge_person;\n\nINSERT INTO modlog_combined (published, admin_purge_post_id)\nSELECT\n    published,\n    id\nFROM\n    admin_purge_post;\n\nINSERT INTO modlog_combined (published, mod_add_id)\nSELECT\n    published,\n    id\nFROM\n    mod_add;\n\nINSERT INTO modlog_combined (published, mod_add_community_id)\nSELECT\n    published,\n    id\nFROM\n    mod_add_community;\n\nINSERT INTO modlog_combined (published, mod_ban_id)\nSELECT\n    published,\n    id\nFROM\n    mod_ban;\n\nINSERT INTO modlog_combined (published, mod_ban_from_community_id)\nSELECT\n    published,\n    id\nFROM\n    mod_ban_from_community;\n\nINSERT INTO modlog_combined (published, mod_feature_post_id)\nSELECT\n    published,\n    id\nFROM\n    mod_feature_post;\n\nINSERT INTO modlog_combined (published, mod_hide_community_id)\nSELECT\n    published,\n    id\nFROM\n    mod_hide_community;\n\nINSERT INTO modlog_combined (published, mod_lock_post_id)\nSELECT\n    published,\n    id\nFROM\n    mod_lock_post;\n\nINSERT INTO modlog_combined (published, mod_remove_comment_id)\nSELECT\n    published,\n    id\nFROM\n    mod_remove_comment;\n\nINSERT INTO modlog_combined (published, mod_remove_community_id)\nSELECT\n    published,\n    id\nFROM\n    mod_remove_community;\n\nINSERT INTO modlog_combined (published, mod_remove_post_id)\nSELECT\n    published,\n    id\nFROM\n    mod_remove_post;\n\nINSERT INTO modlog_combined (published, mod_transfer_community_id)\nSELECT\n    published,\n    id\nFROM\n    mod_transfer_community;\n\nCREATE INDEX idx_modlog_combined_published ON modlog_combined (published DESC, id DESC);\n\n-- Make sure only one of the columns is not null\nALTER TABLE modlog_combined\n    ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_hide_community_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1),\n    ALTER CONSTRAINT modlog_combined_admin_allow_instance_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_admin_block_instance_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_admin_purge_comment_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_admin_purge_post_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_admin_purge_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_admin_purge_person_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_add_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_add_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_ban_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_ban_from_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_feature_post_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_hide_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_lock_post_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_remove_comment_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_remove_community_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_remove_post_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT modlog_combined_mod_transfer_community_id_fkey NOT DEFERRABLE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000026_add_inbox_combined_table/down.sql",
    "content": "-- Rename the person_mention table to person_comment_mention\nALTER TABLE person_comment_mention RENAME TO person_mention;\n\n-- Drop the new tables\nDROP TABLE person_post_mention, inbox_combined;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000026_add_inbox_combined_table/up.sql",
    "content": "-- Creates combined tables for\n-- Inbox: (replies, comment mentions, post mentions, and private_messages)\n-- Also add post mentions, since these didn't exist before.\n-- Rename the person_mention table to person_comment_mention\nALTER TABLE person_mention RENAME TO person_comment_mention;\n\n-- Create the new post_mention table\nCREATE TABLE person_post_mention (\n    id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read boolean DEFAULT FALSE NOT NULL,\n    published timestamptz NOT NULL DEFAULT now(),\n    UNIQUE (recipient_id, post_id)\n);\n\n-- Updating the history\nCREATE TABLE inbox_combined AS\nSELECT\n    published,\n    id AS comment_reply_id,\n    NULL::int AS person_comment_mention_id,\n    NULL::int AS person_post_mention_id,\n    NULL::int AS private_message_id\nFROM\n    comment_reply\nUNION ALL\nSELECT\n    published,\n    NULL::int,\n    id,\n    NULL::int,\n    NULL::int\nFROM\n    person_comment_mention\nUNION ALL\nSELECT\n    published,\n    NULL::int,\n    NULL::int,\n    id,\n    NULL::int\nFROM\n    person_post_mention\nUNION ALL\nSELECT\n    published,\n    NULL::int,\n    NULL::int,\n    NULL::int,\n    id\nFROM\n    private_message;\n\nALTER TABLE inbox_combined\n    ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n    ALTER COLUMN published SET NOT NULL,\n    ADD CONSTRAINT inbox_combined_comment_reply_id_fkey FOREIGN KEY (comment_reply_id) REFERENCES comment_reply ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT inbox_combined_person_comment_mention_id_fkey FOREIGN KEY (person_comment_mention_id) REFERENCES person_comment_mention ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT inbox_combined_person_post_mention_id_fkey FOREIGN KEY (person_post_mention_id) REFERENCES person_post_mention ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT inbox_combined_private_message_id_fkey FOREIGN KEY (private_message_id) REFERENCES private_message ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD UNIQUE (comment_reply_id),\n    ADD UNIQUE (person_comment_mention_id),\n    ADD UNIQUE (person_post_mention_id),\n    ADD UNIQUE (private_message_id),\n    ADD CONSTRAINT inbox_combined_check CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1);\n\nCREATE INDEX idx_inbox_combined_published ON inbox_combined (published DESC, id DESC);\n\nCREATE INDEX idx_inbox_combined_published_asc ON inbox_combined (reverse_timestamp_sort (published) DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000027_add_search_combined_table/down.sql",
    "content": "ALTER TABLE person_aggregates\n    DROP COLUMN published;\n\nDROP TABLE search_combined;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000027_add_search_combined_table/up.sql",
    "content": "-- Creates combined tables for\n-- Search: (post, comment, community, person)\n-- Add published to person_aggregates (it was missing for some reason)\nALTER TABLE person_aggregates\n    ADD COLUMN published timestamptz NOT NULL DEFAULT now();\n\nUPDATE\n    person_aggregates pa\nSET\n    published = p.published\nFROM\n    person p\nWHERE\n    pa.person_id = p.id;\n\n-- score is used for the top sort\n-- For persons: its post score\n-- For comments: score,\n-- For posts: score,\n-- For community: users active monthly\n-- Updating the history\nCREATE TABLE search_combined AS\nSELECT\n    published,\n    score::int,\n    post_id,\n    NULL::int AS comment_id,\n    NULL::int AS community_id,\n    NULL::int AS person_id\nFROM\n    post_aggregates\nUNION ALL\nSELECT\n    published,\n    score::int,\n    NULL::int,\n    comment_id,\n    NULL::int,\n    NULL::int\nFROM\n    comment_aggregates\nUNION ALL\nSELECT\n    published,\n    users_active_month::int,\n    NULL::int,\n    NULL::int,\n    community_id,\n    NULL::int\nFROM\n    community_aggregates\nUNION ALL\nSELECT\n    published,\n    post_score::int,\n    NULL::int,\n    NULL::int,\n    NULL::int,\n    person_id\nFROM\n    person_aggregates;\n\n-- Add the constraints\nALTER TABLE search_combined\n    ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN score SET NOT NULL,\n    ALTER COLUMN score SET DEFAULT 0,\n    ADD CONSTRAINT search_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT search_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT search_combined_community_id_fkey FOREIGN KEY (community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT search_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD UNIQUE (post_id),\n    ADD UNIQUE (comment_id),\n    ADD UNIQUE (community_id),\n    ADD UNIQUE (person_id),\n    ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id) = 1);\n\nCREATE INDEX idx_search_combined_published ON search_combined (published DESC, id DESC);\n\nCREATE INDEX idx_search_combined_published_asc ON search_combined (reverse_timestamp_sort (published) DESC, id DESC);\n\nCREATE INDEX idx_search_combined_score ON search_combined (score DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000028_add_index_on_person_id_read_for_read_only_post_actions/down.sql",
    "content": "DROP INDEX idx_post_actions_on_read_read_not_null;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000028_add_index_on_person_id_read_for_read_only_post_actions/up.sql",
    "content": "CREATE INDEX idx_post_actions_on_read_read_not_null ON post_actions (person_id, read, post_id)\nWHERE\n    read IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000029_community-post-tags/down.sql",
    "content": "DROP TABLE post_tag;\n\nDROP TABLE tag;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000029_community-post-tags/up.sql",
    "content": "-- a tag is a federatable object that gives additional context to another object, which can be displayed and filtered on\n-- currently, we only have community post tags, which is a tag that is created by post authors as well as mods  of a community,\n-- to categorize a post. in the future we may add more tag types, depending on the requirements,\n-- this will lead to either expansion of this table (community_id optional, addition of tag_type enum)\n-- or split of this table / creation of new tables.\nCREATE TABLE tag (\n    id serial PRIMARY KEY,\n    ap_id text NOT NULL UNIQUE,\n    name varchar(255) NOT NULL,\n    display_name varchar(255),\n    description text,\n    community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz NOT NULL DEFAULT now(),\n    updated timestamptz,\n    deleted boolean NOT NULL DEFAULT FALSE\n);\n\n-- an association between a post and a tag. created/updated by the post author or mods of a community\nCREATE TABLE post_tag (\n    post_id int NOT NULL REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    tag_id int NOT NULL REFERENCES tag (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz NOT NULL DEFAULT now(),\n    PRIMARY KEY (post_id, tag_id)\n);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000030_optimize_get_random_community/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN random_number;\n\nDROP FUNCTION random_smallint;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000030_optimize_get_random_community/up.sql",
    "content": "-- * inclusive bounds of `smallint` range from https://www.postgresql.org/docs/17/datatype-numeric.html\n-- * built-in `random` function has `VOLATILE` and `PARALLEL RESTRICTED` according to:\n--   * https://www.postgresql.org/docs/current/parallel-safety.html#PARALLEL-LABELING\n--   * https://www.postgresql.org/docs/17/xfunc-volatility.html\nCREATE FUNCTION random_smallint ()\n    RETURNS smallint\n    LANGUAGE sql\n    VOLATILE PARALLEL RESTRICTED RETURN\n    -- https://stackoverflow.com/questions/1400505/generate-a-random-number-in-the-range-1-10/1400752#1400752\n    -- (65536 = exclusive upper bound - inclusive lower bound)\n    trunc ((random() * (65536)) - 32768\n);\n\nALTER TABLE community\n    ADD COLUMN random_number smallint NOT NULL DEFAULT random_smallint ();\n\nCREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw)\nWHERE\n    NOT (deleted OR removed OR visibility = 'Private');\n\n"
  },
  {
    "path": "migrations/2025-08-01-000031_update-replaceable-schema/down.sql",
    "content": "SELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000031_update-replaceable-schema/up.sql",
    "content": "SELECT\n    1;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000032_community_report/down.sql",
    "content": "DELETE FROM report_combined\nWHERE community_report_id IS NOT NULL;\n\nALTER TABLE report_combined\n    DROP CONSTRAINT report_combined_check,\n    ADD CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id) = 1),\n    DROP COLUMN community_report_id;\n\nDROP TABLE community_report CASCADE;\n\nALTER TABLE community_aggregates\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000032_community_report/up.sql",
    "content": "CREATE TABLE community_report (\n    id serial PRIMARY KEY,\n    creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    original_community_name text NOT NULL,\n    original_community_title text NOT NULL,\n    original_community_description text,\n    original_community_sidebar text,\n    original_community_icon text,\n    original_community_banner text,\n    reason text NOT NULL,\n    resolved bool NOT NULL DEFAULT FALSE,\n    resolver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published timestamptz NOT NULL DEFAULT now(),\n    updated timestamptz NULL,\n    UNIQUE (community_id, creator_id)\n);\n\nCREATE INDEX idx_community_report_published ON community_report (published DESC);\n\nALTER TABLE report_combined\n    ADD COLUMN community_report_id int UNIQUE REFERENCES community_report ON UPDATE CASCADE ON DELETE CASCADE,\n    DROP CONSTRAINT report_combined_check,\n    ADD CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id, community_report_id) = 1);\n\nALTER TABLE community_aggregates\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000033_add_post_keyword_block_table/down.sql",
    "content": "DROP TABLE local_user_keyword_block;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000033_add_post_keyword_block_table/up.sql",
    "content": "CREATE TABLE local_user_keyword_block (\n    local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    keyword varchar(50) NOT NULL,\n    PRIMARY KEY (local_user_id, keyword)\n);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000034_no-image-token/down.sql",
    "content": "ALTER TABLE local_image\n    ADD COLUMN pictrs_delete_token text DEFAULT '',\n    ADD CONSTRAINT image_upload_pictrs_delete_token_not_null NOT NULL pictrs_delete_token;\n\nALTER TABLE local_image\n    ALTER COLUMN pictrs_delete_token DROP DEFAULT;\n\nALTER TABLE local_image\n    ADD COLUMN published_new timestamp with time zone DEFAULT now();\n\nUPDATE\n    local_image\nSET\n    published_new = published;\n\nALTER TABLE local_image\n    DROP COLUMN published;\n\nALTER TABLE local_image RENAME published_new TO published;\n\nALTER TABLE local_image\n    ADD CONSTRAINT image_upload_published_not_null NOT NULL published;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000034_no-image-token/up.sql",
    "content": "ALTER TABLE local_image\n    DROP COLUMN pictrs_delete_token;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000035_media_filter/down.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN hide_media;\n\nDROP INDEX idx_post_url_content_type;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000035_media_filter/up.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN hide_media boolean DEFAULT FALSE NOT NULL;\n\nCREATE INDEX idx_post_url_content_type ON post USING gin (url_content_type gin_trgm_ops);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000036_interactions_per_month_schema/down.sql",
    "content": "ALTER TABLE community_aggregates\n    DROP COLUMN interactions_month;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000036_interactions_per_month_schema/up.sql",
    "content": "-- Add the interactions_month column\nALTER TABLE community_aggregates\n    ADD COLUMN interactions_month bigint NOT NULL DEFAULT 0;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000037_report_to_admins/down.sql",
    "content": "ALTER TABLE post_report\n    DROP COLUMN violates_instance_rules;\n\nALTER TABLE comment_report\n    DROP COLUMN violates_instance_rules;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000037_report_to_admins/up.sql",
    "content": "ALTER TABLE post_report\n    ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE;\n\nALTER TABLE comment_report\n    ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000038_ap_id/down.sql",
    "content": "ALTER TABLE person RENAME ap_id TO actor_id;\n\nALTER TABLE community RENAME ap_id TO actor_id;\n\nALTER TABLE site RENAME ap_id TO actor_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000038_ap_id/up.sql",
    "content": "ALTER TABLE person RENAME actor_id TO ap_id;\n\nALTER TABLE community RENAME actor_id TO ap_id;\n\nALTER TABLE site RENAME actor_id TO ap_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000039_remove_post_sort_type_enums/down.sql",
    "content": "-- This removes all the extra post_sort_type_enums,\n-- and adds a default_post_time_range_seconds field.\n-- Drop the defaults because of a postgres bug\nALTER TABLE local_user\n    ALTER default_post_sort_type DROP DEFAULT;\n\nALTER TABLE local_site\n    ALTER default_post_sort_type DROP DEFAULT;\n\n-- Change all the top variants to top in the two tables that use the enum\nUPDATE\n    local_user\nSET\n    default_post_sort_type = 'Active'\nWHERE\n    default_post_sort_type = 'Top';\n\nUPDATE\n    local_site\nSET\n    default_post_sort_type = 'Active'\nWHERE\n    default_post_sort_type = 'Top';\n\n-- rename the old enum to a tmp name\nALTER TYPE post_sort_type_enum RENAME TO post_sort_type_enum__;\n\n-- create the new enum\nCREATE TYPE post_sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'TopDay',\n    'TopWeek',\n    'TopMonth',\n    'TopYear',\n    'TopAll',\n    'MostComments',\n    'NewComments',\n    'TopHour',\n    'TopSixHour',\n    'TopTwelveHour',\n    'TopThreeMonths',\n    'TopSixMonths',\n    'TopNineMonths',\n    'Controversial',\n    'Scaled'\n);\n\n-- alter all you enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum\n    USING default_post_sort_type::text::post_sort_type_enum;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum\n    USING default_post_sort_type::text::post_sort_type_enum;\n\n-- drop the old enum\nDROP TYPE post_sort_type_enum__;\n\n-- Add back in the default\nALTER TABLE local_user\n    ALTER default_post_sort_type SET DEFAULT 'Active';\n\nALTER TABLE local_site\n    ALTER default_post_sort_type SET DEFAULT 'Active';\n\n-- Drop the new columns\nALTER TABLE local_user\n    DROP COLUMN default_post_time_range_seconds;\n\nALTER TABLE local_site\n    DROP COLUMN default_post_time_range_seconds;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000039_remove_post_sort_type_enums/up.sql",
    "content": "-- This removes all the extra post_sort_type_enums,\n-- and adds a default_post_time_range_seconds field.\n-- Change all the top variants to top in the two tables that use the enum\n-- Because of a postgres bug, you can't assign this to a new enum value,\n-- unless you run an unsafe commit first. So just use active.\n-- https://dba.stackexchange.com/questions/280371/postgres-unsafe-use-of-new-value-of-enum-type\n--\n-- Disable the triggers temporarily\nALTER TABLE local_user DISABLE TRIGGER ALL;\n\nALTER TABLE local_site DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_user');\n\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_site');\n\nUPDATE\n    local_user\nSET\n    default_post_sort_type = 'Active'\nWHERE\n    default_post_sort_type IN ('TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths');\n\nUPDATE\n    local_site\nSET\n    default_post_sort_type = 'Active'\nWHERE\n    default_post_sort_type IN ('TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths');\n\n-- Drop the defaults because of a postgres bug\nALTER TABLE local_user\n    ALTER default_post_sort_type DROP DEFAULT;\n\nALTER TABLE local_site\n    ALTER default_post_sort_type DROP DEFAULT;\n\n-- rename the old enum to a tmp name\nALTER TYPE post_sort_type_enum RENAME TO post_sort_type_enum__;\n\n-- create the new enum\nCREATE TYPE post_sort_type_enum AS ENUM (\n    'Active',\n    'Hot',\n    'New',\n    'Old',\n    'Top',\n    'MostComments',\n    'NewComments',\n    'Controversial',\n    'Scaled'\n);\n\n-- alter all you enum columns\nALTER TABLE local_user\n    ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum\n    USING default_post_sort_type::text::post_sort_type_enum;\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum\n    USING default_post_sort_type::text::post_sort_type_enum;\n\n-- drop the old enum\nDROP TYPE post_sort_type_enum__;\n\n-- Add back in the default\nALTER TABLE local_user\n    ALTER default_post_sort_type SET DEFAULT 'Active';\n\nALTER TABLE local_site\n    ALTER default_post_sort_type SET DEFAULT 'Active';\n\n-- Add the new column to both tables (null means no limit)\nALTER TABLE local_user\n    ADD COLUMN default_post_time_range_seconds integer;\n\nALTER TABLE local_site\n    ADD COLUMN default_post_time_range_seconds integer;\n\n-- Re-enable the triggers\nALTER TABLE local_user ENABLE TRIGGER ALL;\n\nALTER TABLE local_site ENABLE TRIGGER ALL;\n\n-- re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_user');\n\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_site');\n\n-- reindex\nREINDEX TABLE local_user;\n\nREINDEX TABLE local_site;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000040_block_nsfw/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN disallow_nsfw_content;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000040_block_nsfw/up.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN disallow_nsfw_content boolean DEFAULT FALSE NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000041_remove-aggregate-tables/down.sql",
    "content": "-- move comment_aggregates back into separate table\nCREATE TABLE IF NOT EXISTS comment_aggregates (\n    comment_id int PRIMARY KEY NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    score bigint NOT NULL DEFAULT 0,\n    upvotes bigint NOT NULL DEFAULT 0,\n    downvotes bigint NOT NULL DEFAULT 0,\n    published timestamp with time zone NOT NULL DEFAULT now(),\n    child_count integer NOT NULL DEFAULT 0,\n    hot_rank double precision NOT NULL DEFAULT 0.0001,\n    controversy_rank double precision NOT NULL DEFAULT 0,\n    report_count smallint NOT NULL DEFAULT 0,\n    unresolved_report_count smallint NOT NULL DEFAULT 0\n);\n\nINSERT INTO comment_aggregates\nSELECT\n    id AS comment_id,\n    score,\n    upvotes,\n    downvotes,\n    published,\n    child_count,\n    hot_rank,\n    controversy_rank,\n    report_count,\n    unresolved_report_count\nFROM\n    COMMENT\nON CONFLICT (comment_id)\n    DO UPDATE SET\n        score = excluded.score,\n        upvotes = excluded.upvotes,\n        downvotes = excluded.downvotes,\n        published = excluded.published,\n        child_count = excluded.child_count,\n        hot_rank = excluded.hot_rank,\n        controversy_rank = excluded.controversy_rank,\n        report_count = excluded.report_count,\n        unresolved_report_count = excluded.unresolved_report_count;\n\nALTER TABLE comment\n    DROP COLUMN score,\n    DROP COLUMN upvotes,\n    DROP COLUMN downvotes,\n    DROP COLUMN child_count,\n    DROP COLUMN hot_rank,\n    DROP COLUMN controversy_rank,\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count;\n\nALTER TABLE comment_aggregates\n    ALTER CONSTRAINT comment_aggregates_comment_id_fkey DEFERRABLE INITIALLY DEFERRED;\n\nCREATE INDEX IF NOT EXISTS idx_comment_aggregates_controversy ON comment_aggregates USING btree (controversy_rank DESC);\n\nCREATE INDEX IF NOT EXISTS idx_comment_aggregates_hot ON comment_aggregates USING btree (hot_rank DESC, score DESC);\n\nCREATE INDEX IF NOT EXISTS idx_comment_aggregates_nonzero_hotrank ON comment_aggregates USING btree (published)\nWHERE (hot_rank <> (0)::double precision);\n\nCREATE INDEX IF NOT EXISTS idx_comment_aggregates_published ON comment_aggregates USING btree (published DESC);\n\nCREATE INDEX IF NOT EXISTS idx_comment_aggregates_score ON comment_aggregates USING btree (score DESC);\n\n-- move comment_aggregates back into separate table\nCREATE TABLE IF NOT EXISTS post_aggregates (\n    post_id int PRIMARY KEY NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    comments bigint NOT NULL DEFAULT 0,\n    score bigint NOT NULL DEFAULT 0,\n    upvotes bigint NOT NULL DEFAULT 0,\n    downvotes bigint NOT NULL DEFAULT 0,\n    published timestamp with time zone NOT NULL DEFAULT now(),\n    newest_comment_time_necro timestamp with time zone DEFAULT now(),\n    newest_comment_time timestamp with time zone DEFAULT now(),\n    featured_community boolean NOT NULL DEFAULT FALSE,\n    featured_local boolean NOT NULL DEFAULT FALSE,\n    hot_rank double precision NOT NULL DEFAULT 0.0001,\n    hot_rank_active double precision NOT NULL DEFAULT 0.0001,\n    community_id integer NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    creator_id integer NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    controversy_rank double precision NOT NULL DEFAULT 0,\n    instance_id integer NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    scaled_rank double precision NOT NULL DEFAULT 0.0001,\n    report_count smallint NOT NULL DEFAULT 0,\n    unresolved_report_count smallint NOT NULL DEFAULT 0,\n    CONSTRAINT post_aggregates_newest_comment_time_not_null1 NOT NULL newest_comment_time,\n    CONSTRAINT post_aggregates_newest_comment_time_not_null NOT NULL newest_comment_time_necro\n);\n\nINSERT INTO post_aggregates\nSELECT\n    id AS post_id,\n    comments,\n    score,\n    upvotes,\n    downvotes,\n    published,\n    coalesce(newest_comment_time_necro, published),\n    coalesce(newest_comment_time, published),\n    featured_community,\n    featured_local,\n    hot_rank,\n    hot_rank_active,\n    community_id,\n    creator_id,\n    controversy_rank,\n    (\n        SELECT\n            community.instance_id\n        FROM\n            community\n        WHERE\n            community.id = post.community_id) AS instance_id,\n    scaled_rank,\n    report_count,\n    unresolved_report_count\nFROM\n    post\nON CONFLICT (post_id)\n    DO UPDATE SET\n        comments = excluded.comments,\n        score = excluded.score,\n        upvotes = excluded.upvotes,\n        downvotes = excluded.downvotes,\n        published = excluded.published,\n        newest_comment_time_necro = excluded.newest_comment_time_necro,\n        newest_comment_time = excluded.newest_comment_time,\n        featured_community = excluded.featured_community,\n        featured_local = excluded.featured_local,\n        hot_rank = excluded.hot_rank,\n        hot_rank_active = excluded.hot_rank_active,\n        community_id = excluded.community_id,\n        creator_id = excluded.creator_id,\n        controversy_rank = excluded.controversy_rank,\n        instance_id = excluded.instance_id,\n        scaled_rank = excluded.scaled_rank,\n        report_count = excluded.report_count,\n        unresolved_report_count = excluded.unresolved_report_count;\n\nALTER TABLE post\n    DROP COLUMN comments,\n    DROP COLUMN score,\n    DROP COLUMN upvotes,\n    DROP COLUMN downvotes,\n    DROP COLUMN newest_comment_time_necro,\n    DROP COLUMN newest_comment_time,\n    DROP COLUMN hot_rank,\n    DROP COLUMN hot_rank_active,\n    DROP COLUMN controversy_rank,\n    DROP COLUMN scaled_rank,\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count;\n\nALTER TABLE post_aggregates\n    ALTER CONSTRAINT post_aggregates_community_id_fkey DEFERRABLE INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_creator_id_fkey DEFERRABLE INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_instance_id_fkey DEFERRABLE INITIALLY DEFERRED,\n    ALTER CONSTRAINT post_aggregates_post_id_fkey DEFERRABLE INITIALLY DEFERRED;\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_creator ON post_aggregates USING btree (creator_id);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community ON post_aggregates USING btree (community_id);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_active ON post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_controversy ON post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_hot ON post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_most_comments ON post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_newest_comment_time ON post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_newest_comment_time_necro ON post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_published ON post_aggregates USING btree (community_id, featured_local DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_published_asc ON post_aggregates USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_scaled ON post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_community_score ON post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_active ON post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_controversy ON post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_hot ON post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_most_comments ON post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_newest_comment_time_necr ON post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_published ON post_aggregates USING btree (community_id, featured_community DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_published_asc ON post_aggregates USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_scaled ON post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_score ON post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_active ON post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_controversy ON post_aggregates USING btree (featured_local DESC, controversy_rank DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_hot ON post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_most_comments ON post_aggregates USING btree (featured_local DESC, comments DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates USING btree (featured_local DESC, newest_comment_time DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_newest_comment_time_necro ON post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_published ON post_aggregates USING btree (featured_local DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_published_asc ON post_aggregates USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_scaled ON post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_score ON post_aggregates USING btree (featured_local DESC, score DESC, published DESC, post_id DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_nonzero_hotrank ON post_aggregates USING btree (published DESC)\nWHERE ((hot_rank <> (0)::double precision) OR (hot_rank_active <> (0)::double precision));\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_published ON post_aggregates USING btree (published DESC);\n\nCREATE INDEX IF NOT EXISTS idx_post_aggregates_published_asc ON post_aggregates USING btree (reverse_timestamp_sort (published) DESC);\n\nDROP INDEX idx_post_featured_community_published_asc;\n\nDROP INDEX idx_post_featured_local_published;\n\nDROP INDEX idx_post_featured_local_published_asc;\n\nDROP INDEX idx_post_published_asc;\n\nDROP INDEX idx_search_combined_score;\n\n-- move community_aggregates back into separate table\nCREATE TABLE community_aggregates (\n    community_id int PRIMARY KEY NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    subscribers bigint NOT NULL DEFAULT 0,\n    posts bigint NOT NULL DEFAULT 0,\n    comments bigint NOT NULL DEFAULT 0,\n    published timestamp with time zone DEFAULT now() NOT NULL,\n    users_active_day bigint NOT NULL DEFAULT 0,\n    users_active_week bigint NOT NULL DEFAULT 0,\n    users_active_month bigint NOT NULL DEFAULT 0,\n    users_active_half_year bigint NOT NULL DEFAULT 0,\n    hot_rank double precision NOT NULL DEFAULT 0.0001,\n    subscribers_local bigint NOT NULL DEFAULT 0,\n    report_count smallint NOT NULL DEFAULT 0,\n    unresolved_report_count smallint NOT NULL DEFAULT 0,\n    interactions_month bigint NOT NULL DEFAULT 0\n);\n\nINSERT INTO community_aggregates\nSELECT\n    id AS comment_id,\n    subscribers,\n    posts,\n    comments,\n    published,\n    users_active_day,\n    users_active_week,\n    users_active_month,\n    users_active_half_year,\n    hot_rank,\n    subscribers_local,\n    report_count,\n    unresolved_report_count,\n    interactions_month\nFROM\n    community;\n\nALTER TABLE community\n    DROP COLUMN subscribers,\n    DROP COLUMN posts,\n    DROP COLUMN comments,\n    DROP COLUMN users_active_day,\n    DROP COLUMN users_active_week,\n    DROP COLUMN users_active_month,\n    DROP COLUMN users_active_half_year,\n    DROP COLUMN hot_rank,\n    DROP COLUMN subscribers_local,\n    DROP COLUMN report_count,\n    DROP COLUMN unresolved_report_count,\n    DROP COLUMN interactions_month;\n\nALTER TABLE community\n    ALTER CONSTRAINT community_instance_id_fkey NOT DEFERRABLE;\n\nCREATE INDEX idx_community_aggregates_hot ON public.community_aggregates USING btree (hot_rank DESC);\n\nCREATE INDEX idx_community_aggregates_nonzero_hotrank ON public.community_aggregates USING btree (published)\nWHERE (hot_rank <> (0)::double precision);\n\nCREATE INDEX idx_community_aggregates_published ON public.community_aggregates USING btree (published DESC);\n\nCREATE INDEX idx_community_aggregates_subscribers ON public.community_aggregates USING btree (subscribers DESC);\n\nCREATE INDEX idx_community_aggregates_users_active_month ON public.community_aggregates USING btree (users_active_month DESC);\n\n-- move person_aggregates back into separate table\nCREATE TABLE person_aggregates (\n    person_id int PRIMARY KEY REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    post_count bigint DEFAULT 0,\n    post_score bigint DEFAULT 0,\n    comment_count bigint DEFAULT 0,\n    comment_score bigint DEFAULT 0,\n    published timestamp with time zone DEFAULT now() NOT NULL,\n    CONSTRAINT user_aggregates_comment_count_not_null NOT NULL comment_count,\n    CONSTRAINT user_aggregates_comment_score_not_null NOT NULL comment_score,\n    CONSTRAINT user_aggregates_user_id_not_null NOT NULL person_id,\n    CONSTRAINT user_aggregates_post_count_not_null NOT NULL post_count,\n    CONSTRAINT user_aggregates_post_score_not_null NOT NULL post_score\n);\n\nINSERT INTO person_aggregates\nSELECT\n    id AS person_id,\n    post_count,\n    post_score,\n    comment_count,\n    comment_score,\n    published\nFROM\n    person;\n\nALTER TABLE person\n    DROP COLUMN post_count,\n    DROP COLUMN post_score,\n    DROP COLUMN comment_count,\n    DROP COLUMN comment_score;\n\nALTER TABLE person_aggregates\n    ALTER CONSTRAINT person_aggregates_person_id_fkey DEFERRABLE INITIALLY DEFERRED;\n\nCREATE INDEX idx_person_aggregates_comment_score ON public.person_aggregates USING btree (comment_score DESC);\n\nCREATE INDEX idx_person_aggregates_person ON public.person_aggregates USING btree (person_id);\n\n-- move site_aggregates back into separate table\nCREATE TABLE site_aggregates (\n    site_id int PRIMARY KEY NOT NULL REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE,\n    users bigint NOT NULL DEFAULT 1,\n    posts bigint NOT NULL DEFAULT 0,\n    comments bigint NOT NULL DEFAULT 0,\n    communities bigint NOT NULL DEFAULT 0,\n    users_active_day bigint NOT NULL DEFAULT 0,\n    users_active_week bigint NOT NULL DEFAULT 0,\n    users_active_month bigint NOT NULL DEFAULT 0,\n    users_active_half_year bigint NOT NULL DEFAULT 0\n);\n\nINSERT INTO site_aggregates\nSELECT\n    id AS site_id,\n    users,\n    posts,\n    comments,\n    communities,\n    users_active_day,\n    users_active_week,\n    users_active_month,\n    users_active_half_year\nFROM\n    local_site;\n\nALTER TABLE local_site\n    DROP COLUMN users,\n    DROP COLUMN posts,\n    DROP COLUMN comments,\n    DROP COLUMN communities,\n    DROP COLUMN users_active_day,\n    DROP COLUMN users_active_week,\n    DROP COLUMN users_active_month,\n    DROP COLUMN users_active_half_year;\n\n-- move local_user_vote_display_mode back into separate table\nCREATE TABLE local_user_vote_display_mode (\n    local_user_id int PRIMARY KEY NOT NULL REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE,\n    score boolean NOT NULL DEFAULT FALSE,\n    upvotes boolean NOT NULL DEFAULT TRUE,\n    downvotes boolean NOT NULL DEFAULT TRUE,\n    upvote_percentage boolean NOT NULL DEFAULT FALSE\n);\n\nINSERT INTO local_user_vote_display_mode\nSELECT\n    id AS local_user_id,\n    show_score AS score,\n    show_upvotes AS upvotes,\n    show_downvotes AS downvotes,\n    show_upvote_percentage AS upvote_percentage\nFROM\n    local_user;\n\nALTER TABLE local_user\n    DROP COLUMN show_score,\n    DROP COLUMN show_upvotes,\n    DROP COLUMN show_downvotes,\n    DROP COLUMN show_upvote_percentage;\n\nCREATE INDEX idx_search_combined_score ON public.search_combined USING btree (score DESC, id DESC);\n\nALTER TABLE site_aggregates\n    ALTER CONSTRAINT site_aggregates_site_id_fkey DEFERRABLE INITIALLY DEFERRED;\n\nCREATE UNIQUE INDEX idx_site_aggregates_1_row_only ON public.site_aggregates USING btree ((TRUE));\n\nALTER TABLE community_aggregates\n    ALTER CONSTRAINT community_aggregates_community_id_fkey DEFERRABLE INITIALLY DEFERRED;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000041_remove-aggregate-tables/up.sql",
    "content": "-- Merge comment_aggregates into comment table\nALTER TABLE comment\n    ADD COLUMN score int NOT NULL DEFAULT 1, -- Default value only for previous rows, to match the similar thing done with `upvotes`\n    ADD COLUMN upvotes int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `upvotes != 1` instead of `upvotes != 0`\n    ADD COLUMN downvotes int NOT NULL DEFAULT 0,\n    ADD COLUMN child_count int NOT NULL DEFAULT 0,\n    ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001`\n    ADD COLUMN controversy_rank real NOT NULL DEFAULT 0,\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n-- Default values only for future rows\nALTER TABLE comment\n    ALTER COLUMN score SET DEFAULT 0,\n    ALTER COLUMN upvotes SET DEFAULT 0,\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001;\n\n-- Disable the triggers temporarily\nALTER TABLE comment DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'comment');\n\nUPDATE\n    comment\nSET\n    score = ca.score,\n    upvotes = ca.upvotes,\n    downvotes = ca.downvotes,\n    child_count = ca.child_count,\n    hot_rank = ca.hot_rank,\n    controversy_rank = ca.controversy_rank,\n    report_count = ca.report_count,\n    unresolved_report_count = ca.unresolved_report_count\nFROM\n    comment_aggregates AS ca\nWHERE\n    comment.id = ca.comment_id\n    -- If `(upvotes, downvotes) = (1, 0)`, then `(score, controversy_rank) = (1, 0)`, so it would be redundant to check `score` and `controversy_rank` in this filter.\n    AND (ca.upvotes != 1\n        OR ca.downvotes != 0\n        OR ca.child_count != 0\n        OR ca.hot_rank != 0\n        OR ca.report_count != 0\n        OR ca.unresolved_report_count != 0);\n\nDROP TABLE comment_aggregates;\n\n-- Re-enable triggers after upserts\nALTER TABLE comment ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'comment');\n\n-- reindex\nREINDEX TABLE comment;\n\n-- 30s-2m each\nCREATE INDEX idx_comment_controversy ON comment USING btree (controversy_rank DESC);\n\nCREATE INDEX idx_comment_hot ON comment USING btree (hot_rank DESC, score DESC);\n\nCREATE INDEX idx_comment_nonzero_hotrank ON comment USING btree (published)\nWHERE (hot_rank <> (0)::double precision);\n\n--CREATE INDEX idx_comment_published on comment USING btree (published DESC);\nCREATE INDEX idx_comment_score ON comment USING btree (score DESC);\n\n-- merge post_aggregates into post table\nALTER TABLE post\n    ADD COLUMN newest_comment_time_necro timestamp with time zone,\n    ADD COLUMN newest_comment_time timestamp with time zone,\n    ADD COLUMN comments int NOT NULL DEFAULT 0,\n    ADD COLUMN score int NOT NULL DEFAULT 1, -- Default value only for previous rows, to match the similar thing done with `upvotes`\n    ADD COLUMN upvotes int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `upvotes != 1` instead of `upvotes != 0`\n    ADD COLUMN downvotes int NOT NULL DEFAULT 0,\n    ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001`\n    ADD COLUMN hot_rank_active real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank_active != 0` instead of `hot_rank_active != 0.0001`\n    ADD COLUMN controversy_rank real NOT NULL DEFAULT 0,\n    ADD COLUMN scaled_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `scaled_rank != 0` instead of `scaled_rank != 0.0001`\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n-- Default values only for future rows\nALTER TABLE post\n    ALTER COLUMN score SET DEFAULT 0,\n    ALTER COLUMN upvotes SET DEFAULT 0,\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001,\n    ALTER COLUMN hot_rank_active SET DEFAULT 0.0001,\n    ALTER COLUMN scaled_rank SET DEFAULT 0.0001;\n\n-- Disable the triggers temporarily\nALTER TABLE post DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'post');\n\nUPDATE\n    post\nSET\n    newest_comment_time_necro = nullif (pa.newest_comment_time_necro, pa.published),\n    newest_comment_time = nullif (pa.newest_comment_time, pa.published),\n    comments = pa.comments,\n    score = pa.score,\n    upvotes = pa.upvotes,\n    downvotes = pa.downvotes,\n    hot_rank = pa.hot_rank,\n    hot_rank_active = pa.hot_rank_active,\n    controversy_rank = pa.controversy_rank,\n    scaled_rank = pa.scaled_rank,\n    report_count = pa.report_count,\n    unresolved_report_count = pa.unresolved_report_count\nFROM\n    post_aggregates AS pa\nWHERE\n    post.id = pa.post_id\n    -- If `(upvotes, downvotes) = (1, 0)`, then `(score, controversy_rank) = (1, 0)`, so it would be redundant to check `score` and `controversy_rank` in this filter.\n    AND (pa.newest_comment_time_necro != pa.published\n        OR pa.newest_comment_time != pa.published\n        OR pa.comments != 0\n        OR pa.upvotes != 1\n        OR pa.downvotes != 0\n        OR pa.hot_rank != 0\n        OR pa.hot_rank_active != 0\n        OR pa.scaled_rank != 0\n        OR pa.report_count != 0\n        OR pa.unresolved_report_count != 0);\n\n-- Delete that data\nDROP TABLE post_aggregates;\n\n-- Re-enable triggers after upserts\nALTER TABLE post ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'post');\n\n-- reindex\nREINDEX TABLE post;\n\nCREATE INDEX idx_post_community_active ON post USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_community_controversy ON post USING btree (community_id, featured_local DESC, controversy_rank DESC, id DESC);\n\nCREATE INDEX idx_post_community_hot ON post USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_community_most_comments ON post USING btree (community_id, featured_local DESC, comments DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_community_newest_comment_time ON post USING btree (community_id, featured_local DESC, coalesce(newest_comment_time, published) DESC, id DESC);\n\nCREATE INDEX idx_post_community_newest_comment_time_necro ON post USING btree (community_id, featured_local DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC);\n\n-- INDEX idx_post_community_published ON post USING btree (community_id, featured_local DESC, published DESC);\n--CREATE INDEX idx_post_community_published_asc ON post USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC);\nCREATE INDEX idx_post_community_scaled ON post USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_community_score ON post USING btree (community_id, featured_local DESC, score DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_active ON post USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_controversy ON post USING btree (community_id, featured_community DESC, controversy_rank DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_hot ON post USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_most_comments ON post USING btree (community_id, featured_community DESC, comments DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_newest_comment_time ON post USING btree (community_id, featured_community DESC, coalesce(newest_comment_time, published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_newest_comment_time_necr ON post USING btree (community_id, featured_community DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC);\n\n--CREATE INDEX idx_post_featured_community_published ON post USING btree (community_id, featured_community DESC, published DESC);\nCREATE INDEX idx_post_featured_community_published_asc ON post USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_scaled ON post USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_community_score ON post USING btree (community_id, featured_community DESC, score DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_active ON post USING btree (featured_local DESC, hot_rank_active DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_controversy ON post USING btree (featured_local DESC, controversy_rank DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_hot ON post USING btree (featured_local DESC, hot_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_most_comments ON post USING btree (featured_local DESC, comments DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_newest_comment_time ON post USING btree (featured_local DESC, coalesce(newest_comment_time, published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_newest_comment_time_necro ON post USING btree (featured_local DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_published ON post USING btree (featured_local DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_published_asc ON post USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_scaled ON post USING btree (featured_local DESC, scaled_rank DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_score ON post USING btree (featured_local DESC, score DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_nonzero_hotrank ON post USING btree (published DESC)\nWHERE ((hot_rank <> (0)::double precision) OR (hot_rank_active <> (0)::double precision));\n\nCREATE INDEX idx_post_published_asc ON post USING btree (reverse_timestamp_sort (published) DESC);\n\n-- merge community_aggregates into community table\nALTER TABLE community\n    ADD COLUMN subscribers int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `subscribers != 1` instead of `subscribers != 0`\n    ADD COLUMN posts int NOT NULL DEFAULT 0,\n    ADD COLUMN comments int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_day int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_week int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_month int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_half_year int NOT NULL DEFAULT 0,\n    ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001`\n    ADD COLUMN subscribers_local int NOT NULL DEFAULT 0,\n    ADD COLUMN interactions_month int NOT NULL DEFAULT 0,\n    ADD COLUMN report_count smallint NOT NULL DEFAULT 0,\n    ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0;\n\n-- Default values only for future rows\nALTER TABLE community\n    ALTER COLUMN subscribers SET DEFAULT 0,\n    ALTER COLUMN hot_rank SET DEFAULT 0.0001;\n\n-- Disable the triggers temporarily\nALTER TABLE community DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'community');\n\nUPDATE\n    community\nSET\n    subscribers = ca.subscribers,\n    posts = ca.posts,\n    comments = ca.comments,\n    users_active_day = ca.users_active_day,\n    users_active_week = ca.users_active_week,\n    users_active_month = ca.users_active_month,\n    users_active_half_year = ca.users_active_half_year,\n    hot_rank = ca.hot_rank,\n    subscribers_local = ca.subscribers_local,\n    interactions_month = ca.interactions_month,\n    report_count = ca.report_count,\n    unresolved_report_count = ca.unresolved_report_count\nFROM\n    community_aggregates AS ca\nWHERE\n    community.id = ca.community_id\n    AND (ca.subscribers != 1\n        OR ca.posts != 0\n        OR ca.comments != 0\n        OR ca.users_active_day != 0\n        OR ca.users_active_week != 0\n        OR ca.users_active_month != 0\n        OR ca.users_active_half_year != 0\n        OR ca.hot_rank != 0\n        OR ca.subscribers_local != 0\n        OR ca.interactions_month != 0\n        OR ca.report_count != 0\n        OR ca.unresolved_report_count != 0);\n\nDROP TABLE community_aggregates;\n\n-- Re-enable triggers after upserts\nALTER TABLE community ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'community');\n\n-- reindex\nREINDEX TABLE community;\n\nCREATE INDEX idx_community_hot ON public.community USING btree (hot_rank DESC);\n\nCREATE INDEX idx_community_nonzero_hotrank ON community USING btree (published)\nWHERE (hot_rank <> (0)::double precision);\n\nCREATE INDEX idx_community_subscribers ON public.community USING btree (subscribers DESC);\n\nCREATE INDEX idx_community_users_active_month ON public.community USING btree (users_active_month DESC);\n\n-- merge person_aggregates into person table\nALTER TABLE person\n    ADD COLUMN post_count int NOT NULL DEFAULT 0,\n    ADD COLUMN post_score int NOT NULL DEFAULT 0,\n    ADD COLUMN comment_count int NOT NULL DEFAULT 0,\n    ADD COLUMN comment_score int NOT NULL DEFAULT 0;\n\n-- Disable the triggers temporarily\nALTER TABLE person DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'person');\n\nUPDATE\n    person\nSET\n    post_count = pa.post_count,\n    post_score = pa.post_score,\n    comment_count = pa.comment_count,\n    comment_score = pa.comment_score\nFROM\n    person_aggregates AS pa\nWHERE\n    person.id = pa.person_id\n    AND (pa.post_count != 0\n        OR pa.post_score != 0\n        OR pa.comment_count != 0\n        OR pa.comment_score != 0);\n\nDROP TABLE person_aggregates;\n\n-- Re-enable triggers after upserts\nALTER TABLE person ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'person');\n\n-- reindex\nREINDEX TABLE person;\n\n-- merge site_aggregates into local_site table\nALTER TABLE local_site\n    ADD COLUMN users int NOT NULL DEFAULT 1,\n    ADD COLUMN posts int NOT NULL DEFAULT 0,\n    ADD COLUMN comments int NOT NULL DEFAULT 0,\n    ADD COLUMN communities int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_day int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_week int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_month int NOT NULL DEFAULT 0,\n    ADD COLUMN users_active_half_year int NOT NULL DEFAULT 0;\n\n-- Disable the triggers temporarily\nALTER TABLE local_site DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_site');\n\nUPDATE\n    local_site\nSET\n    users = sa.users,\n    posts = sa.posts,\n    comments = sa.comments,\n    communities = sa.communities,\n    users_active_day = sa.users_active_day,\n    users_active_week = sa.users_active_week,\n    users_active_month = sa.users_active_month,\n    users_active_half_year = sa.users_active_half_year\nFROM\n    site_aggregates AS sa\nWHERE\n    local_site.site_id = sa.site_id;\n\nDROP TABLE site_aggregates;\n\n-- Re-enable triggers after upserts\nALTER TABLE local_site ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_site');\n\n-- reindex\nREINDEX TABLE local_site;\n\n-- merge local_user_vote_display_mode into local_user table\nALTER TABLE local_user\n    ADD COLUMN show_score boolean NOT NULL DEFAULT FALSE,\n    ADD COLUMN show_upvotes boolean NOT NULL DEFAULT TRUE,\n    ADD COLUMN show_downvotes boolean NOT NULL DEFAULT TRUE,\n    ADD COLUMN show_upvote_percentage boolean NOT NULL DEFAULT FALSE;\n\n-- Disable the triggers temporarily\nALTER TABLE local_user DISABLE TRIGGER ALL;\n\n-- disable all table indexes\nUPDATE\n    pg_index\nSET\n    indisready = FALSE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_user');\n\nUPDATE\n    local_user\nSET\n    show_score = v.score,\n    show_upvotes = v.upvotes,\n    show_downvotes = v.downvotes,\n    show_upvote_percentage = v.upvote_percentage\nFROM\n    local_user_vote_display_mode AS v\nWHERE\n    local_user.id = v.local_user_id\n    AND (v.score\n        OR NOT v.upvotes\n        OR NOT v.downvotes\n        OR v.upvote_percentage);\n\nDROP TABLE local_user_vote_display_mode;\n\n-- Re-enable triggers after upserts\nALTER TABLE local_user ENABLE TRIGGER ALL;\n\n-- Re-enable indexes\nUPDATE\n    pg_index\nSET\n    indisready = TRUE\nWHERE\n    indrelid = (\n        SELECT\n            oid\n        FROM\n            pg_class\n        WHERE\n            relname = 'local_user');\n\n-- reindex\nREINDEX TABLE local_user;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000042_community-hidden-visibility/down.sql",
    "content": "-- recreate columns in the original order\nALTER TABLE community\n    ADD COLUMN hidden bool DEFAULT FALSE NOT NULL,\n    ADD COLUMN visibility_new community_visibility DEFAULT 'Public';\n\nUPDATE\n    community\nSET\n    visibility_new = visibility;\n\nALTER TABLE community\n    DROP COLUMN visibility;\n\nALTER TABLE community RENAME COLUMN visibility_new TO visibility;\n\n-- same changes as up.sql, but the other way round\nUPDATE\n    community\nSET\n    (hidden,\n        visibility) = (TRUE,\n        'Public')\nWHERE\n    visibility = 'Unlisted';\n\nALTER TYPE community_visibility RENAME VALUE 'LocalOnlyPrivate' TO 'LocalOnly';\n\nALTER TYPE community_visibility RENAME TO community_visibility__;\n\nCREATE TYPE community_visibility AS enum (\n    'Public',\n    'LocalOnly',\n    'Private'\n);\n\nALTER TABLE community\n    ALTER COLUMN visibility DROP DEFAULT;\n\nALTER TABLE community\n    ALTER COLUMN visibility TYPE community_visibility\n    USING visibility::text::community_visibility;\n\nALTER TABLE community\n    ALTER COLUMN visibility SET DEFAULT 'Public',\n    ALTER COLUMN visibility SET NOT NULL;\n\nCREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw)\nWHERE\n    NOT (deleted OR removed OR visibility = 'Private');\n\nREINDEX TABLE community;\n\n-- revert modlog table changes\nCREATE TABLE mod_hide_community (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz DEFAULT now(),\n    reason text,\n    hidden boolean DEFAULT FALSE NOT NULL,\n    CONSTRAINT mod_hide_community_when__not_null NOT NULL published\n);\n\nALTER TABLE modlog_combined\n    DROP COLUMN mod_change_community_visibility_id,\n    ADD COLUMN mod_hide_community_id int REFERENCES mod_hide_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_lock_post_id_new int,\n    ADD COLUMN mod_remove_comment_id_new int,\n    ADD COLUMN mod_remove_community_id_new int,\n    ADD COLUMN mod_remove_post_id_new int,\n    ADD COLUMN mod_transfer_community_id_new int;\n\nUPDATE\n    modlog_combined\nSET\n    (mod_lock_post_id_new,\n        mod_remove_comment_id_new,\n        mod_remove_community_id_new,\n        mod_remove_post_id_new,\n        mod_transfer_community_id_new) = (mod_lock_post_id,\n        mod_remove_comment_id,\n        mod_remove_community_id,\n        mod_remove_post_id,\n        mod_transfer_community_id);\n\nALTER TABLE modlog_combined\n    DROP COLUMN mod_lock_post_id,\n    DROP COLUMN mod_remove_comment_id,\n    DROP COLUMN mod_remove_community_id,\n    DROP COLUMN mod_remove_post_id,\n    DROP COLUMN mod_transfer_community_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_lock_post_id_new TO mod_lock_post_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_remove_comment_id_new TO mod_remove_comment_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_remove_community_id_new TO mod_remove_community_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_remove_post_id_new TO mod_remove_post_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_transfer_community_id_new TO mod_transfer_community_id;\n\nALTER TABLE modlog_combined\n    ADD CONSTRAINT modlog_combined_mod_hide_community_id_key UNIQUE (mod_hide_community_id),\n    ADD CONSTRAINT modlog_combined_mod_lock_post_id_key UNIQUE (mod_lock_post_id),\n    ADD CONSTRAINT modlog_combined_mod_remove_comment_id_key UNIQUE (mod_remove_comment_id),\n    ADD CONSTRAINT modlog_combined_mod_remove_community_id_key UNIQUE (mod_remove_community_id),\n    ADD CONSTRAINT modlog_combined_mod_remove_post_id_key UNIQUE (mod_remove_post_id),\n    ADD CONSTRAINT modlog_combined_mod_transfer_community_id_key UNIQUE (mod_transfer_community_id),\n    ADD CONSTRAINT modlog_combined_mod_lock_post_id_fkey FOREIGN KEY (mod_lock_post_id) REFERENCES mod_lock_post (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_mod_remove_comment_id_fkey FOREIGN KEY (mod_remove_comment_id) REFERENCES mod_remove_comment (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_mod_remove_community_id_fkey FOREIGN KEY (mod_remove_community_id) REFERENCES mod_remove_community (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_mod_remove_post_id_fkey FOREIGN KEY (mod_remove_post_id) REFERENCES mod_remove_post (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_mod_transfer_community_id_fkey FOREIGN KEY (mod_transfer_community_id) REFERENCES mod_transfer_community (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_check CHECK ((num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_hide_community_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1));\n\nDROP TABLE mod_change_community_visibility;\n\nDROP TYPE community_visibility__;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000042_community-hidden-visibility/up.sql",
    "content": "-- Change community.visibility to allow values:\n-- ('Public', 'LocalOnlyPublic', 'LocalOnlyPrivate','Private', 'Hidden')\n-- rename old enum and add new one\nALTER TYPE community_visibility RENAME TO community_visibility__;\n\nCREATE TYPE community_visibility AS enum (\n    'Public',\n    'LocalOnlyPublic',\n    'LocalOnly',\n    'Private',\n    'Unlisted'\n);\n\n-- drop default value and index which reference old enum\nALTER TABLE community\n    ALTER COLUMN visibility DROP DEFAULT;\n\nDROP INDEX idx_community_random_number;\n\n-- change the column type\nALTER TABLE community\n    ALTER COLUMN visibility TYPE community_visibility\n    USING visibility::text::community_visibility;\n\n-- add default and index back in\nALTER TABLE community\n    ALTER COLUMN visibility SET DEFAULT 'Public';\n\nCREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw)\nWHERE\n    NOT (deleted OR removed OR visibility = 'Private' OR visibility = 'Unlisted');\n\nDROP TYPE community_visibility__ CASCADE;\n\nALTER TYPE community_visibility RENAME VALUE 'LocalOnly' TO 'LocalOnlyPrivate';\n\n-- write hidden value to visibility column\nUPDATE\n    community\nSET\n    visibility = 'Unlisted'\nWHERE\n    hidden;\n\n-- drop the old hidden column\nALTER TABLE community\n    DROP COLUMN hidden;\n\n-- change modlog tables\nALTER TABLE modlog_combined\n    DROP COLUMN mod_hide_community_id;\n\nDROP TABLE mod_hide_community;\n\nCREATE TABLE mod_change_community_visibility (\n    id serial PRIMARY KEY,\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published timestamptz NOT NULL DEFAULT now(),\n    reason text,\n    visibility community_visibility NOT NULL\n);\n\nALTER TABLE modlog_combined\n    ADD COLUMN mod_change_community_visibility_id int REFERENCES mod_change_community_visibility (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_combined_check CHECK ((num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1));\n\n"
  },
  {
    "path": "migrations/2025-08-01-000043_community-local-removed/down.sql",
    "content": "ALTER TABLE community\n    DROP COLUMN local_removed;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000043_community-local-removed/up.sql",
    "content": "-- Same for remote community, local removal should not be overwritten by\n-- remove+restore on home instance\nALTER TABLE community\n    ADD COLUMN local_removed boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000044_post_comment_pending/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN federation_pending;\n\nALTER TABLE comment\n    DROP COLUMN federation_pending;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000044_post_comment_pending/up.sql",
    "content": "-- When posting to a remote community mark it as pending until it gets announced back to us.\n-- This way the posts of banned users wont appear in the community on other instances.\nALTER TABLE post\n    ADD COLUMN federation_pending boolean NOT NULL DEFAULT FALSE;\n\nALTER TABLE comment\n    ADD COLUMN federation_pending boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000045_site_person_ban/down.sql",
    "content": "ALTER TABLE mod_ban\n    DROP COLUMN instance_id;\n\nALTER TABLE person\n    ADD COLUMN banned boolean DEFAULT FALSE,\n    ADD CONSTRAINT user__banned_not_null NOT NULL banned,\n    ADD COLUMN published_new timestamp with time zone DEFAULT now() NOT NULL,\n    ADD COLUMN updated_new timestamp with time zone,\n    ADD COLUMN ap_id_new varchar(255) DEFAULT generate_unique_changeme () NOT NULL,\n    ADD COLUMN bio_new text,\n    ADD COLUMN local_new boolean DEFAULT TRUE,\n    ADD COLUMN private_key_new text,\n    ADD COLUMN public_key_new text,\n    ADD COLUMN last_refreshed_at_new timestamptz DEFAULT now() NOT NULL,\n    ADD COLUMN banner_new text,\n    ADD COLUMN deleted_new boolean NOT NULL DEFAULT FALSE,\n    ADD COLUMN inbox_url_new varchar(255) DEFAULT generate_unique_changeme () NOT NULL,\n    ADD COLUMN matrix_user_id_new text,\n    ADD COLUMN bot_account_new boolean DEFAULT FALSE,\n    ADD COLUMN ban_expires timestamptz,\n    ADD COLUMN instance_id_new int;\n\nUPDATE\n    person\nSET\n    (published_new,\n        updated_new,\n        ap_id_new,\n        bio_new,\n        local_new,\n        private_key_new,\n        public_key_new,\n        last_refreshed_at_new,\n        banner_new,\n        deleted_new,\n        inbox_url_new,\n        matrix_user_id_new,\n        bot_account_new,\n        instance_id_new) = (published,\n        updated,\n        ap_id,\n        bio,\n        local,\n        private_key,\n        public_key,\n        last_refreshed_at,\n        banner,\n        deleted,\n        inbox_url,\n        matrix_user_id,\n        bot_account,\n        instance_id);\n\nALTER TABLE person\n    DROP COLUMN published,\n    DROP COLUMN updated,\n    DROP COLUMN ap_id,\n    DROP COLUMN bio,\n    DROP COLUMN local,\n    DROP COLUMN private_key,\n    DROP COLUMN public_key,\n    DROP COLUMN last_refreshed_at,\n    DROP COLUMN banner,\n    DROP COLUMN deleted,\n    DROP COLUMN inbox_url,\n    DROP COLUMN matrix_user_id,\n    DROP COLUMN bot_account,\n    DROP COLUMN instance_id;\n\nALTER TABLE person RENAME COLUMN published_new TO published;\n\nALTER TABLE person RENAME COLUMN updated_new TO updated;\n\nALTER TABLE person RENAME COLUMN ap_id_new TO ap_id;\n\nALTER TABLE person RENAME COLUMN bio_new TO bio;\n\nALTER TABLE person RENAME COLUMN local_new TO local;\n\nALTER TABLE person\n    ADD CONSTRAINT user__local_not_null NOT NULL local;\n\nALTER TABLE person RENAME COLUMN private_key_new TO private_key;\n\nALTER TABLE person RENAME COLUMN public_key_new TO public_key;\n\nALTER TABLE person RENAME COLUMN last_refreshed_at_new TO last_refreshed_at;\n\nALTER TABLE person RENAME COLUMN banner_new TO banner;\n\nALTER TABLE person RENAME COLUMN deleted_new TO deleted;\n\nALTER TABLE person RENAME COLUMN inbox_url_new TO inbox_url;\n\nALTER TABLE person RENAME COLUMN matrix_user_id_new TO matrix_user_id;\n\nALTER TABLE person RENAME COLUMN bot_account_new TO bot_account;\n\nALTER TABLE person\n    ALTER COLUMN bot_account SET NOT NULL;\n\nALTER TABLE person RENAME COLUMN instance_id_new TO instance_id;\n\nALTER TABLE person RENAME CONSTRAINT person_ap_id_new_not_null TO user__actor_id_not_null;\n\nALTER TABLE person RENAME CONSTRAINT person_deleted_new_not_null TO user__deleted_not_null;\n\nALTER TABLE person RENAME CONSTRAINT person_inbox_url_new_not_null TO person_shared_inbox_url_not_null;\n\nALTER TABLE person RENAME CONSTRAINT person_last_refreshed_at_new_not_null TO user__last_refreshed_at_not_null;\n\nALTER TABLE person RENAME CONSTRAINT person_published_new_not_null TO user__published_not_null;\n\nALTER TABLE person\n    ALTER public_key SET NOT NULL,\n    ALTER instance_id SET NOT NULL,\n    ADD CONSTRAINT idx_person_actor_id UNIQUE (ap_id);\n\nCREATE INDEX idx_person_local_instance ON person USING btree (local DESC, instance_id);\n\nCREATE UNIQUE INDEX idx_person_lower_actor_id ON person USING btree (lower((ap_id)::text));\n\nCREATE INDEX idx_person_published ON person USING btree (published DESC);\n\nALTER TABLE ONLY person\n    ADD CONSTRAINT person_instance_id_fkey FOREIGN KEY (instance_id) REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- write existing bans into person table\nUPDATE\n    person\nSET\n    (banned,\n        ban_expires) = (TRUE,\n        subquery.expires)\nFROM (\n    SELECT\n        instance_actions.ban_expires AS expires\n    FROM\n        instance_actions\n        INNER JOIN instance ON instance_actions.instance_id = instance.id\n        INNER JOIN person ON person.instance_id = instance.id\n    WHERE\n        instance_actions.received_ban != NULL) AS subquery;\n\nALTER TABLE instance_actions\n    DROP COLUMN received_ban,\n    DROP COLUMN ban_expires;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000045_site_person_ban/up.sql",
    "content": "ALTER TABLE instance_actions\n    ADD COLUMN received_ban timestamptz;\n\nALTER TABLE instance_actions\n    ADD COLUMN ban_expires timestamptz;\n\nALTER TABLE mod_ban\n    ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE;\n\nUPDATE\n    mod_ban\nSET\n    instance_id = person.instance_id\nFROM\n    person\nWHERE\n    mod_ban.instance_id IS NULL\n    AND mod_ban.mod_person_id = person.id;\n\nALTER TABLE mod_ban\n    ALTER COLUMN instance_id SET NOT NULL,\n    ALTER CONSTRAINT mod_ban_instance_id_fkey NOT DEFERRABLE;\n\n-- insert existing bans into instance_actions table, assuming they were all banned from home instance\nINSERT INTO instance_actions (person_id, instance_id, received_ban, ban_expires)\nSELECT\n    id,\n    instance_id,\n    now(),\n    ban_expires\nFROM\n    person\nWHERE\n    banned;\n\nALTER TABLE person\n    DROP COLUMN banned;\n\nALTER TABLE person\n    DROP COLUMN ban_expires;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000047_disable-email-notifications/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN disable_email_notifications;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000047_disable-email-notifications/up.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN disable_email_notifications bool NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000048_cursor_pagination_indexes/down.sql",
    "content": "DROP INDEX idx_tagline_published_id;\n\nDROP INDEX idx_comment_actions_like_score;\n\nDROP INDEX idx_post_actions_like_score;\n\n-- Fixing the community sorts for an id tie-breaker\nDROP INDEX idx_community_lower_name;\n\nDROP INDEX idx_community_hot;\n\nDROP INDEX idx_community_published;\n\nDROP INDEX idx_community_subscribers;\n\nDROP INDEX idx_community_title;\n\nDROP INDEX idx_community_users_active_month;\n\nCREATE INDEX idx_community_lower_name ON community USING btree (lower((name)::text));\n\nCREATE INDEX idx_community_hot ON community USING btree (hot_rank DESC);\n\nCREATE INDEX idx_community_published ON community USING btree (published DESC);\n\nCREATE INDEX idx_community_subscribers ON community USING btree (subscribers DESC);\n\nCREATE INDEX idx_community_title ON community USING btree (title);\n\nCREATE INDEX idx_community_users_active_month ON community USING btree (users_active_month DESC);\n\n-- Drop the missing ones.\nDROP INDEX idx_community_users_active_half_year;\n\nDROP INDEX idx_community_users_active_week;\n\nDROP INDEX idx_community_users_active_day;\n\nDROP INDEX idx_community_subscribers_local;\n\nDROP INDEX idx_community_comments;\n\nDROP INDEX idx_community_posts;\n\n-- Fix the post reverse_timestamp key sorts.\nDROP INDEX idx_post_community_published;\n\nDROP INDEX idx_post_featured_community_published;\n\nCREATE INDEX idx_post_featured_community_published_asc ON post USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, id DESC);\n\nCREATE INDEX idx_post_featured_local_published_asc ON post USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, id DESC);\n\nCREATE INDEX idx_post_published_asc ON post USING btree (reverse_timestamp_sort (published) DESC);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000048_cursor_pagination_indexes/up.sql",
    "content": "-- Taglines\nCREATE INDEX idx_tagline_published_id ON tagline (published DESC, id DESC);\n\n-- Some for the vote views\nCREATE INDEX idx_comment_actions_like_score ON comment_actions (comment_id, vote_is_upvote, person_id)\nWHERE\n    vote_is_upvote IS NOT NULL;\n\nCREATE INDEX idx_post_actions_like_score ON post_actions (post_id, vote_is_upvote, person_id)\nWHERE\n    vote_is_upvote IS NOT NULL;\n\n-- Fixing the community sorts for an id tie-breaker\nDROP INDEX idx_community_lower_name;\n\nDROP INDEX idx_community_hot;\n\nDROP INDEX idx_community_published;\n\nDROP INDEX idx_community_subscribers;\n\nDROP INDEX idx_community_title;\n\nDROP INDEX idx_community_users_active_month;\n\nCREATE INDEX idx_community_lower_name ON community USING btree (lower((name)::text) DESC, id DESC);\n\nCREATE INDEX idx_community_hot ON community USING btree (hot_rank DESC, id DESC);\n\nCREATE INDEX idx_community_published ON community USING btree (published DESC, id DESC);\n\nCREATE INDEX idx_community_subscribers ON community USING btree (subscribers DESC, id DESC);\n\nCREATE INDEX idx_community_title ON community USING btree (title DESC, id DESC);\n\nCREATE INDEX idx_community_users_active_month ON community USING btree (users_active_month DESC, id DESC);\n\n-- Create a few missing ones\nCREATE INDEX idx_community_users_active_half_year ON community USING btree (users_active_half_year DESC, id DESC);\n\nCREATE INDEX idx_community_users_active_week ON community USING btree (users_active_week DESC, id DESC);\n\nCREATE INDEX idx_community_users_active_day ON community USING btree (users_active_day DESC, id DESC);\n\nCREATE INDEX idx_community_subscribers_local ON community USING btree (subscribers_local DESC, id DESC);\n\nCREATE INDEX idx_community_comments ON community USING btree (comments DESC, id DESC);\n\nCREATE INDEX idx_community_posts ON community USING btree (posts DESC, id DESC);\n\n-- Fix the post reverse_timestamp key sorts.\nDROP INDEX idx_post_featured_community_published_asc;\n\nDROP INDEX idx_post_featured_local_published_asc;\n\nDROP INDEX idx_post_published_asc;\n\nCREATE INDEX idx_post_featured_community_published ON post USING btree (community_id, featured_community DESC, published DESC, id DESC);\n\nCREATE INDEX idx_post_community_published ON post USING btree (community_id, published DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000049_add_liked_combined/down.sql",
    "content": "DROP TABLE person_liked_combined;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000049_add_liked_combined/up.sql",
    "content": "-- Creates combined tables for\n-- person_liked: (comment, post)\n-- This one is special, because you use the liked date, not the ordinary published\n-- Updating the history\nCREATE SEQUENCE person_liked_combined_id_seq\n    AS integer START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\nCREATE TABLE person_liked_combined AS\nSELECT\n    pa.liked,\n    -- `ADD COLUMN id serial` is not used for this because it would require either putting the column at the end (might increase the amount of padding bytes) or using an `INSERT` statement (not parallelizable).\n    nextval('person_liked_combined_id_seq'::regclass)::int AS id,\n    pa.person_id,\n    po.creator_id,\n    pa.post_id,\n    NULL::int AS comment_id,\n    pa.vote_is_upvote\nFROM\n    post_actions pa,\n    person p,\n    post po\nWHERE\n    pa.liked IS NOT NULL\n    AND p.local = TRUE\n    AND pa.person_id = p.id\n    AND pa.post_id = po.id\nUNION ALL\nSELECT\n    ca.liked,\n    nextval('person_liked_combined_id_seq'::regclass)::int,\n    ca.person_id,\n    co.creator_id,\n    NULL::int,\n    ca.comment_id,\n    ca.vote_is_upvote\nFROM\n    comment_actions ca,\n    person p,\n    comment co\nWHERE\n    liked IS NOT NULL\n    AND p.local = TRUE\n    AND ca.person_id = p.id\n    AND ca.comment_id = co.id;\n\nALTER TABLE person_liked_combined\n    ALTER COLUMN id SET DEFAULT nextval('person_liked_combined_id_seq'::regclass),\n    ALTER COLUMN liked SET NOT NULL,\n    ALTER COLUMN vote_is_upvote SET NOT NULL,\n    ALTER COLUMN person_id SET NOT NULL,\n    ALTER COLUMN creator_id SET NOT NULL,\n    ADD CONSTRAINT person_liked_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_liked_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_liked_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT person_liked_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD UNIQUE (person_id, post_id),\n    ADD UNIQUE (person_id, comment_id),\n    ADD PRIMARY KEY (id),\n    ADD CONSTRAINT person_liked_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1);\n\nALTER SEQUENCE person_liked_combined_id_seq OWNED BY person_liked_combined.id;\n\nCREATE INDEX idx_person_liked_combined_person ON person_liked_combined (person_id);\n\nCREATE INDEX idx_person_liked_combined_creator ON person_liked_combined (creator_id);\n\nCREATE INDEX idx_person_liked_combined_person_voted_at ON person_liked_combined (person_id, liked DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000050_show_downvotes_for_others_only/down.sql",
    "content": "ALTER TABLE local_user\n    ALTER COLUMN show_downvotes DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN show_downvotes TYPE boolean\n    USING\n        CASE show_downvotes\n        WHEN 'Hide' THEN\n            FALSE\n        ELSE\n            TRUE\n        END;\n\n-- Make true the default\nALTER TABLE local_user\n    ALTER COLUMN show_downvotes SET DEFAULT TRUE;\n\nDROP TYPE vote_show_enum;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000050_show_downvotes_for_others_only/up.sql",
    "content": "-- This changes the local_user.show_downvotes column to an enum,\n-- which by default shows all downvotes.\nCREATE TYPE vote_show_enum AS ENUM (\n    'Show',\n    'ShowForOthers',\n    'Hide'\n);\n\nALTER TABLE local_user\n    ALTER COLUMN show_downvotes DROP DEFAULT;\n\nALTER TABLE local_user\n    ALTER COLUMN show_downvotes TYPE vote_show_enum\n    USING\n        CASE show_downvotes\n        WHEN FALSE THEN\n            'Hide'\n        ELSE\n            'Show'\n        END::vote_show_enum;\n\n-- Make ShowForOthers the default\nALTER TABLE local_user\n    ALTER COLUMN show_downvotes SET DEFAULT 'Show';\n\n"
  },
  {
    "path": "migrations/2025-08-01-000051_local_image_person/down.sql",
    "content": "ALTER TABLE local_image\n    ADD COLUMN local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nUPDATE\n    local_image AS li\nSET\n    local_user_id = lu.id\nFROM\n    local_user AS lu\nWHERE\n    li.person_id = lu.person_id;\n\n-- You need to have the exact correct column order, so this needs to be re-created\n--\n-- Rename the table\nALTER TABLE local_image RENAME TO local_image_old;\n\n-- Rename a few constraints\nALTER TABLE local_image_old RENAME CONSTRAINT image_upload_pkey TO image_upload_pkey_old;\n\n-- Create the old one again\nCREATE TABLE local_image (\n    local_user_id integer,\n    pictrs_alias text,\n    published timestamp with time zone DEFAULT now(),\n    CONSTRAINT image_upload_pictrs_alias_not_null NOT NULL pictrs_alias,\n    CONSTRAINT image_upload_published_not_null NOT NULL published\n);\n\nALTER TABLE ONLY local_image\n    ADD CONSTRAINT image_upload_pkey PRIMARY KEY (pictrs_alias);\n\nCREATE INDEX idx_image_upload_local_user_id ON local_image USING btree (local_user_id);\n\nALTER TABLE ONLY local_image\n    ADD CONSTRAINT image_upload_local_user_id_fkey FOREIGN KEY (local_user_id) REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Insert the data again\nINSERT INTO local_image (local_user_id, pictrs_alias, published)\nSELECT\n    local_user_id,\n    pictrs_alias,\n    published\nFROM\n    local_image_old;\n\nDROP TABLE local_image_old;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000051_local_image_person/up.sql",
    "content": "-- Since local thumbnails could be generated from posts of external users,\n-- use the person_id instead of local_user_id for the LocalImage table.\n--\n-- Also connect the thumbnail to a post id.\n--\n-- See https://github.com/LemmyNet/lemmy/issues/5564\nALTER TABLE local_image\n    ADD COLUMN person_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE,\n    ADD COLUMN thumbnail_for_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE;\n\n-- Update historical person_id columns\n-- Note: The local_user_id rows are null for thumbnails, so there's nothing you can do there.\nUPDATE\n    local_image AS li\nSET\n    person_id = lu.person_id\nFROM\n    local_user AS lu\nWHERE\n    li.local_user_id = lu.id;\n\n-- Remove the local_user_id column\nALTER TABLE local_image\n    DROP COLUMN local_user_id;\n\nCREATE INDEX idx_image_upload_person_id ON local_image (person_id);\n\nALTER TABLE local_image\n    ALTER CONSTRAINT local_image_person_id_fkey NOT DEFERRABLE,\n    ALTER CONSTRAINT local_image_thumbnail_for_post_id_fkey NOT DEFERRABLE;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000052_lock_reason/down.sql",
    "content": "ALTER TABLE mod_lock_post\n    DROP COLUMN reason;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000052_lock_reason/up.sql",
    "content": "-- Adding a lock reason field to mod_lock_post\nALTER TABLE mod_lock_post\n    ADD COLUMN reason text;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000053_remove_hide_modlog_names/down.sql",
    "content": "-- You need to remake all the columns after the changed one.\n--\n-- 1. Create old column, and add _new to every one after\n-- 2. Update the _new to the old\n-- 3. Drop the old\n-- 4. Rename the new\nALTER TABLE local_site\n    ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL,\n    ADD COLUMN application_email_admins_new boolean DEFAULT FALSE,\n    ADD COLUMN slur_filter_regex_new text,\n    ADD COLUMN actor_name_max_length_new integer DEFAULT 20,\n    ADD COLUMN federation_enabled_new boolean DEFAULT TRUE,\n    ADD COLUMN captcha_enabled_new boolean DEFAULT FALSE,\n    ADD COLUMN captcha_difficulty_new character varying(255) DEFAULT 'medium'::character varying,\n    ADD COLUMN published_new timestamp with time zone DEFAULT now(),\n    ADD COLUMN updated_new timestamp with time zone,\n    ADD COLUMN registration_mode_new public.registration_mode_enum DEFAULT 'RequireApplication'::public.registration_mode_enum,\n    ADD COLUMN reports_email_admins_new boolean DEFAULT FALSE,\n    ADD COLUMN federation_signed_fetch_new boolean DEFAULT TRUE,\n    ADD COLUMN default_post_listing_mode_new public.post_listing_mode_enum DEFAULT 'List'::public.post_listing_mode_enum,\n    ADD COLUMN default_post_sort_type_new public.post_sort_type_enum DEFAULT 'Active'::public.post_sort_type_enum,\n    ADD COLUMN default_comment_sort_type_new public.comment_sort_type_enum DEFAULT 'Hot'::public.comment_sort_type_enum,\n    ADD COLUMN oauth_registration_new boolean DEFAULT TRUE,\n    ADD COLUMN post_upvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum,\n    ADD COLUMN post_downvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum,\n    ADD COLUMN comment_upvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum,\n    ADD COLUMN comment_downvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum,\n    ADD COLUMN default_post_time_range_seconds_new integer,\n    ADD COLUMN disallow_nsfw_content_new boolean DEFAULT FALSE,\n    ADD COLUMN users_new int DEFAULT 1,\n    ADD COLUMN posts_new int DEFAULT 0,\n    ADD COLUMN comments_new int DEFAULT 0,\n    ADD COLUMN communities_new int DEFAULT 0,\n    ADD COLUMN users_active_day_new int DEFAULT 0,\n    ADD COLUMN users_active_week_new int DEFAULT 0,\n    ADD COLUMN users_active_month_new int DEFAULT 0,\n    ADD COLUMN users_active_half_year_new int DEFAULT 0,\n    ADD COLUMN disable_email_notifications_new boolean DEFAULT FALSE;\n\n-- Update\nUPDATE\n    local_site\nSET\n    (application_email_admins_new,\n        slur_filter_regex_new,\n        actor_name_max_length_new,\n        federation_enabled_new,\n        captcha_enabled_new,\n        captcha_difficulty_new,\n        published_new,\n        updated_new,\n        registration_mode_new,\n        reports_email_admins_new,\n        federation_signed_fetch_new,\n        default_post_listing_mode_new,\n        default_post_sort_type_new,\n        default_comment_sort_type_new,\n        oauth_registration_new,\n        post_upvotes_new,\n        post_downvotes_new,\n        comment_upvotes_new,\n        comment_downvotes_new,\n        default_post_time_range_seconds_new,\n        disallow_nsfw_content_new,\n        users_new,\n        posts_new,\n        comments_new,\n        communities_new,\n        users_active_day_new,\n        users_active_week_new,\n        users_active_month_new,\n        users_active_half_year_new,\n        disable_email_notifications_new) = (application_email_admins,\n        slur_filter_regex,\n        actor_name_max_length,\n        federation_enabled,\n        captcha_enabled,\n        captcha_difficulty,\n        published,\n        updated,\n        registration_mode,\n        reports_email_admins,\n        federation_signed_fetch,\n        default_post_listing_mode,\n        default_post_sort_type,\n        default_comment_sort_type,\n        oauth_registration,\n        post_upvotes,\n        post_downvotes,\n        comment_upvotes,\n        comment_downvotes,\n        default_post_time_range_seconds,\n        disallow_nsfw_content,\n        users,\n        posts,\n        comments,\n        communities,\n        users_active_day,\n        users_active_week,\n        users_active_month,\n        users_active_half_year,\n        disable_email_notifications);\n\n-- Drop\nALTER TABLE local_site\n    DROP COLUMN application_email_admins,\n    DROP COLUMN slur_filter_regex,\n    DROP COLUMN actor_name_max_length,\n    DROP COLUMN federation_enabled,\n    DROP COLUMN captcha_enabled,\n    DROP COLUMN captcha_difficulty,\n    DROP COLUMN published,\n    DROP COLUMN updated,\n    DROP COLUMN registration_mode,\n    DROP COLUMN reports_email_admins,\n    DROP COLUMN federation_signed_fetch,\n    DROP COLUMN default_post_listing_mode,\n    DROP COLUMN default_post_sort_type,\n    DROP COLUMN default_comment_sort_type,\n    DROP COLUMN oauth_registration,\n    DROP COLUMN post_upvotes,\n    DROP COLUMN post_downvotes,\n    DROP COLUMN comment_upvotes,\n    DROP COLUMN comment_downvotes,\n    DROP COLUMN default_post_time_range_seconds,\n    DROP COLUMN disallow_nsfw_content,\n    DROP COLUMN users,\n    DROP COLUMN posts,\n    DROP COLUMN comments,\n    DROP COLUMN communities,\n    DROP COLUMN users_active_day,\n    DROP COLUMN users_active_week,\n    DROP COLUMN users_active_month,\n    DROP COLUMN users_active_half_year,\n    DROP COLUMN disable_email_notifications;\n\n-- Rename\nALTER TABLE local_site RENAME COLUMN application_email_admins_new TO application_email_admins;\n\nALTER TABLE local_site RENAME COLUMN slur_filter_regex_new TO slur_filter_regex;\n\nALTER TABLE local_site RENAME COLUMN actor_name_max_length_new TO actor_name_max_length;\n\nALTER TABLE local_site RENAME COLUMN federation_enabled_new TO federation_enabled;\n\nALTER TABLE local_site RENAME COLUMN captcha_enabled_new TO captcha_enabled;\n\nALTER TABLE local_site RENAME COLUMN captcha_difficulty_new TO captcha_difficulty;\n\nALTER TABLE local_site RENAME COLUMN published_new TO published;\n\nALTER TABLE local_site RENAME COLUMN updated_new TO updated;\n\nALTER TABLE local_site RENAME COLUMN registration_mode_new TO registration_mode;\n\nALTER TABLE local_site RENAME COLUMN reports_email_admins_new TO reports_email_admins;\n\nALTER TABLE local_site RENAME COLUMN federation_signed_fetch_new TO federation_signed_fetch;\n\nALTER TABLE local_site RENAME COLUMN default_post_listing_mode_new TO default_post_listing_mode;\n\nALTER TABLE local_site RENAME COLUMN default_post_sort_type_new TO default_post_sort_type;\n\nALTER TABLE local_site RENAME COLUMN default_comment_sort_type_new TO default_comment_sort_type;\n\nALTER TABLE local_site RENAME COLUMN oauth_registration_new TO oauth_registration;\n\nALTER TABLE local_site RENAME COLUMN post_upvotes_new TO post_upvotes;\n\nALTER TABLE local_site RENAME COLUMN post_downvotes_new TO post_downvotes;\n\nALTER TABLE local_site RENAME COLUMN comment_upvotes_new TO comment_upvotes;\n\nALTER TABLE local_site RENAME COLUMN comment_downvotes_new TO comment_downvotes;\n\nALTER TABLE local_site RENAME COLUMN default_post_time_range_seconds_new TO default_post_time_range_seconds;\n\nALTER TABLE local_site RENAME COLUMN disallow_nsfw_content_new TO disallow_nsfw_content;\n\nALTER TABLE local_site RENAME COLUMN users_new TO users;\n\nALTER TABLE local_site RENAME COLUMN posts_new TO posts;\n\nALTER TABLE local_site RENAME COLUMN comments_new TO comments;\n\nALTER TABLE local_site RENAME COLUMN communities_new TO communities;\n\nALTER TABLE local_site RENAME COLUMN users_active_day_new TO users_active_day;\n\nALTER TABLE local_site RENAME COLUMN users_active_week_new TO users_active_week;\n\nALTER TABLE local_site RENAME COLUMN users_active_month_new TO users_active_month;\n\nALTER TABLE local_site RENAME COLUMN users_active_half_year_new TO users_active_half_year;\n\nALTER TABLE local_site RENAME COLUMN disable_email_notifications_new TO disable_email_notifications;\n\nALTER TABLE local_site\n    ALTER COLUMN application_email_admins SET NOT NULL,\n    ALTER COLUMN actor_name_max_length SET NOT NULL,\n    ALTER COLUMN captcha_difficulty SET NOT NULL,\n    ALTER COLUMN captcha_enabled SET NOT NULL,\n    ALTER COLUMN comment_downvotes SET NOT NULL,\n    ALTER COLUMN comments SET NOT NULL,\n    ALTER COLUMN communities SET NOT NULL,\n    ALTER COLUMN comment_upvotes SET NOT NULL,\n    ALTER COLUMN default_comment_sort_type SET NOT NULL,\n    ALTER COLUMN default_post_listing_mode SET NOT NULL,\n    ADD CONSTRAINT local_site_default_sort_type_not_null NOT NULL default_post_sort_type,\n    ALTER COLUMN disable_email_notifications SET NOT NULL,\n    ALTER COLUMN disallow_nsfw_content SET NOT NULL,\n    ALTER COLUMN federation_enabled SET NOT NULL,\n    ALTER COLUMN federation_signed_fetch SET NOT NULL,\n    ALTER COLUMN oauth_registration SET NOT NULL,\n    ALTER COLUMN post_downvotes SET NOT NULL,\n    ALTER COLUMN post_upvotes SET NOT NULL,\n    ALTER COLUMN published SET NOT NULL,\n    ALTER COLUMN posts SET NOT NULL,\n    ALTER COLUMN users SET NOT NULL,\n    ALTER COLUMN registration_mode SET NOT NULL,\n    ALTER COLUMN reports_email_admins SET NOT NULL,\n    ALTER COLUMN users_active_day SET NOT NULL,\n    ALTER COLUMN users_active_week SET NOT NULL,\n    ALTER COLUMN users_active_month SET NOT NULL,\n    ALTER COLUMN users_active_half_year SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000053_remove_hide_modlog_names/up.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN hide_modlog_mod_names;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000054_mod-change-community-vis/down.sql",
    "content": "ALTER TABLE mod_change_community_visibility\n    ADD COLUMN reason text,\n    ADD COLUMN visibility_new community_visibility;\n\nUPDATE\n    mod_change_community_visibility\nSET\n    visibility_new = visibility;\n\nALTER TABLE mod_change_community_visibility\n    DROP COLUMN visibility;\n\nALTER TABLE mod_change_community_visibility RENAME COLUMN visibility_new TO visibility;\n\nALTER TABLE mod_change_community_visibility\n    ALTER COLUMN visibility SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000054_mod-change-community-vis/up.sql",
    "content": "ALTER TABLE mod_change_community_visibility\n    DROP COLUMN reason;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000055_rename_timestamp_add_at/down.sql",
    "content": "ALTER TABLE admin_allow_instance RENAME published_at TO published;\n\nALTER TABLE admin_block_instance RENAME COLUMN expires_at TO expires;\n\nALTER TABLE admin_block_instance RENAME COLUMN published_at TO published;\n\nALTER TABLE admin_purge_comment RENAME COLUMN published_at TO published;\n\nALTER TABLE admin_purge_community RENAME COLUMN published_at TO published;\n\nALTER TABLE admin_purge_person RENAME COLUMN published_at TO published;\n\nALTER TABLE admin_purge_post RENAME COLUMN published_at TO published;\n\nALTER TABLE captcha_answer RENAME COLUMN published_at TO published;\n\nALTER TABLE comment RENAME COLUMN published_at TO published;\n\nALTER TABLE comment RENAME COLUMN updated_at TO updated;\n\nALTER TABLE comment_actions RENAME COLUMN voted_at TO liked;\n\nALTER TABLE comment_actions RENAME COLUMN saved_at TO saved;\n\nALTER TABLE comment_reply RENAME COLUMN published_at TO published;\n\nALTER TABLE comment_report RENAME COLUMN published_at TO published;\n\nALTER TABLE comment_report RENAME COLUMN updated_at TO updated;\n\nALTER TABLE community RENAME COLUMN published_at TO published;\n\nALTER TABLE community RENAME COLUMN updated_at TO updated;\n\nALTER TABLE community_actions RENAME COLUMN followed_at TO followed;\n\nALTER TABLE community_actions RENAME COLUMN blocked_at TO blocked;\n\nALTER TABLE community_actions RENAME COLUMN became_moderator_at TO became_moderator;\n\nALTER TABLE community_actions RENAME COLUMN received_ban_at TO received_ban;\n\nALTER TABLE community_actions RENAME COLUMN ban_expires_at TO ban_expires;\n\nALTER TABLE community_report RENAME COLUMN published_at TO published;\n\nALTER TABLE community_report RENAME COLUMN updated_at TO updated;\n\nALTER TABLE custom_emoji RENAME COLUMN published_at TO published;\n\nALTER TABLE custom_emoji RENAME COLUMN updated_at TO updated;\n\nALTER TABLE email_verification RENAME COLUMN published_at TO published;\n\nALTER TABLE federation_allowlist RENAME COLUMN published_at TO published;\n\nALTER TABLE federation_allowlist RENAME COLUMN updated_at TO updated;\n\nALTER TABLE federation_blocklist RENAME COLUMN published_at TO published;\n\nALTER TABLE federation_blocklist RENAME COLUMN updated_at TO updated;\n\nALTER TABLE federation_blocklist RENAME COLUMN expires_at TO expires;\n\nALTER TABLE federation_queue_state RENAME COLUMN last_retry_at TO last_retry;\n\nALTER TABLE federation_queue_state RENAME COLUMN last_successful_published_time_at TO last_successful_published_time;\n\nALTER TABLE inbox_combined RENAME COLUMN published_at TO published;\n\nALTER TABLE instance RENAME COLUMN published_at TO published;\n\nALTER TABLE instance RENAME COLUMN updated_at TO updated;\n\nALTER TABLE instance_actions RENAME COLUMN blocked_at TO blocked;\n\nALTER TABLE instance_actions RENAME COLUMN received_ban_at TO received_ban;\n\nALTER TABLE instance_actions RENAME COLUMN ban_expires_at TO ban_expires;\n\nALTER TABLE local_image RENAME COLUMN published_at TO published;\n\nALTER TABLE local_site RENAME COLUMN published_at TO published;\n\nALTER TABLE local_site RENAME COLUMN updated_at TO updated;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN published_at TO published;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN updated_at TO updated;\n\nALTER TABLE local_site_url_blocklist RENAME COLUMN published_at TO published;\n\nALTER TABLE local_site_url_blocklist RENAME COLUMN updated_at TO updated;\n\nALTER TABLE local_user RENAME COLUMN last_donation_notification_at TO last_donation_notification;\n\nALTER TABLE login_token RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_add RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_add_community RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_ban RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_ban RENAME COLUMN expires_at TO expires;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN expires_at TO expires;\n\nALTER TABLE mod_change_community_visibility RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_feature_post RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_lock_post RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_remove_comment RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_remove_community RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_remove_post RENAME COLUMN published_at TO published;\n\nALTER TABLE mod_transfer_community RENAME COLUMN published_at TO published;\n\nALTER TABLE modlog_combined RENAME COLUMN published_at TO published;\n\nALTER TABLE oauth_account RENAME COLUMN published_at TO published;\n\nALTER TABLE oauth_account RENAME COLUMN updated_at TO updated;\n\nALTER TABLE oauth_provider RENAME COLUMN published_at TO published;\n\nALTER TABLE oauth_provider RENAME COLUMN updated_at TO updated;\n\nALTER TABLE password_reset_request RENAME COLUMN published_at TO published;\n\nALTER TABLE person RENAME COLUMN published_at TO published;\n\nALTER TABLE person RENAME COLUMN updated_at TO updated;\n\nALTER TABLE person_actions RENAME COLUMN followed_at TO followed;\n\nALTER TABLE person_actions RENAME COLUMN blocked_at TO blocked;\n\nALTER TABLE person_ban RENAME COLUMN published_at TO published;\n\nALTER TABLE person_comment_mention RENAME COLUMN published_at TO published;\n\nALTER TABLE person_content_combined RENAME COLUMN published_at TO published;\n\nALTER TABLE person_liked_combined RENAME COLUMN voted_at TO liked;\n\nALTER TABLE person_post_mention RENAME COLUMN published_at TO published;\n\nALTER TABLE person_saved_combined RENAME COLUMN saved_at TO saved;\n\nALTER TABLE post RENAME COLUMN published_at TO published;\n\nALTER TABLE post RENAME COLUMN updated_at TO updated;\n\nALTER TABLE post RENAME COLUMN scheduled_publish_time_at TO scheduled_publish_time;\n\nALTER TABLE post RENAME COLUMN newest_comment_time_at TO newest_comment_time;\n\nALTER TABLE post RENAME COLUMN newest_comment_time_necro_at TO newest_comment_time_necro;\n\nALTER TABLE post_actions RENAME COLUMN read_at TO read;\n\nALTER TABLE post_actions RENAME COLUMN read_comments_at TO read_comments;\n\nALTER TABLE post_actions RENAME COLUMN saved_at TO saved;\n\nALTER TABLE post_actions RENAME COLUMN voted_at TO liked;\n\nALTER TABLE post_actions RENAME COLUMN hidden_at TO hidden;\n\nALTER TABLE post_report RENAME COLUMN published_at TO published;\n\nALTER TABLE post_report RENAME COLUMN updated_at TO updated;\n\nALTER TABLE post_tag RENAME COLUMN published_at TO published;\n\nALTER TABLE private_message RENAME COLUMN published_at TO published;\n\nALTER TABLE private_message RENAME COLUMN updated_at TO updated;\n\nALTER TABLE private_message_report RENAME COLUMN published_at TO published;\n\nALTER TABLE private_message_report RENAME COLUMN updated_at TO updated;\n\nALTER TABLE received_activity RENAME COLUMN published_at TO published;\n\nALTER TABLE registration_application RENAME COLUMN published_at TO published;\n\nALTER TABLE remote_image RENAME COLUMN published_at TO published;\n\nALTER TABLE report_combined RENAME COLUMN published_at TO published;\n\nALTER TABLE search_combined RENAME COLUMN published_at TO published;\n\nALTER TABLE sent_activity RENAME COLUMN published_at TO published;\n\nALTER TABLE site RENAME COLUMN published_at TO published;\n\nALTER TABLE site RENAME COLUMN updated_at TO updated;\n\nALTER TABLE tag RENAME COLUMN published_at TO published;\n\nALTER TABLE tag RENAME COLUMN updated_at TO updated;\n\nALTER TABLE tagline RENAME COLUMN published_at TO published;\n\nALTER TABLE tagline RENAME COLUMN updated_at TO updated;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000055_rename_timestamp_add_at/up.sql",
    "content": "ALTER TABLE admin_allow_instance RENAME COLUMN published TO published_at;\n\nALTER TABLE admin_block_instance RENAME COLUMN expires TO expires_at;\n\nALTER TABLE admin_block_instance RENAME COLUMN published TO published_at;\n\nALTER TABLE admin_purge_comment RENAME COLUMN published TO published_at;\n\nALTER TABLE admin_purge_community RENAME COLUMN published TO published_at;\n\nALTER TABLE admin_purge_person RENAME COLUMN published TO published_at;\n\nALTER TABLE admin_purge_post RENAME COLUMN published TO published_at;\n\nALTER TABLE captcha_answer RENAME COLUMN published TO published_at;\n\nALTER TABLE comment RENAME COLUMN published TO published_at;\n\nALTER TABLE comment RENAME COLUMN updated TO updated_at;\n\nALTER TABLE comment_actions RENAME COLUMN liked TO voted_at;\n\nALTER TABLE comment_actions RENAME COLUMN saved TO saved_at;\n\nALTER TABLE comment_reply RENAME COLUMN published TO published_at;\n\nALTER TABLE comment_report RENAME COLUMN published TO published_at;\n\nALTER TABLE comment_report RENAME COLUMN updated TO updated_at;\n\nALTER TABLE community RENAME COLUMN published TO published_at;\n\nALTER TABLE community RENAME COLUMN updated TO updated_at;\n\nALTER TABLE community_actions RENAME COLUMN followed TO followed_at;\n\nALTER TABLE community_actions RENAME COLUMN blocked TO blocked_at;\n\nALTER TABLE community_actions RENAME COLUMN became_moderator TO became_moderator_at;\n\nALTER TABLE community_actions RENAME COLUMN received_ban TO received_ban_at;\n\nALTER TABLE community_actions RENAME COLUMN ban_expires TO ban_expires_at;\n\nALTER TABLE community_report RENAME COLUMN published TO published_at;\n\nALTER TABLE community_report RENAME COLUMN updated TO updated_at;\n\nALTER TABLE custom_emoji RENAME COLUMN published TO published_at;\n\nALTER TABLE custom_emoji RENAME COLUMN updated TO updated_at;\n\nALTER TABLE email_verification RENAME COLUMN published TO published_at;\n\nALTER TABLE federation_allowlist RENAME COLUMN published TO published_at;\n\nALTER TABLE federation_allowlist RENAME COLUMN updated TO updated_at;\n\nALTER TABLE federation_blocklist RENAME COLUMN published TO published_at;\n\nALTER TABLE federation_blocklist RENAME COLUMN updated TO updated_at;\n\nALTER TABLE federation_blocklist RENAME COLUMN expires TO expires_at;\n\nALTER TABLE federation_queue_state RENAME COLUMN last_retry TO last_retry_at;\n\nALTER TABLE federation_queue_state RENAME COLUMN last_successful_published_time TO last_successful_published_time_at;\n\nALTER TABLE inbox_combined RENAME COLUMN published TO published_at;\n\nALTER TABLE instance RENAME COLUMN published TO published_at;\n\nALTER TABLE instance RENAME COLUMN updated TO updated_at;\n\nALTER TABLE instance_actions RENAME COLUMN blocked TO blocked_at;\n\nALTER TABLE instance_actions RENAME COLUMN received_ban TO received_ban_at;\n\nALTER TABLE instance_actions RENAME COLUMN ban_expires TO ban_expires_at;\n\nALTER TABLE local_image RENAME COLUMN published TO published_at;\n\nALTER TABLE local_site RENAME COLUMN published TO published_at;\n\nALTER TABLE local_site RENAME COLUMN updated TO updated_at;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN published TO published_at;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN updated TO updated_at;\n\nALTER TABLE local_site_url_blocklist RENAME COLUMN published TO published_at;\n\nALTER TABLE local_site_url_blocklist RENAME COLUMN updated TO updated_at;\n\nALTER TABLE local_user RENAME COLUMN last_donation_notification TO last_donation_notification_at;\n\nALTER TABLE login_token RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_add RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_add_community RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_ban RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_ban RENAME COLUMN expires TO expires_at;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_ban_from_community RENAME COLUMN expires TO expires_at;\n\nALTER TABLE mod_change_community_visibility RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_feature_post RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_lock_post RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_remove_comment RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_remove_community RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_remove_post RENAME COLUMN published TO published_at;\n\nALTER TABLE mod_transfer_community RENAME COLUMN published TO published_at;\n\nALTER TABLE modlog_combined RENAME COLUMN published TO published_at;\n\nALTER TABLE oauth_account RENAME COLUMN published TO published_at;\n\nALTER TABLE oauth_account RENAME COLUMN updated TO updated_at;\n\nALTER TABLE oauth_provider RENAME COLUMN published TO published_at;\n\nALTER TABLE oauth_provider RENAME COLUMN updated TO updated_at;\n\nALTER TABLE password_reset_request RENAME COLUMN published TO published_at;\n\nALTER TABLE person RENAME COLUMN published TO published_at;\n\nALTER TABLE person RENAME COLUMN updated TO updated_at;\n\nALTER TABLE person_actions RENAME COLUMN followed TO followed_at;\n\nALTER TABLE person_actions RENAME COLUMN blocked TO blocked_at;\n\nALTER TABLE person_ban RENAME COLUMN published TO published_at;\n\nALTER TABLE person_comment_mention RENAME COLUMN published TO published_at;\n\nALTER TABLE person_content_combined RENAME COLUMN published TO published_at;\n\nALTER TABLE person_liked_combined RENAME COLUMN liked TO voted_at;\n\nALTER TABLE person_post_mention RENAME COLUMN published TO published_at;\n\nALTER TABLE person_saved_combined RENAME COLUMN saved TO saved_at;\n\nALTER TABLE post RENAME COLUMN published TO published_at;\n\nALTER TABLE post RENAME COLUMN updated TO updated_at;\n\nALTER TABLE post RENAME COLUMN scheduled_publish_time TO scheduled_publish_time_at;\n\nALTER TABLE post RENAME COLUMN newest_comment_time TO newest_comment_time_at;\n\nALTER TABLE post RENAME COLUMN newest_comment_time_necro TO newest_comment_time_necro_at;\n\nALTER TABLE post_actions RENAME COLUMN read TO read_at;\n\nALTER TABLE post_actions RENAME COLUMN read_comments TO read_comments_at;\n\nALTER TABLE post_actions RENAME COLUMN saved TO saved_at;\n\nALTER TABLE post_actions RENAME COLUMN liked TO voted_at;\n\nALTER TABLE post_actions RENAME COLUMN hidden TO hidden_at;\n\nALTER TABLE post_report RENAME COLUMN published TO published_at;\n\nALTER TABLE post_report RENAME COLUMN updated TO updated_at;\n\nALTER TABLE post_tag RENAME COLUMN published TO published_at;\n\nALTER TABLE private_message RENAME COLUMN published TO published_at;\n\nALTER TABLE private_message RENAME COLUMN updated TO updated_at;\n\nALTER TABLE private_message_report RENAME COLUMN published TO published_at;\n\nALTER TABLE private_message_report RENAME COLUMN updated TO updated_at;\n\nALTER TABLE received_activity RENAME COLUMN published TO published_at;\n\nALTER TABLE registration_application RENAME COLUMN published TO published_at;\n\nALTER TABLE remote_image RENAME COLUMN published TO published_at;\n\nALTER TABLE report_combined RENAME COLUMN published TO published_at;\n\nALTER TABLE search_combined RENAME COLUMN published TO published_at;\n\nALTER TABLE sent_activity RENAME COLUMN published TO published_at;\n\nALTER TABLE site RENAME COLUMN published TO published_at;\n\nALTER TABLE site RENAME COLUMN updated TO updated_at;\n\nALTER TABLE tag RENAME COLUMN published TO published_at;\n\nALTER TABLE tag RENAME COLUMN updated TO updated_at;\n\nALTER TABLE tagline RENAME COLUMN published TO published_at;\n\nALTER TABLE tagline RENAME COLUMN updated TO updated_at;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000056_person_note/down.sql",
    "content": "ALTER TABLE person_actions\n    DROP COLUMN noted_at,\n    DROP COLUMN note;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000056_person_note/up.sql",
    "content": "ALTER TABLE person_actions\n    ADD COLUMN noted_at timestamptz,\n    ADD COLUMN note text;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000057_multi-community/down.sql",
    "content": "ALTER TABLE search_combined\n    DROP CONSTRAINT search_combined_check;\n\nALTER TABLE search_combined\n    ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id) = 1);\n\nALTER TABLE search_combined\n    DROP COLUMN multi_community_id;\n\nALTER TABLE local_site\n    DROP COLUMN suggested_communities;\n\nDROP TABLE multi_community_follow;\n\nDROP TABLE multi_community_entry;\n\nDROP TABLE multi_community;\n\nCREATE TYPE listing_type_enum_tmp AS ENUM (\n    'All',\n    'Local',\n    'Subscribed',\n    'ModeratorView'\n);\n\nUPDATE\n    local_user\nSET\n    default_listing_type = 'All'\nWHERE\n    default_listing_type = 'Suggested';\n\nUPDATE\n    local_site\nSET\n    default_post_listing_type = 'All'\nWHERE\n    default_post_listing_type = 'Suggested';\n\nALTER TABLE local_user\n    ALTER COLUMN default_listing_type DROP DEFAULT,\n    ALTER COLUMN default_listing_type TYPE listing_type_enum_tmp\n    USING (default_listing_type::text::listing_type_enum_tmp),\n    ALTER COLUMN default_listing_type SET DEFAULT 'Local';\n\nALTER TABLE local_site\n    ALTER COLUMN default_post_listing_type DROP DEFAULT,\n    ALTER COLUMN default_post_listing_type TYPE listing_type_enum_tmp\n    USING (default_post_listing_type::text::listing_type_enum_tmp),\n    ALTER COLUMN default_post_listing_type SET DEFAULT 'Local',\n    DROP COLUMN system_account;\n\nDROP TYPE listing_type_enum;\n\nALTER TYPE listing_type_enum_tmp RENAME TO listing_type_enum;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000057_multi-community/up.sql",
    "content": "CREATE TABLE multi_community (\n    id serial PRIMARY KEY,\n    creator_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    instance_id int NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE,\n    name varchar(255) NOT NULL,\n    title varchar(255),\n    description varchar(255),\n    local bool NOT NULL DEFAULT TRUE,\n    deleted bool NOT NULL DEFAULT FALSE,\n    ap_id text UNIQUE NOT NULL DEFAULT generate_unique_changeme (),\n    public_key text NOT NULL,\n    private_key text,\n    inbox_url text NOT NULL DEFAULT generate_unique_changeme (),\n    last_refreshed_at timestamptz NOT NULL DEFAULT now(),\n    following_url text NOT NULL DEFAULT generate_unique_changeme (),\n    published_at timestamptz NOT NULL DEFAULT now(),\n    updated_at timestamptz\n);\n\nCREATE TABLE multi_community_entry (\n    multi_community_id int NOT NULL REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE,\n    community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    PRIMARY KEY (multi_community_id, community_id)\n);\n\nCREATE TABLE multi_community_follow (\n    multi_community_id int NOT NULL REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE,\n    person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    follow_state community_follower_state NOT NULL,\n    PRIMARY KEY (person_id, multi_community_id)\n);\n\nALTER TABLE local_site\n    ADD COLUMN suggested_communities int REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN system_account int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- generate new account with randomized name (max 20 chars) and set it\n-- as local_site.system_account\nWITH x AS (\nINSERT INTO person (name, public_key, private_key, instance_id, inbox_url, bot_account)\n    SELECT\n        'lemmy_' || substr(gen_random_uuid ()::text, 0, 14),\n        public_key,\n        private_key,\n        instance_id,\n        inbox_url,\n        TRUE\n    FROM\n        site,\n        local_site\n    WHERE\n        site.id = local_site.id\n    RETURNING\n        person.id)\nUPDATE\n    local_site\nSET\n    system_account = x.id\nFROM\n    x;\n\nALTER TABLE local_site\n    ALTER COLUMN system_account SET NOT NULL;\n\n-- set ap_id for system_account (should use r.local_url but thats not defined here)\nUPDATE\n    person\nSET\n    ap_id = current_setting('lemmy.protocol_and_hostname') || '/u/' || person.name\nFROM\n    local_site\nWHERE\n    person.id = local_site.system_account;\n\nALTER TYPE listing_type_enum\n    ADD VALUE 'Suggested';\n\nCREATE INDEX idx_multi_community_read_from_name ON multi_community (local)\nWHERE\n    local AND NOT deleted;\n\nCREATE INDEX idx_multi_community_ap_id ON multi_community (ap_id);\n\nCREATE INDEX idx_multi_creator_id ON multi_community (creator_id);\n\nCREATE INDEX idx_multi_community_follow_multi_id ON multi_community_follow (multi_community_id);\n\nCREATE INDEX idx_multi_community_entry_community_id ON multi_community_entry (community_id);\n\nALTER TABLE search_combined\n    ADD COLUMN multi_community_id int REFERENCES multi_community (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE search_combined\n    DROP CONSTRAINT search_combined_check;\n\nALTER TABLE search_combined\n    ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id, multi_community_id) = 1);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000058_instance_block_communities_persons/down.sql",
    "content": "ALTER TABLE instance_actions RENAME COLUMN blocked_communities_at TO blocked_at;\n\nALTER TABLE instance_actions\n    DROP COLUMN blocked_persons_at;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000058_instance_block_communities_persons/up.sql",
    "content": "-- Currently, the instance.blocked_at columns only blocks communities from the given instance.\n--\n-- This creates a new block type, to also be able to block persons.\n-- Also changes the name of blocked_at to blocked_communities_at\nALTER TABLE instance_actions RENAME COLUMN blocked_at TO blocked_communities_at;\n\nALTER TABLE instance_actions\n    ADD COLUMN blocked_persons_at timestamptz;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000059_person_votes/down.sql",
    "content": "ALTER TABLE person_actions\n    DROP COLUMN voted_at,\n    DROP COLUMN upvotes,\n    DROP COLUMN downvotes;\n\nALTER TABLE local_user\n    DROP COLUMN show_person_votes;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000059_person_votes/up.sql",
    "content": "ALTER TABLE person_actions\n    ADD COLUMN voted_at timestamptz,\n    ADD COLUMN upvotes int,\n    ADD COLUMN downvotes int;\n\nALTER TABLE local_user\n    ADD COLUMN show_person_votes boolean NOT NULL DEFAULT TRUE;\n\n-- Disable the triggers temporarily\nALTER TABLE person_actions DISABLE TRIGGER ALL;\n\n-- Adding vote history\n-- This union alls the comment and post actions tables,\n-- inner joins to local_user for the above to filter out non-locals\n-- separates the like_score into upvote and downvote columns,\n-- groups and sums the upvotes and downvotes,\n-- handles conflicts using the `excluded` magic column.\nINSERT INTO person_actions (person_id, target_id, voted_at, upvotes, downvotes)\nSELECT\n    votes.person_id,\n    votes.creator_id,\n    now(),\n    count(*) FILTER (WHERE votes.vote_is_upvote) AS upvotes,\n    count(*) FILTER (WHERE NOT votes.vote_is_upvote) AS downvotes\nFROM (\n    SELECT\n        pa.person_id,\n        p.creator_id,\n        vote_is_upvote\n    FROM\n        post_actions pa\n        INNER JOIN post p ON pa.post_id = p.id\n            AND p.local\n        UNION ALL\n        SELECT\n            ca.person_id,\n            c.creator_id,\n            vote_is_upvote\n        FROM\n            comment_actions ca\n        INNER JOIN comment c ON ca.comment_id = c.id\n            AND c.local) AS votes\nGROUP BY\n    votes.person_id,\n    votes.creator_id\nON CONFLICT (person_id,\n    target_id)\n    DO UPDATE SET\n        voted_at = now(),\n        upvotes = excluded.upvotes,\n        downvotes = excluded.downvotes;\n\n-- Re-enable the triggers\nALTER TABLE person_actions ENABLE TRIGGER ALL;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000060_rename-rate-limit-columns/down.sql",
    "content": "ALTER TABLE local_site_rate_limit RENAME COLUMN message_max_requests TO message;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN message_interval_seconds TO message_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN post_max_requests TO post;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN post_interval_seconds TO post_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN comment_max_requests TO comment;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN comment_interval_seconds TO comment_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN register_max_requests TO register;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN register_interval_seconds TO register_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN image_max_requests TO image;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN image_interval_seconds TO image_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN search_max_requests TO search;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN search_interval_seconds TO search_per_second;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_max_requests TO import_user_settings;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_interval_seconds TO import_user_settings_per_second;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000060_rename-rate-limit-columns/up.sql",
    "content": "ALTER TABLE local_site_rate_limit RENAME COLUMN message TO message_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN message_per_second TO message_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN post TO post_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN post_per_second TO post_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN comment TO comment_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN comment_per_second TO comment_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN register TO register_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN register_per_second TO register_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN image TO image_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN image_per_second TO image_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN search TO search_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN search_per_second TO search_interval_seconds;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings TO import_user_settings_max_requests;\n\nALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_per_second TO import_user_settings_interval_seconds;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000061_drop-person-ban/down.sql",
    "content": "CREATE TABLE person_ban (\n    person_id integer REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamptz DEFAULT now(),\n    CONSTRAINT user_ban_user_id_not_null NOT NULL person_id,\n    CONSTRAINT user_ban_published_not_null NOT NULL published_at,\n    PRIMARY KEY (person_id)\n);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000061_drop-person-ban/up.sql",
    "content": "DROP TABLE person_ban;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000062_username-instance-unique/down.sql",
    "content": "ALTER TABLE person\n    DROP CONSTRAINT person_name_instance_unique;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000062_username-instance-unique/up.sql",
    "content": "-- lemmy requires (username + instance_id) to be unique\n-- delete any existing duplicates\nDELETE FROM person p1 USING (\n    SELECT\n        min(id) AS id,\n        name,\n        instance_id\n    FROM\n        person\n    GROUP BY\n        name,\n        instance_id\n    HAVING\n        count(*) > 1) p2\nWHERE\n    p1.name = p2.name\n    AND p1.instance_id = p2.instance_id\n    AND p1.id <> p2.id;\n\nALTER TABLE person\n    ADD CONSTRAINT person_name_instance_unique UNIQUE (name, instance_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000063_post-or-comment-notification/down.sql",
    "content": "CREATE TABLE person_post_mention (\n    id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read bool NOT NULL DEFAULT FALSE,\n    published_at timestamptz DEFAULT now(),\n    CONSTRAINT person_post_mention_published_not_null NOT NULL published_at\n);\n\nCREATE TABLE person_mention (\n    id serial,\n    recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    read bool DEFAULT FALSE,\n    published_at timestamptz DEFAULT now(),\n    CONSTRAINT user_mention_id_not_null NOT NULL id,\n    CONSTRAINT user_mention_comment_id_not_null NOT NULL comment_id,\n    CONSTRAINT user_mention_recipient_id_not_null NOT NULL recipient_id,\n    CONSTRAINT user_mention_published_not_null NOT NULL published_at,\n    CONSTRAINT user_mention_read_not_null NOT NULL read,\n    PRIMARY KEY (id),\n    UNIQUE (recipient_id, comment_id)\n);\n\nALTER TABLE person_mention RENAME TO person_comment_mention;\n\nCREATE TABLE comment_reply (\n    id serial,\n    recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    read bool DEFAULT FALSE,\n    published_at timestamptz DEFAULT now(),\n    CONSTRAINT comment_reply_published_not_null NOT NULL published_at,\n    UNIQUE (recipient_id, comment_id)\n);\n\nCREATE TABLE inbox_combined (\n    id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    -- TODO add this foreign key constraint later\n    comment_reply_id int,\n    person_comment_mention_id int REFERENCES person_comment_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE,\n    person_post_mention_id int REFERENCES person_post_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE,\n    private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE,\n    published_at timestamptz,\n    CONSTRAINT inbox_combined_published_not_null NOT NULL published_at\n);\n\nALTER TABLE private_message\n    ADD COLUMN read bool DEFAULT FALSE NOT NULL;\n\n-- copy back data to person_post_mention table\nINSERT INTO person_post_mention (recipient_id, post_id, read, published_at)\nSELECT\n    recipient_id,\n    post_id,\n    read,\n    published_at\nFROM\n    notification\nWHERE\n    kind = 'Mention'\n    AND post_id IS NOT NULL;\n\nINSERT INTO inbox_combined (person_post_mention_id, published_at)\nSELECT\n    id,\n    published_at\nFROM\n    person_post_mention;\n\n-- copy back data to person_comment_mention table\nINSERT INTO person_comment_mention (recipient_id, comment_id, read, published_at)\nSELECT\n    recipient_id,\n    comment_id,\n    read,\n    published_at\nFROM\n    notification\nWHERE\n    kind = 'Mention'\n    AND comment_id IS NOT NULL;\n\n-- copy back data to person_comment_mention table\nUPDATE\n    private_message p\nSET\n    read = n.read\nFROM\n    notification n\nWHERE\n    p.id = n.private_message_id;\n\nINSERT INTO inbox_combined (person_comment_mention_id, published_at)\nSELECT\n    id,\n    published_at\nFROM\n    person_comment_mention;\n\n-- copy back data to comment_reply table\nINSERT INTO comment_reply (recipient_id, comment_id, read, published_at)\nSELECT\n    recipient_id,\n    comment_id,\n    read,\n    published_at\nFROM\n    notification\nWHERE\n    kind = 'Reply'\n    AND comment_id IS NOT NULL;\n\nINSERT INTO inbox_combined (comment_reply_id, published_at)\nSELECT\n    id,\n    published_at\nFROM\n    comment_reply;\n\nALTER TABLE ONLY person_post_mention\n    ADD CONSTRAINT person_post_mention_recipient_id_post_id_key UNIQUE (recipient_id, post_id);\n\nCREATE INDEX idx_comment_reply_comment ON comment_reply USING btree (comment_id);\n\nCREATE INDEX idx_comment_reply_recipient ON comment_reply USING btree (recipient_id);\n\nCREATE INDEX idx_comment_reply_published ON comment_reply USING btree (published_at DESC);\n\nCREATE INDEX idx_inbox_combined_published_asc ON inbox_combined USING btree (reverse_timestamp_sort (published_at) DESC, id DESC);\n\nCREATE INDEX idx_inbox_combined_published ON inbox_combined USING btree (published_at DESC, id DESC);\n\nDROP TABLE notification;\n\nDROP TYPE notification_type_enum;\n\nALTER TABLE community_actions\n    DROP COLUMN notifications;\n\nDROP TYPE community_notifications_mode_enum;\n\nALTER TABLE post_actions\n    DROP COLUMN notifications;\n\nDROP TYPE post_notifications_mode_enum;\n\nALTER TABLE comment_reply\n    DROP CONSTRAINT comment_reply_id_not_null1,\n    ALTER COLUMN id SET NOT NULL,\n    ADD PRIMARY KEY (id);\n\nALTER TABLE comment_reply\n    ALTER COLUMN recipient_id SET NOT NULL,\n    ALTER COLUMN read SET NOT NULL;\n\nALTER TABLE inbox_combined\n    ADD CONSTRAINT inbox_combined_comment_reply_id_fkey FOREIGN KEY (comment_reply_id) REFERENCES comment_reply (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT inbox_combined_comment_reply_id_key UNIQUE (comment_reply_id),\n    ADD CONSTRAINT inbox_combined_check CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000063_post-or-comment-notification/up.sql",
    "content": "-- create new data types\nCREATE TYPE notification_type_enum AS enum (\n    'Mention',\n    'Reply',\n    'Subscribed',\n    'PrivateMessage'\n);\n\n-- create notification table by renaming comment_reply, to avoid copying lots of data around\nALTER TABLE comment_reply RENAME TO notification;\n\nALTER INDEX idx_comment_reply_comment RENAME TO idx_notification_comment;\n\nALTER INDEX idx_comment_reply_recipient RENAME TO idx_notification_recipient;\n\nALTER INDEX idx_comment_reply_published RENAME TO idx_notification_published;\n\nALTER SEQUENCE comment_reply_id_seq\n    RENAME TO notification_id_seq;\n\nALTER TABLE notification RENAME CONSTRAINT comment_reply_comment_id_fkey TO notification_comment_id_fkey;\n\nALTER TABLE notification RENAME CONSTRAINT comment_reply_pkey TO notification_pkey;\n\nALTER TABLE notification\n    DROP CONSTRAINT comment_reply_recipient_id_comment_id_key;\n\nALTER TABLE notification RENAME CONSTRAINT comment_reply_recipient_id_fkey TO notification_recipient_id_fkey;\n\nALTER TABLE notification\n    ADD COLUMN kind notification_type_enum NOT NULL DEFAULT 'Reply',\n    ALTER COLUMN comment_id DROP NOT NULL,\n    ADD COLUMN post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE notification\n    ALTER COLUMN kind DROP DEFAULT;\n\n-- copy data from person_post_mention table\nINSERT INTO notification (post_id, recipient_id, kind, read, published_at)\nSELECT\n    post_id,\n    recipient_id,\n    'Mention',\n    read,\n    published_at\nFROM\n    person_post_mention;\n\n-- copy data from person_comment_mention table\nINSERT INTO notification (comment_id, recipient_id, kind, read, published_at)\nSELECT\n    comment_id,\n    recipient_id,\n    'Mention',\n    read,\n    published_at\nFROM\n    person_comment_mention;\n\n-- copy data from private_message table\nINSERT INTO notification (private_message_id, recipient_id, kind, read, published_at)\nSELECT\n    id,\n    recipient_id,\n    'PrivateMessage',\n    read,\n    published_at\nFROM\n    private_message;\n\nALTER TABLE private_message\n    DROP COLUMN read;\n\nALTER TABLE notification\n    ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id) = 1);\n\nCREATE INDEX idx_notification_recipient_published ON notification (recipient_id, published_at);\n\nCREATE INDEX idx_notification_post ON notification (post_id)\nWHERE\n    post_id IS NOT NULL;\n\nCREATE INDEX idx_notification_private_message ON notification (private_message_id)\nWHERE\n    private_message_id IS NOT NULL;\n\nDROP TABLE inbox_combined, person_post_mention, person_comment_mention;\n\nCREATE TYPE post_notifications_mode_enum AS enum (\n    'AllComments',\n    'RepliesAndMentions',\n    'Mute'\n);\n\nALTER TABLE post_actions\n    ADD COLUMN notifications post_notifications_mode_enum;\n\nCREATE TYPE community_notifications_mode_enum AS enum (\n    'AllPostsAndComments',\n    'AllPosts',\n    'RepliesAndMentions',\n    'Mute'\n);\n\nALTER TABLE community_actions\n    ADD COLUMN notifications community_notifications_mode_enum;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000064_add_missing_foreign_key_indexes/down.sql",
    "content": "DROP INDEX idx_registration_application_admin, idx_admin_allow_instance_admin, idx_admin_block_instance_admin, idx_admin_purge_comment_admin, idx_admin_purge_community_admin, idx_admin_purge_person_admin, idx_admin_purge_post_admin, idx_mod_remove_comment_comment, idx_person_liked_combined_comment, idx_person_saved_combined_comment, idx_comment_report_creator, idx_community_report_creator, idx_post_report_creator, idx_private_message_creator, idx_private_message_report_creator, idx_admin_purge_post_community, idx_mod_add_community_community, idx_mod_ban_from_community_community, idx_mod_change_community_visibility_community, idx_mod_remove_community_community, idx_mod_transfer_community_community, idx_tag_community, idx_community_actions_follow_approver, idx_admin_allow_instance_instance, idx_admin_block_instance_instance, idx_community_instance, idx_mod_ban_instance, idx_multi_community_instance, idx_person_instance, idx_community_language_language, idx_local_user_language_language, idx_site_language_language, idx_email_verification_user, idx_oauth_account_user, idx_password_reset_request_user, idx_modlog_combined_mod_change_community_visibility_id, idx_mod_add_community_mod, idx_mod_add_mod, idx_mod_ban_from_community_mod, idx_mod_ban_mod, idx_mod_change_community_visibility_mod, idx_mod_feature_post_mod, idx_mod_lock_post_mod, idx_mod_remove_comment_mod, idx_mod_remove_community_mod, idx_mod_remove_post_mod, idx_mod_transfer_community_mod, idx_local_site_system_account, idx_search_combined_multi_community, idx_mod_add_community_other_person, idx_mod_add_other_person, idx_mod_ban_from_community_other_person, idx_mod_other_person, idx_mod_transfer_community_other_person, idx_admin_purge_comment_post, idx_mod_feature_post_post, idx_mod_lock_post_post, idx_mod_remove_post_post, idx_person_liked_combined_post, idx_person_saved_combined_post, idx_private_message_recipient, idx_comment_report_resolver, idx_community_report_resolver, idx_post_report_resolver, idx_private_message_report_resolver, idx_local_site_suggested_communities, idx_post_tag_tag, idx_local_image_thumbnail_post;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000064_add_missing_foreign_key_indexes/up.sql",
    "content": "CREATE INDEX idx_registration_application_admin ON registration_application (admin_id);\n\nCREATE INDEX idx_admin_allow_instance_admin ON admin_allow_instance (admin_person_id);\n\nCREATE INDEX idx_admin_block_instance_admin ON admin_block_instance (admin_person_id);\n\nCREATE INDEX idx_admin_purge_comment_admin ON admin_purge_comment (admin_person_id);\n\nCREATE INDEX idx_admin_purge_community_admin ON admin_purge_community (admin_person_id);\n\nCREATE INDEX idx_admin_purge_person_admin ON admin_purge_person (admin_person_id);\n\nCREATE INDEX idx_admin_purge_post_admin ON admin_purge_post (admin_person_id);\n\nCREATE INDEX idx_mod_remove_comment_comment ON mod_remove_comment (comment_id);\n\nCREATE INDEX idx_person_liked_combined_comment ON person_liked_combined (comment_id)\nWHERE\n    comment_id IS NOT NULL;\n\nCREATE INDEX idx_person_saved_combined_comment ON person_saved_combined (comment_id)\nWHERE\n    comment_id IS NOT NULL;\n\nCREATE INDEX idx_comment_report_creator ON comment_report (creator_id);\n\nCREATE INDEX idx_community_report_creator ON community_report (creator_id);\n\nCREATE INDEX idx_post_report_creator ON post_report (creator_id);\n\nCREATE INDEX idx_private_message_creator ON private_message (creator_id);\n\nCREATE INDEX idx_private_message_report_creator ON private_message_report (creator_id);\n\nCREATE INDEX idx_admin_purge_post_community ON admin_purge_post (community_id);\n\nCREATE INDEX idx_mod_add_community_community ON mod_add_community (community_id);\n\nCREATE INDEX idx_mod_ban_from_community_community ON mod_ban_from_community (community_id);\n\nCREATE INDEX idx_mod_change_community_visibility_community ON mod_change_community_visibility (community_id);\n\nCREATE INDEX idx_mod_remove_community_community ON mod_remove_community (community_id);\n\nCREATE INDEX idx_mod_transfer_community_community ON mod_transfer_community (community_id);\n\nCREATE INDEX idx_tag_community ON tag (community_id);\n\nCREATE INDEX idx_community_actions_follow_approver ON community_actions (follow_approver_id);\n\nCREATE INDEX idx_admin_allow_instance_instance ON admin_allow_instance (instance_id);\n\nCREATE INDEX idx_admin_block_instance_instance ON admin_block_instance (instance_id);\n\nCREATE INDEX idx_community_instance ON community (instance_id);\n\nCREATE INDEX idx_mod_ban_instance ON mod_ban (instance_id);\n\nCREATE INDEX idx_multi_community_instance ON multi_community (instance_id);\n\nCREATE INDEX idx_person_instance ON person (instance_id);\n\nCREATE INDEX idx_community_language_language ON community_language (language_id);\n\nCREATE INDEX idx_local_user_language_language ON local_user_language (language_id);\n\nCREATE INDEX idx_site_language_language ON site_language (language_id);\n\nCREATE INDEX idx_email_verification_user ON email_verification (local_user_id);\n\nCREATE INDEX idx_oauth_account_user ON oauth_account (local_user_id);\n\nCREATE INDEX idx_password_reset_request_user ON password_reset_request (local_user_id);\n\nCREATE INDEX idx_modlog_combined_mod_change_community_visibility_id ON modlog_combined (mod_change_community_visibility_id)\nWHERE\n    mod_change_community_visibility_id IS NOT NULL;\n\nCREATE INDEX idx_mod_add_community_mod ON mod_add_community (mod_person_id);\n\nCREATE INDEX idx_mod_add_mod ON mod_add (mod_person_id);\n\nCREATE INDEX idx_mod_ban_from_community_mod ON mod_ban_from_community (mod_person_id);\n\nCREATE INDEX idx_mod_ban_mod ON mod_ban (mod_person_id);\n\nCREATE INDEX idx_mod_change_community_visibility_mod ON mod_change_community_visibility (mod_person_id);\n\nCREATE INDEX idx_mod_feature_post_mod ON mod_feature_post (mod_person_id);\n\nCREATE INDEX idx_mod_lock_post_mod ON mod_lock_post (mod_person_id);\n\nCREATE INDEX idx_mod_remove_comment_mod ON mod_remove_comment (mod_person_id);\n\nCREATE INDEX idx_mod_remove_community_mod ON mod_remove_community (mod_person_id);\n\nCREATE INDEX idx_mod_remove_post_mod ON mod_remove_post (mod_person_id);\n\nCREATE INDEX idx_mod_transfer_community_mod ON mod_transfer_community (mod_person_id);\n\nCREATE INDEX idx_local_site_system_account ON local_site (system_account);\n\nCREATE INDEX idx_search_combined_multi_community ON search_combined (multi_community_id)\nWHERE\n    multi_community_id IS NOT NULL;\n\nCREATE INDEX idx_mod_add_community_other_person ON mod_add_community (other_person_id);\n\nCREATE INDEX idx_mod_add_other_person ON mod_add (other_person_id);\n\nCREATE INDEX idx_mod_ban_from_community_other_person ON mod_ban_from_community (other_person_id);\n\nCREATE INDEX idx_mod_other_person ON mod_ban (other_person_id);\n\nCREATE INDEX idx_mod_transfer_community_other_person ON mod_transfer_community (other_person_id);\n\nCREATE INDEX idx_admin_purge_comment_post ON admin_purge_comment (post_id);\n\nCREATE INDEX idx_mod_feature_post_post ON mod_feature_post (post_id);\n\nCREATE INDEX idx_mod_lock_post_post ON mod_lock_post (post_id);\n\nCREATE INDEX idx_mod_remove_post_post ON mod_remove_post (post_id);\n\nCREATE INDEX idx_person_liked_combined_post ON person_liked_combined (post_id)\nWHERE\n    post_id IS NOT NULL;\n\nCREATE INDEX idx_person_saved_combined_post ON person_saved_combined (post_id)\nWHERE\n    post_id IS NOT NULL;\n\nCREATE INDEX idx_private_message_recipient ON private_message (recipient_id);\n\nCREATE INDEX idx_comment_report_resolver ON comment_report (resolver_id);\n\nCREATE INDEX idx_community_report_resolver ON community_report (resolver_id);\n\nCREATE INDEX idx_post_report_resolver ON post_report (resolver_id);\n\nCREATE INDEX idx_private_message_report_resolver ON private_message_report (resolver_id);\n\nCREATE INDEX idx_local_site_suggested_communities ON local_site (suggested_communities);\n\nCREATE INDEX idx_post_tag_tag ON post_tag (tag_id);\n\nCREATE INDEX idx_local_image_thumbnail_post ON local_image (thumbnail_for_post_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000065_group-follow/down.sql",
    "content": "DROP TABLE community_community_follow;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000065_group-follow/up.sql",
    "content": "CREATE TABLE community_community_follow (\n    target_id int REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    community_id int REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,\n    published_at timestamptz NOT NULL DEFAULT now(),\n    PRIMARY KEY (community_id, target_id)\n);\n\nCREATE INDEX idx_community_community_follow_target ON community_community_follow (target_id);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000066_modlog-rename/down.sql",
    "content": "ALTER TABLE admin_ban RENAME TO mod_ban;\n\nALTER TABLE admin_add RENAME TO mod_add;\n\nALTER TABLE admin_remove_community RENAME TO mod_remove_community;\n\nALTER TABLE mod_add_to_community RENAME TO mod_add_community;\n\nALTER TABLE modlog_combined RENAME COLUMN admin_ban_id TO mod_ban_id;\n\nALTER TABLE modlog_combined RENAME COLUMN admin_add_id TO mod_add_id;\n\nALTER TABLE modlog_combined RENAME COLUMN admin_remove_community_id TO mod_remove_community_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_add_to_community_id TO mod_add_community_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000066_modlog-rename/up.sql",
    "content": "ALTER TABLE mod_ban RENAME TO admin_ban;\n\nALTER TABLE mod_add RENAME TO admin_add;\n\nALTER TABLE mod_remove_community RENAME TO admin_remove_community;\n\nALTER TABLE mod_add_community RENAME TO mod_add_to_community;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_ban_id TO admin_ban_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_add_id TO admin_add_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_remove_community_id TO admin_remove_community_id;\n\nALTER TABLE modlog_combined RENAME COLUMN mod_add_community_id TO mod_add_to_community_id;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000067_add_default_items_per_page/down.sql",
    "content": "-- Drop the new columns\nALTER TABLE local_user\n    DROP COLUMN default_items_per_page;\n\nALTER TABLE local_site\n    DROP COLUMN default_items_per_page;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000067_add_default_items_per_page/up.sql",
    "content": "-- Adds an optional default fetch limit (IE fetch a certain number of posts) to local_user and local_site\nALTER TABLE local_user\n    ADD COLUMN default_items_per_page integer NOT NULL DEFAULT 20;\n\nALTER TABLE local_site\n    ADD COLUMN default_items_per_page integer NOT NULL DEFAULT 20;\n\n"
  },
  {
    "path": "migrations/2025-08-01-000068_local_user_trigger/down.sql",
    "content": "UPDATE\n    local_site\nSET\n    users = (\n        SELECT\n            count(*)\n        FROM\n            local_user);\n\n"
  },
  {
    "path": "migrations/2025-08-01-000068_local_user_trigger/up.sql",
    "content": "UPDATE\n    local_site\nSET\n    users = (\n        SELECT\n            count(*)\n        FROM\n            local_user\n        WHERE\n            accepted_application);\n\n"
  },
  {
    "path": "migrations/2025-08-06-170325_add_indexes_for_aggregates_activity_new/down.sql",
    "content": "DROP INDEX idx_post_actions_voted_at, idx_comment_actions_voted_at;\n\n"
  },
  {
    "path": "migrations/2025-08-06-170325_add_indexes_for_aggregates_activity_new/up.sql",
    "content": "CREATE INDEX idx_post_actions_voted_at ON post_actions (voted_at)\nWHERE\n    voted_at IS NOT NULL;\n\nCREATE INDEX idx_comment_actions_voted_at ON comment_actions (voted_at)\nWHERE\n    voted_at IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-08-20-000000_comment-lock/down.sql",
    "content": "ALTER TABLE modlog_combined\n    DROP COLUMN mod_lock_comment_id,\n    ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1);\n\nDROP TABLE mod_lock_comment;\n\nALTER TABLE comment\n    DROP COLUMN LOCKED;\n\n"
  },
  {
    "path": "migrations/2025-08-20-000000_comment-lock/up.sql",
    "content": "ALTER TABLE comment\n    ADD COLUMN \"locked\" bool NOT NULL DEFAULT FALSE;\n\nCREATE TABLE mod_lock_comment (\n    id serial PRIMARY KEY,\n    mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    locked boolean NOT NULL DEFAULT TRUE,\n    reason text,\n    published_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX idx_mod_lock_comment_mod ON mod_lock_comment (mod_person_id);\n\nCREATE INDEX idx_mod_lock_comment_comment ON mod_lock_comment (comment_id);\n\nALTER TABLE modlog_combined\n    ADD COLUMN mod_lock_comment_id integer UNIQUE REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE modlog_combined\n    DROP CONSTRAINT modlog_combined_check,\n    ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id, mod_lock_comment_id) = 1),\n    ALTER CONSTRAINT modlog_combined_mod_lock_comment_id_fkey NOT DEFERRABLE;\n\n"
  },
  {
    "path": "migrations/2025-09-01-141127_local-community-collections/down.sql",
    "content": "UPDATE\n    community\nSET\n    moderators_url = NULL,\n    featured_url = NULL\nWHERE\n    local;\n\n"
  },
  {
    "path": "migrations/2025-09-01-141127_local-community-collections/up.sql",
    "content": "UPDATE\n    community c1\nSET\n    moderators_url = trim(TRAILING '/' FROM c2.ap_id) || '/moderators',\n    featured_url = trim(TRAILING '/' FROM c2.ap_id) || '/featured'\nFROM\n    community c2\nWHERE\n    c1.local\n    AND c1.id = c2.id;\n\n"
  },
  {
    "path": "migrations/2025-09-08-000001_add-video-dimensions/down.sql",
    "content": "ALTER TABLE post\n    DROP COLUMN embed_video_width,\n    DROP COLUMN embed_video_height;\n\n"
  },
  {
    "path": "migrations/2025-09-08-000001_add-video-dimensions/up.sql",
    "content": "ALTER TABLE post\n    ADD COLUMN embed_video_width integer,\n    ADD COLUMN embed_video_height integer;\n\n"
  },
  {
    "path": "migrations/2025-09-08-140711_remove-actor-name-max-length/down.sql",
    "content": "ALTER TABLE local_site\n    ADD COLUMN actor_name_max_length int DEFAULT 20 NOT NULL;\n\nALTER TABLE person\n    ALTER COLUMN display_name TYPE varchar(255);\n\nALTER TABLE community\n    ALTER COLUMN title TYPE varchar(255);\n\n"
  },
  {
    "path": "migrations/2025-09-08-140711_remove-actor-name-max-length/up.sql",
    "content": "-- get rid of max name length setting\nALTER TABLE local_site\n    DROP COLUMN actor_name_max_length;\n\n-- truncate existing strings\nUPDATE\n    person\nSET\n    display_name = substring(display_name FROM 1 FOR 50)\nWHERE\n    length(display_name) > 50;\n\nUPDATE\n    community\nSET\n    title = substring(title FROM 1 FOR 50)\nWHERE\n    length(title) > 50;\n\n-- reduce max length of db columns\nALTER TABLE person\n    ALTER COLUMN display_name TYPE varchar(50);\n\nALTER TABLE community\n    ALTER COLUMN title TYPE varchar(50);\n\n"
  },
  {
    "path": "migrations/2025-09-12-093537_mod-reason-mandatory/down.sql",
    "content": "ALTER TABLE admin_allow_instance\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_ban\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_block_instance\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_purge_comment\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_purge_community\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_purge_person\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_purge_post\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE admin_remove_community\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE mod_lock_comment\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN reason DROP NOT NULL;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN reason DROP NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-09-12-093537_mod-reason-mandatory/up.sql",
    "content": "-- provide default value for null rows\nUPDATE\n    admin_allow_instance\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_ban\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_block_instance\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_purge_comment\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_purge_community\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_purge_person\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_purge_post\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    admin_remove_community\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    mod_ban_from_community\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    mod_lock_comment\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    mod_lock_post\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    mod_remove_comment\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\nUPDATE\n    mod_remove_post\nSET\n    reason = 'No reason given'\nWHERE\n    reason IS NULL;\n\n-- set not null\nALTER TABLE admin_allow_instance\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_ban\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_block_instance\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_purge_comment\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_purge_community\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_purge_person\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_purge_post\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE admin_remove_community\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE mod_ban_from_community\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE mod_lock_comment\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE mod_lock_post\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE mod_remove_comment\n    ALTER COLUMN reason SET NOT NULL;\n\nALTER TABLE mod_remove_post\n    ALTER COLUMN reason SET NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-09-15-090401_remove-keyboard-nav/down.sql",
    "content": "ALTER TABLE local_user\n    ADD COLUMN enable_keyboard_navigation boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2025-09-15-090401_remove-keyboard-nav/up.sql",
    "content": "ALTER TABLE local_user\n    DROP COLUMN enable_keyboard_navigation;\n\n"
  },
  {
    "path": "migrations/2025-09-19-090047_notify-mod-action/down.sql",
    "content": "-- drop new foreign keys\nALTER TABLE notification\n    DROP COLUMN mod_remove_comment_id,\n    DROP COLUMN admin_add_id,\n    DROP COLUMN mod_add_to_community_id,\n    DROP COLUMN admin_ban_id,\n    DROP COLUMN mod_ban_from_community_id,\n    DROP COLUMN mod_lock_post_id,\n    DROP COLUMN admin_remove_community_id,\n    DROP COLUMN mod_remove_post_id,\n    DROP COLUMN mod_lock_comment_id,\n    DROP COLUMN mod_transfer_community_id;\n\n-- revert change to notification_type enum\nALTER TYPE notification_type_enum RENAME TO notification_type_enum__;\n\nDELETE FROM notification\nWHERE kind = 'ModAction';\n\nCREATE TYPE notification_type_enum AS ENUM (\n    'Mention',\n    'Reply',\n    'Subscribed',\n    'PrivateMessage'\n);\n\nALTER TABLE notification\n    ALTER COLUMN kind TYPE notification_type_enum\n    USING kind::text::notification_type_enum;\n\n-- revert changes to constraint\nALTER TABLE notification\n    DROP CONSTRAINT IF EXISTS notification_check;\n\nALTER TABLE notification\n    ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id) = 1);\n\n-- drop the old enum\nDROP TYPE notification_type_enum__;\n\nDROP INDEX idx_notification_unread;\n\n"
  },
  {
    "path": "migrations/2025-09-19-090047_notify-mod-action/up.sql",
    "content": "-- new foreign keys\nALTER TABLE notification\n    ADD COLUMN admin_add_id int REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_add_to_community_id int REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN admin_ban_id int REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_ban_from_community_id int REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_lock_post_id int REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_remove_comment_id int REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN admin_remove_community_id int REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_remove_post_id int REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_lock_comment_id int REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_transfer_community_id int REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- new types for mod actions\nALTER TYPE notification_type_enum\n    ADD value 'ModAction';\n\n-- update constraint with new columns\nALTER TABLE notification\n    DROP CONSTRAINT IF EXISTS notification_check;\n\nALTER TABLE notification\n    ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_lock_post_id, mod_remove_post_id, mod_lock_comment_id, mod_remove_comment_id, admin_remove_community_id, mod_transfer_community_id) = 1);\n\n-- add indexes\nCREATE INDEX idx_notification_unread ON notification (read);\n\nCREATE INDEX idx_notification_admin_add_id ON notification (admin_add_id)\nWHERE\n    admin_add_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_add_to_community_id ON notification (mod_add_to_community_id)\nWHERE\n    mod_add_to_community_id IS NOT NULL;\n\nCREATE INDEX idx_notification_admin_ban_id ON notification (admin_ban_id)\nWHERE\n    admin_ban_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_ban_from_community_id ON notification (mod_ban_from_community_id)\nWHERE\n    mod_ban_from_community_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_lock_post_id ON notification (mod_lock_post_id)\nWHERE\n    mod_lock_post_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_remove_comment_id ON notification (mod_remove_comment_id)\nWHERE\n    mod_remove_comment_id IS NOT NULL;\n\nCREATE INDEX idx_notification_admin_remove_community_id ON notification (admin_remove_community_id)\nWHERE\n    admin_remove_community_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_remove_post_id ON notification (mod_remove_post_id)\nWHERE\n    mod_remove_post_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_lock_comment_id ON notification (mod_lock_comment_id)\nWHERE\n    mod_lock_comment_id IS NOT NULL;\n\nCREATE INDEX idx_notification_mod_transfer_community_id ON notification (mod_transfer_community_id)\nWHERE\n    mod_transfer_community_id IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2025-09-19-132648-0000_theme-instance-default/down.sql",
    "content": "UPDATE\n    local_user\nSET\n    theme = 'browser'\nWHERE\n    theme = 'instance';\n\nUPDATE\n    local_user\nSET\n    theme = 'browser-compact'\nWHERE\n    theme = 'instance-compact';\n\n"
  },
  {
    "path": "migrations/2025-09-19-132648-0000_theme-instance-default/up.sql",
    "content": "UPDATE\n    local_user\nSET\n    theme = 'instance'\nWHERE\n    theme = 'browser';\n\nUPDATE\n    local_user\nSET\n    theme = 'instance-compact'\nWHERE\n    theme = 'browser-compact';\n\n"
  },
  {
    "path": "migrations/2025-10-08-084508-0000_multi-comm-index-lower/down.sql",
    "content": "DROP INDEX idx_multi_community_lower_actor_id;\n\n"
  },
  {
    "path": "migrations/2025-10-08-084508-0000_multi-comm-index-lower/up.sql",
    "content": "CREATE UNIQUE INDEX idx_multi_community_lower_actor_id ON multi_community (lower(ap_id));\n\n"
  },
  {
    "path": "migrations/2025-10-09-101527-0000_community-follower-denied/down.sql",
    "content": "-- revert change to community follow state enum\nALTER TYPE community_follower_state RENAME TO community_follower_state__;\n\nCREATE TYPE community_follower_state AS ENUM (\n    'Accepted',\n    'Pending',\n    'ApprovalRequired'\n);\n\nALTER TABLE community_actions\n    ALTER COLUMN follow_state TYPE community_follower_state\n    USING follow_state::text::community_follower_state;\n\nALTER TABLE multi_community_follow\n    ALTER COLUMN follow_state TYPE community_follower_state\n    USING follow_state::text::community_follower_state;\n\nDROP TYPE community_follower_state__;\n\n"
  },
  {
    "path": "migrations/2025-10-09-101527-0000_community-follower-denied/up.sql",
    "content": "-- add follow state denied for private communities\nALTER TYPE community_follower_state\n    ADD value 'Denied';\n\n"
  },
  {
    "path": "migrations/2025-10-15-114811-0000_merge-modlog-tables/down.sql",
    "content": "CREATE TABLE mod_add_to_community (\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    removed boolean DEFAULT FALSE,\n    CONSTRAINT mod_add_community_community_id_not_null NOT NULL community_id,\n    CONSTRAINT mod_add_community_id_not_null NOT NULL id,\n    CONSTRAINT mod_add_community_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_add_community_other_user_id_not_null NOT NULL other_person_id,\n    CONSTRAINT mod_add_community_when__not_null NOT NULL published_at,\n    CONSTRAINT mod_add_community_removed_not_null NOT NULL removed,\n    PRIMARY KEY (id)\n);\n\nALTER SEQUENCE mod_add_to_community_id_seq\n    RENAME TO mod_add_community_id_seq;\n\nALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_community_id_fkey TO mod_add_community_community_id_fkey;\n\nALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_mod_person_id_fkey TO mod_add_community_mod_person_id_fkey;\n\nALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_other_person_id_fkey TO mod_add_community_other_person_id_fkey;\n\nALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_pkey TO mod_add_community_pkey;\n\nCREATE TABLE admin_purge_comment (\n    admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_purge_comment_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_add (\n    id serial,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    removed boolean DEFAULT FALSE,\n    CONSTRAINT mod_add_id_not_null NOT NULL id,\n    CONSTRAINT mod_add_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_add_other_user_id_not_null NOT NULL other_person_id,\n    CONSTRAINT mod_add_when__not_null NOT NULL published_at,\n    CONSTRAINT mod_add_removed_not_null NOT NULL removed,\n    PRIMARY KEY (id)\n);\n\nALTER SEQUENCE admin_add_id_seq\n    RENAME TO mod_add_id_seq;\n\nALTER TABLE admin_add RENAME CONSTRAINT admin_add_mod_person_id_fkey TO mod_add_mod_person_id_fkey;\n\nALTER TABLE admin_add RENAME CONSTRAINT admin_add_other_person_id_fkey TO mod_add_other_person_id_fkey;\n\nALTER TABLE admin_add RENAME CONSTRAINT admin_add_pkey TO mod_add_pkey;\n\nCREATE TABLE mod_transfer_community (\n    community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    other_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    CONSTRAINT mod_transfer_community_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_allow_instance (\n    admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    allowed boolean NOT NULL,\n    id serial PRIMARY KEY,\n    instance_id integer NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_allow_instance_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE mod_lock_post (\n    id serial PRIMARY KEY,\n    locked boolean DEFAULT TRUE NOT NULL,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT mod_lock_post_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_lock_post_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE mod_remove_post (\n    id serial PRIMARY KEY,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    removed boolean DEFAULT TRUE NOT NULL,\n    CONSTRAINT mod_remove_post_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_remove_post_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE mod_change_community_visibility (\n    community_id integer NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    visibility community_visibility NOT NULL,\n    CONSTRAINT mod_change_community_visibility_published_not_null NOT NULL published_at\n);\n\nCREATE TABLE mod_remove_comment (\n    comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    removed boolean DEFAULT TRUE NOT NULL,\n    CONSTRAINT mod_remove_comment_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_remove_comment_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_remove_community (\n    community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    removed boolean DEFAULT TRUE,\n    CONSTRAINT mod_remove_community_id_not_null NOT NULL id,\n    CONSTRAINT mod_remove_community_community_id_not_null NOT NULL community_id,\n    CONSTRAINT mod_remove_community_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_remove_community_removed_not_null NOT NULL removed,\n    CONSTRAINT mod_remove_community_when__not_null NOT NULL published_at,\n    PRIMARY KEY (id)\n);\n\nALTER SEQUENCE admin_remove_community_id_seq\n    RENAME TO mod_remove_community_id_seq;\n\nALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_community_id_fkey TO mod_remove_community_community_id_fkey;\n\nALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_mod_person_id_fkey TO mod_remove_community_mod_person_id_fkey;\n\nALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_pkey TO mod_remove_community_pkey;\n\nCREATE TABLE mod_lock_comment (\n    comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    locked boolean DEFAULT TRUE NOT NULL,\n    mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now() NOT NULL,\n    reason text NOT NULL\n);\n\nCREATE TABLE mod_feature_post (\n    featured boolean DEFAULT TRUE,\n    id serial,\n    is_featured_community boolean DEFAULT TRUE,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    post_id integer REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    CONSTRAINT mod_sticky_post_featured_not_null NOT NULL featured,\n    CONSTRAINT mod_sticky_post_id_not_null NOT NULL id,\n    CONSTRAINT mod_sticky_post_is_featured_community_not_null NOT NULL is_featured_community,\n    CONSTRAINT mod_sticky_post_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_sticky_post_post_id_not_null NOT NULL post_id,\n    CONSTRAINT mod_sticky_post_when__not_null NOT NULL published_at,\n    PRIMARY KEY (id)\n);\n\nALTER SEQUENCE mod_feature_post_id_seq\n    RENAME TO mod_sticky_post_id_seq;\n\nALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_mod_person_id_fkey TO mod_sticky_post_mod_person_id_fkey;\n\nALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_pkey TO mod_sticky_post_pkey;\n\nALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_post_id_fkey TO mod_sticky_post_post_id_fkey;\n\nCREATE TABLE admin_block_instance (\n    admin_person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    blocked boolean NOT NULL,\n    expires_at timestamp with time zone,\n    id serial PRIMARY KEY,\n    instance_id integer NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_block_instance_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_ban (\n    banned boolean DEFAULT TRUE,\n    expires_at timestamp with time zone,\n    id serial,\n    instance_id integer REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    other_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT mod_ban_banned_not_null NOT NULL banned,\n    CONSTRAINT mod_ban_id_not_null NOT NULL id,\n    CONSTRAINT mod_ban_instance_id_not_null NOT NULL instance_id,\n    CONSTRAINT mod_ban_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_ban_other_user_id_not_null NOT NULL other_person_id,\n    CONSTRAINT mod_ban_when__not_null NOT NULL published_at,\n    PRIMARY KEY (id)\n);\n\nALTER SEQUENCE admin_ban_id_seq\n    RENAME TO mod_ban_id_seq;\n\nALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_instance_id_fkey TO mod_ban_instance_id_fkey;\n\nALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_mod_person_id_fkey TO mod_ban_mod_person_id_fkey;\n\nALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_other_person_id_fkey TO mod_ban_other_person_id_fkey;\n\nALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_pkey TO mod_ban_pkey;\n\nCREATE TABLE admin_purge_post (\n    admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_purge_post_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_purge_person (\n    admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_purge_person_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE admin_purge_community (\n    admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    id serial PRIMARY KEY,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    CONSTRAINT admin_purge_community_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE mod_ban_from_community (\n    id serial PRIMARY KEY,\n    published_at timestamp with time zone DEFAULT now(),\n    reason text NOT NULL,\n    mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    expires_at timestamp with time zone,\n    other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    banned bool NOT NULL DEFAULT TRUE,\n    CONSTRAINT mod_ban_from_community_mod_user_id_not_null NOT NULL mod_person_id,\n    CONSTRAINT mod_ban_from_community_other_user_id_not_null NOT NULL other_person_id,\n    CONSTRAINT mod_ban_from_community_when__not_null NOT NULL published_at\n);\n\nCREATE TABLE modlog_combined (\n    id serial PRIMARY KEY,\n    published_at timestamptz,\n    admin_allow_instance_id int UNIQUE REFERENCES admin_allow_instance ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_block_instance_id int UNIQUE REFERENCES admin_block_instance ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_purge_comment_id int UNIQUE REFERENCES admin_purge_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_purge_community_id int UNIQUE REFERENCES admin_purge_community ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_purge_person_id int UNIQUE REFERENCES admin_purge_person ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_purge_post_id int UNIQUE REFERENCES admin_purge_post ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_add_id int UNIQUE REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_add_to_community_id int UNIQUE REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_ban_id int UNIQUE REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_ban_from_community_id int UNIQUE REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_feature_post_id int UNIQUE REFERENCES mod_feature_post ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_change_community_visibility_id int REFERENCES mod_change_community_visibility ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_lock_post_id int UNIQUE REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_lock_comment_id int UNIQUE REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_remove_comment_id int UNIQUE REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    admin_remove_community_id int UNIQUE REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_remove_post_id int UNIQUE REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE,\n    mod_transfer_community_id int UNIQUE REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE,\n    CONSTRAINT modlog_combined_published_not_null NOT NULL published_at\n);\n\nALTER TABLE modlog_combined\n    ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id, mod_lock_comment_id) = 1);\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_add_id_fkey TO modlog_combined_mod_add_id_fkey;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_add_id_key TO modlog_combined_mod_add_id_key;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_ban_id_fkey TO modlog_combined_mod_ban_id_fkey;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_ban_id_key TO modlog_combined_mod_ban_id_key;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_remove_community_id_fkey TO modlog_combined_mod_remove_community_id_fkey;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_remove_community_id_key TO modlog_combined_mod_remove_community_id_key;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_mod_add_to_community_id_key TO modlog_combined_mod_add_community_id_key;\n\nALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_mod_add_to_community_id_fkey TO modlog_combined_mod_add_community_id_fkey;\n\nALTER TABLE notification\n    ADD COLUMN admin_add_id int REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_add_to_community_id int REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN admin_ban_id int REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_ban_from_community_id int REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_lock_post_id int REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_remove_comment_id int REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN admin_remove_community_id int REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_remove_post_id int REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_lock_comment_id int REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD COLUMN mod_transfer_community_id int REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE,\n    DROP COLUMN modlog_id;\n\nALTER TABLE notification\n    DROP CONSTRAINT IF EXISTS notification_check;\n\nALTER TABLE notification\n    ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_lock_post_id, mod_remove_post_id, mod_lock_comment_id, mod_remove_comment_id, admin_remove_community_id, mod_transfer_community_id) = 1);\n\nDROP TABLE modlog;\n\nDROP TYPE modlog_kind;\n\nCREATE INDEX idx_mod_add_mod ON admin_add USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_ban_mod ON admin_ban USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_ban_instance ON admin_ban USING btree (instance_id);\n\nCREATE INDEX idx_mod_lock_post_post ON mod_lock_post USING btree (post_id);\n\nCREATE INDEX idx_mod_other_person ON admin_ban USING btree (other_person_id);\n\nCREATE INDEX idx_mod_remove_post_post ON mod_remove_post USING btree (post_id);\n\nCREATE INDEX idx_mod_lock_post_mod ON mod_lock_post USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_add_other_person ON admin_add USING btree (other_person_id);\n\nCREATE INDEX idx_mod_feature_post_post ON mod_feature_post USING btree (post_id);\n\nCREATE INDEX idx_mod_remove_post_mod ON mod_remove_post USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_feature_post_mod ON mod_feature_post USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_lock_comment_mod ON mod_lock_comment USING btree (mod_person_id);\n\nCREATE INDEX idx_admin_purge_comment_post ON admin_purge_comment USING btree (post_id);\n\nCREATE INDEX idx_mod_lock_comment_comment ON mod_lock_comment USING btree (comment_id);\n\nCREATE INDEX idx_admin_purge_post_admin ON admin_purge_post USING btree (admin_person_id);\n\nCREATE INDEX idx_mod_remove_comment_mod ON mod_remove_comment USING btree (mod_person_id);\n\nCREATE INDEX idx_admin_purge_post_community ON admin_purge_post USING btree (community_id);\n\nCREATE INDEX idx_mod_add_community_mod ON mod_add_to_community USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_remove_comment_comment ON mod_remove_comment USING btree (comment_id);\n\nCREATE INDEX idx_admin_purge_person_admin ON admin_purge_person USING btree (admin_person_id);\n\nCREATE INDEX idx_admin_purge_comment_admin ON admin_purge_comment USING btree (admin_person_id);\n\nCREATE INDEX idx_mod_add_community_community ON mod_add_to_community USING btree (community_id);\n\nCREATE INDEX idx_mod_remove_community_mod ON admin_remove_community USING btree (mod_person_id);\n\nCREATE INDEX idx_admin_allow_instance_instance ON admin_allow_instance USING btree (instance_id);\n\nCREATE INDEX idx_admin_block_instance_instance ON admin_block_instance USING btree (instance_id);\n\nCREATE INDEX idx_admin_allow_instance_admin ON admin_allow_instance USING btree (admin_person_id);\n\nCREATE INDEX idx_admin_block_instance_admin ON admin_block_instance USING btree (admin_person_id);\n\nCREATE INDEX idx_mod_ban_from_community_mod ON mod_ban_from_community USING btree (mod_person_id);\n\nCREATE INDEX idx_mod_transfer_community_mod ON mod_transfer_community USING btree (mod_person_id);\n\nCREATE INDEX idx_admin_purge_community_admin ON admin_purge_community USING btree (admin_person_id);\n\nCREATE INDEX idx_mod_remove_community_community ON admin_remove_community USING btree (community_id);\n\nCREATE INDEX idx_mod_add_community_other_person ON mod_add_to_community USING btree (other_person_id);\n\nCREATE INDEX idx_mod_ban_from_community_community ON mod_ban_from_community USING btree (community_id);\n\nCREATE INDEX idx_mod_transfer_community_community ON mod_transfer_community USING btree (community_id);\n\nCREATE INDEX idx_modlog_combined_published ON modlog_combined USING btree (published_at DESC, id DESC);\n\nCREATE INDEX idx_mod_ban_from_community_other_person ON mod_ban_from_community USING btree (other_person_id);\n\nCREATE INDEX idx_mod_transfer_community_other_person ON mod_transfer_community USING btree (other_person_id);\n\nCREATE INDEX idx_mod_change_community_visibility_mod ON mod_change_community_visibility USING btree (mod_person_id);\n\nCREATE INDEX idx_notification_admin_add_id ON notification USING btree (admin_add_id)\nWHERE (admin_add_id IS NOT NULL);\n\nCREATE INDEX idx_notification_admin_ban_id ON notification USING btree (admin_ban_id)\nWHERE (admin_ban_id IS NOT NULL);\n\nCREATE INDEX idx_mod_change_community_visibility_community ON mod_change_community_visibility USING btree (community_id);\n\nCREATE INDEX idx_notification_mod_lock_post_id ON notification USING btree (mod_lock_post_id)\nWHERE (mod_lock_post_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_remove_post_id ON notification USING btree (mod_remove_post_id)\nWHERE (mod_remove_post_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_lock_comment_id ON notification USING btree (mod_lock_comment_id)\nWHERE (mod_lock_comment_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_remove_comment_id ON notification USING btree (mod_remove_comment_id)\nWHERE (mod_remove_comment_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_add_to_community_id ON notification USING btree (mod_add_to_community_id)\nWHERE (mod_add_to_community_id IS NOT NULL);\n\nCREATE INDEX idx_notification_admin_remove_community_id ON notification USING btree (admin_remove_community_id)\nWHERE (admin_remove_community_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_ban_from_community_id ON notification USING btree (mod_ban_from_community_id)\nWHERE (mod_ban_from_community_id IS NOT NULL);\n\nCREATE INDEX idx_notification_mod_transfer_community_id ON notification USING btree (mod_transfer_community_id)\nWHERE (mod_transfer_community_id IS NOT NULL);\n\nCREATE INDEX idx_modlog_combined_mod_change_community_visibility_id ON modlog_combined USING btree (mod_change_community_visibility_id)\nWHERE (mod_change_community_visibility_id IS NOT NULL);\n\n"
  },
  {
    "path": "migrations/2025-10-15-114811-0000_merge-modlog-tables/up.sql",
    "content": "-- New enum with all possible mod actions\n-- TODO: We could also remove the Admin/Mod prefix\nCREATE TYPE modlog_kind AS enum (\n    'AdminAdd',\n    'AdminBan',\n    'AdminAllowInstance',\n    'AdminBlockInstance',\n    'AdminPurgeComment',\n    'AdminPurgeCommunity',\n    'AdminPurgePerson',\n    'AdminPurgePost',\n    'ModAddToCommunity',\n    'ModBanFromCommunity',\n    'ModFeaturePostCommunity',\n    'AdminFeaturePostSite',\n    'ModChangeCommunityVisibility',\n    'ModLockPost',\n    'ModRemoveComment',\n    'AdminRemoveCommunity',\n    'ModRemovePost',\n    'ModTransferCommunity',\n    'ModLockComment'\n);\n\n-- New table with data for all mod actions\nCREATE TABLE modlog (\n    id serial PRIMARY KEY,\n    kind modlog_kind NOT NULL,\n    -- Used to be `revert`, but that makes little sense for things like feature post or\n    -- transfer community. Instead we use this which means values have to be inverted.\n    is_revert boolean NOT NULL,\n    -- Not using `references person` for any of the foreign keys to avoid modlog entries\n    -- disappearing if the mod or any target gets purged.\n    mod_id int NOT NULL,\n    -- For some actions reason is quite pointless so leave it optional (eg add admin, feature post)\n    reason text,\n    target_person_id int,\n    target_community_id int,\n    target_post_id int,\n    target_comment_id int,\n    target_instance_id int,\n    expires_at timestamptz,\n    published_at timestamptz NOT NULL DEFAULT now()\n);\n\n-- Most mod actions can have only one target. We could make this much more specific and state\n-- which exact column must be set for each kind but that would be excessive.\nALTER TABLE modlog\n    ADD CHECK ((kind = 'AdminAdd'\n        AND num_nonnulls (target_person_id) = 1\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0)\n        OR (kind = 'AdminBan'\n        AND num_nonnulls (target_person_id, target_instance_id) = 2\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0)\n        OR (kind = 'ModRemovePost'\n        AND num_nonnulls (target_post_id, target_person_id) = 2\n        AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModRemoveComment'\n        AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3\n        AND num_nonnulls (target_community_id, target_instance_id) = 0)\n        OR (kind = 'ModLockComment'\n        AND num_nonnulls (target_comment_id, target_person_id) = 2\n        AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0)\n        OR (kind = 'ModLockPost'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminRemoveCommunity'\n        AND num_nonnulls (target_community_id) = 1\n        -- target_person_id (community owner) can be either null or not null here\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModChangeCommunityVisibility'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'ModBanFromCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModAddToCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModTransferCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminAllowInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminBlockInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeComment'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePost'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeCommunity'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePerson'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModFeaturePostCommunity'\n        AND num_nonnulls (target_post_id, target_community_id) = 2\n        AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'AdminFeaturePostSite'\n        AND num_nonnulls (target_post_id) = 1\n        AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0));\n\n-- copy old data to new table\nINSERT INTO modlog (kind, is_revert, mod_id, target_person_id, published_at)\nSELECT\n    'AdminAdd',\n    NOT removed,\n    mod_person_id,\n    other_person_id,\n    published_at\nFROM\n    admin_add;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_person_id, target_instance_id, published_at)\nSELECT\n    'AdminBan',\n    reason,\n    NOT banned,\n    mod_person_id,\n    other_person_id,\n    p.instance_id,\n    a. published_at\nFROM\n    admin_ban a\n    INNER JOIN person p ON p.id = mod_person_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, published_at)\nSELECT\n    'ModRemovePost',\n    reason,\n    NOT m.removed,\n    mod_person_id,\n    post_id,\n    p.creator_id,\n    m.published_at\nFROM\n    mod_remove_post m\n    INNER JOIN post p ON p.id = post_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_comment_id, target_person_id, target_post_id, published_at)\nSELECT\n    'ModRemoveComment',\n    reason,\n    NOT m.removed,\n    mod_person_id,\n    comment_id,\n    c.creator_id,\n    c.post_id,\n    m.published_at\nFROM\n    mod_remove_comment m\n    INNER JOIN comment c ON c.id = comment_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_comment_id, target_person_id, published_at)\nSELECT\n    'ModLockComment',\n    reason,\n    NOT m.LOCKED,\n    mod_person_id,\n    comment_id,\n    c.creator_id,\n    m.published_at\nFROM\n    mod_lock_comment m\n    INNER JOIN comment c ON c.id = comment_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, target_community_id, published_at)\nSELECT\n    'ModLockPost',\n    reason,\n    NOT m.LOCKED,\n    mod_person_id,\n    post_id,\n    p.creator_id,\n    p.community_id,\n    m.published_at\nFROM\n    mod_lock_post m\n    INNER JOIN post p ON p.id = post_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, published_at)\nSELECT\n    'AdminRemoveCommunity',\n    reason,\n    NOT removed,\n    mod_person_id,\n    community_id,\n    published_at\nFROM\n    admin_remove_community;\n\nINSERT INTO modlog (kind, is_revert, mod_id, target_community_id, published_at)\nSELECT\n    'ModChangeCommunityVisibility',\n    FALSE,\n    mod_person_id,\n    community_id,\n    published_at\nFROM\n    mod_change_community_visibility;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, target_person_id, expires_at, published_at)\nSELECT\n    'ModBanFromCommunity',\n    reason,\n    NOT banned,\n    mod_person_id,\n    community_id,\n    other_person_id,\n    expires_at,\n    published_at\nFROM\n    mod_ban_from_community;\n\nINSERT INTO modlog (kind, is_revert, mod_id, target_community_id, target_person_id, published_at)\nSELECT\n    'ModAddToCommunity',\n    NOT removed,\n    mod_person_id,\n    community_id,\n    other_person_id,\n    published_at\nFROM\n    mod_add_to_community;\n\nINSERT INTO modlog (kind, is_revert, mod_id, target_community_id, target_person_id, published_at)\nSELECT\n    'ModTransferCommunity',\n    FALSE,\n    mod_person_id,\n    community_id,\n    other_person_id,\n    published_at\nFROM\n    mod_transfer_community;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_instance_id, published_at)\nSELECT\n    'AdminAllowInstance',\n    reason,\n    NOT allowed,\n    admin_person_id,\n    instance_id,\n    published_at\nFROM\n    admin_allow_instance;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_instance_id, published_at)\nSELECT\n    'AdminBlockInstance',\n    reason,\n    NOT blocked,\n    admin_person_id,\n    instance_id,\n    published_at\nFROM\n    admin_block_instance;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, target_community_id, published_at)\nSELECT\n    'AdminPurgeComment',\n    reason,\n    FALSE,\n    admin_person_id,\n    post_id,\n    p.creator_id,\n    p.community_id,\n    a.published_at\nFROM\n    admin_purge_comment a\n    INNER JOIN post p ON p.id = post_id;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, published_at)\nSELECT\n    'AdminPurgePost',\n    reason,\n    FALSE,\n    admin_person_id,\n    community_id,\n    published_at\nFROM\n    admin_purge_post;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, published_at)\nSELECT\n    'AdminPurgeCommunity',\n    reason,\n    FALSE,\n    admin_person_id,\n    published_at\nFROM\n    admin_purge_community;\n\nINSERT INTO modlog (kind, reason, is_revert, mod_id, published_at)\nSELECT\n    'AdminPurgePerson',\n    reason,\n    FALSE,\n    admin_person_id,\n    published_at\nFROM\n    admin_purge_person;\n\nINSERT INTO modlog (kind, is_revert, mod_id, target_post_id, target_community_id, published_at)\nSELECT\n    'ModFeaturePostCommunity',\n    NOT featured,\n    mod_person_id,\n    post_id,\n    post.community_id,\n    m.published_at\nFROM\n    mod_feature_post m\n    INNER JOIN post ON post.id = m.post_id\nWHERE\n    is_featured_community;\n\nINSERT INTO modlog (kind, is_revert, mod_id, target_post_id, published_at)\nSELECT\n    'AdminFeaturePostSite',\n    NOT featured,\n    mod_person_id,\n    post_id,\n    published_at\nFROM\n    mod_feature_post\nWHERE\n    NOT is_featured_community;\n\nALTER TABLE notification\n    DROP CONSTRAINT IF EXISTS notification_check;\n\n-- Rewrite notifications to reference new modlog table. This is not used in production yet\n-- so no need to copy over data.\nALTER TABLE notification\n    DROP COLUMN admin_add_id,\n    DROP COLUMN mod_add_to_community_id,\n    DROP COLUMN admin_ban_id,\n    DROP COLUMN mod_ban_from_community_id,\n    DROP COLUMN mod_lock_post_id,\n    DROP COLUMN mod_remove_comment_id,\n    DROP COLUMN admin_remove_community_id,\n    DROP COLUMN mod_remove_post_id,\n    DROP COLUMN mod_lock_comment_id,\n    DROP COLUMN mod_transfer_community_id,\n    ADD COLUMN modlog_id int REFERENCES modlog ON UPDATE CASCADE ON DELETE CASCADE;\n\nDELETE FROM notification\nWHERE post_id IS NULL\n    AND comment_id IS NULL\n    AND private_message_id IS NULL\n    AND modlog_id IS NULL;\n\nALTER TABLE notification\n    ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, modlog_id) = 1);\n\nCREATE INDEX idx_notification_modlog_id ON notification USING btree (modlog_id)\nWHERE (modlog_id IS NOT NULL);\n\nDROP TABLE modlog_combined, admin_add, admin_allow_instance, admin_ban, admin_block_instance, admin_remove_community, admin_purge_comment, admin_purge_community, admin_purge_person, admin_purge_post, mod_add_to_community, mod_ban_from_community, mod_change_community_visibility, mod_feature_post, mod_lock_comment, mod_lock_post, mod_remove_comment, mod_remove_post, mod_transfer_community;\n\n"
  },
  {
    "path": "migrations/2025-11-05-181519-0000_add_registration_application_updated_at/down.sql",
    "content": "DROP INDEX idx_registration_application_updated;\n\nALTER TABLE registration_application\n    DROP COLUMN updated_at;\n\n"
  },
  {
    "path": "migrations/2025-11-05-181519-0000_add_registration_application_updated_at/up.sql",
    "content": "ALTER TABLE registration_application\n    ADD COLUMN updated_at timestamptz;\n\nCREATE INDEX idx_registration_application_updated ON registration_application (updated_at DESC);\n\n"
  },
  {
    "path": "migrations/2025-11-08-123111-0000_add_multi_community_subscribers_community_count/down.sql",
    "content": "DROP INDEX idx_multi_community_lower_name;\n\nDROP INDEX idx_multi_community_subscribers;\n\nDROP INDEX idx_multi_community_subscribers_local;\n\nDROP INDEX idx_multi_community_communities;\n\nDROP INDEX idx_multi_community_published;\n\nALTER TABLE multi_community\n    DROP COLUMN subscribers,\n    DROP COLUMN subscribers_local,\n    DROP COLUMN communities;\n\n"
  },
  {
    "path": "migrations/2025-11-08-123111-0000_add_multi_community_subscribers_community_count/up.sql",
    "content": "ALTER TABLE multi_community\n    ADD COLUMN subscribers int NOT NULL DEFAULT 0,\n    ADD COLUMN subscribers_local int NOT NULL DEFAULT 0,\n    ADD COLUMN communities int NOT NULL DEFAULT 0;\n\n-- Add indexes for all the sorts, to somewhat match the ones on community\nCREATE INDEX idx_multi_community_lower_name ON multi_community (lower(name::text) DESC, id DESC);\n\nCREATE INDEX idx_multi_community_subscribers ON multi_community (subscribers DESC, id DESC);\n\nCREATE INDEX idx_multi_community_subscribers_local ON multi_community (subscribers_local DESC, id DESC);\n\nCREATE INDEX idx_multi_community_communities ON multi_community (communities DESC, id DESC);\n\nCREATE INDEX idx_multi_community_published ON multi_community (published_at DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2026-01-08-132525-0000_community-sidebar-summary/down.sql",
    "content": "ALTER TABLE community RENAME COLUMN description TO sidebar;\n\nALTER TABLE community RENAME summary TO description;\n\nALTER TABLE community_report RENAME original_community_description TO original_community_sidebar;\n\nALTER TABLE community_report RENAME original_community_summary TO original_community_description;\n\nALTER TABLE site RENAME COLUMN description TO sidebar;\n\nALTER TABLE site RENAME summary TO description;\n\n"
  },
  {
    "path": "migrations/2026-01-08-132525-0000_community-sidebar-summary/up.sql",
    "content": "ALTER TABLE community RENAME description TO summary;\n\nALTER TABLE community RENAME COLUMN sidebar TO description;\n\nALTER TABLE community_report RENAME original_community_description TO original_community_summary;\n\nALTER TABLE community_report RENAME original_community_sidebar TO original_community_description;\n\nALTER TABLE site RENAME description TO summary;\n\nALTER TABLE site RENAME COLUMN sidebar TO description;\n\n"
  },
  {
    "path": "migrations/2026-01-19-122321-0000_add_community_tag_color/down.sql",
    "content": "ALTER TABLE tag\n    DROP COLUMN color;\n\nDROP TYPE tag_color_enum;\n\n"
  },
  {
    "path": "migrations/2026-01-19-122321-0000_add_community_tag_color/up.sql",
    "content": "-- creates a new tag color enum\nCREATE TYPE tag_color_enum AS ENUM (\n    'color01',\n    'color02',\n    'color03',\n    'color04',\n    'color05',\n    'color06',\n    'color07',\n    'color08',\n    'color09',\n    'color10'\n);\n\nALTER TABLE tag\n    ADD COLUMN color tag_color_enum DEFAULT 'color01' NOT NULL;\n\n"
  },
  {
    "path": "migrations/2026-01-23-094410-0000_rename-sidebar-again/down.sql",
    "content": "ALTER TABLE community RENAME sidebar TO description;\n\nALTER TABLE community_report RENAME original_community_sidebar TO original_community_description;\n\nALTER TABLE site RENAME sidebar TO description;\n\nALTER TABLE multi_community RENAME summary TO description;\n\nALTER TABLE tag RENAME summary TO description;\n\nALTER TABLE tag\n    ALTER description TYPE text;\n\n"
  },
  {
    "path": "migrations/2026-01-23-094410-0000_rename-sidebar-again/up.sql",
    "content": "ALTER TABLE community RENAME description TO sidebar;\n\nALTER TABLE community_report RENAME original_community_description TO original_community_sidebar;\n\nALTER TABLE site RENAME description TO sidebar;\n\n-- using summary for this because it has 150 char limit\nALTER TABLE multi_community RENAME description TO summary;\n\nALTER TABLE tag RENAME description TO summary;\n\nALTER TABLE tag\n    ALTER summary TYPE varchar(150);\n\n"
  },
  {
    "path": "migrations/2026-01-23-140244-0000_rename-tag-to-community-tag/down.sql",
    "content": "ALTER TABLE post_community_tag RENAME TO post_tag;\n\nALTER TABLE post_tag RENAME community_tag_id TO tag_id;\n\nALTER TABLE community_tag RENAME TO tag;\n\n"
  },
  {
    "path": "migrations/2026-01-23-140244-0000_rename-tag-to-community-tag/up.sql",
    "content": "ALTER TABLE tag RENAME TO community_tag;\n\nALTER TABLE post_tag RENAME tag_id TO community_tag_id;\n\nALTER TABLE post_tag RENAME TO post_community_tag;\n\n"
  },
  {
    "path": "migrations/2026-01-28-115414-0000_captcha-plugin/down.sql",
    "content": "CREATE TABLE captcha_answer (\n    uuid uuid NOT NULL DEFAULT gen_random_uuid () PRIMARY KEY,\n    answer text NOT NULL,\n    published timestamptz NOT NULL DEFAULT now()\n);\n\nALTER TABLE captcha_answer RENAME COLUMN published TO published_at;\n\nALTER TABLE local_site\n    ADD COLUMN captcha_enabled boolean DEFAULT FALSE NOT NULL;\n\nALTER TABLE local_site\n    ADD COLUMN captcha_difficulty varchar(255) DEFAULT 'medium'::character varying NOT NULL;\n\n"
  },
  {
    "path": "migrations/2026-01-28-115414-0000_captcha-plugin/up.sql",
    "content": "DROP TABLE captcha_answer;\n\nALTER TABLE local_site\n    DROP COLUMN captcha_enabled;\n\nALTER TABLE local_site\n    DROP COLUMN captcha_difficulty;\n\n"
  },
  {
    "path": "migrations/2026-02-01-205644-0000_add_moderator_warn_modlog_kind/down.sql",
    "content": "-- reverting an enum value addition is not supported by postgres:\n-- https://www.postgresql.org/docs/current/datatype-enum.html#DATATYPE-ENUM-IMPLEMENTATION-DETAILS\n-- so this workaround is necessary\nCREATE TYPE modlog_kind_old AS ENUM (\n    'AdminAdd',\n    'AdminBan',\n    'AdminAllowInstance',\n    'AdminBlockInstance',\n    'AdminPurgeComment',\n    'AdminPurgeCommunity',\n    'AdminPurgePerson',\n    'AdminPurgePost',\n    'ModAddToCommunity',\n    'ModBanFromCommunity',\n    'ModFeaturePostCommunity',\n    'AdminFeaturePostSite',\n    'ModChangeCommunityVisibility',\n    'ModLockPost',\n    'ModRemoveComment',\n    'AdminRemoveCommunity',\n    'ModRemovePost',\n    'ModTransferCommunity',\n    'ModLockComment'\n);\n\nALTER TABLE modlog\n    DROP CONSTRAINT IF EXISTS modlog_check;\n\nALTER TABLE modlog\n    ALTER COLUMN kind TYPE modlog_kind_old\n    USING kind::text::modlog_kind_old;\n\nDROP TYPE modlog_kind;\n\nALTER TYPE modlog_kind_old RENAME TO modlog_kind;\n\nALTER TABLE modlog\n    ADD CONSTRAINT modlog_check CHECK ((kind = 'AdminAdd' AND num_nonnulls (target_person_id) = 1 AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0) OR (kind = 'AdminBan' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'ModRemovePost' AND num_nonnulls (target_post_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModRemoveComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3 AND num_nonnulls (target_community_id, target_instance_id) = 0) OR (kind = 'ModLockComment' AND num_nonnulls (target_comment_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0) OR (kind = 'ModLockPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminRemoveCommunity' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModChangeCommunityVisibility' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'ModBanFromCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModAddToCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModTransferCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminAllowInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminBlockInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminPurgeComment' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePost' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgeCommunity' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePerson' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModFeaturePostCommunity' AND num_nonnulls (target_post_id, target_community_id) = 2 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'AdminFeaturePostSite' AND num_nonnulls (target_post_id) = 1 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0));\n\n"
  },
  {
    "path": "migrations/2026-02-01-205644-0000_add_moderator_warn_modlog_kind/up.sql",
    "content": "ALTER TYPE modlog_kind\n    ADD VALUE 'ModWarnComment';\n\nALTER TYPE modlog_kind\n    ADD VALUE 'ModWarnPost';\n\n"
  },
  {
    "path": "migrations/2026-02-03-235249-0000_add_moderator_warn_constraint_check/down.sql",
    "content": "-- remove ModWarn from constraint checks\nALTER TABLE modlog\n    DROP CONSTRAINT IF EXISTS modlog_check;\n\nALTER TABLE modlog\n    ADD CHECK ((kind = 'AdminAdd'\n        AND num_nonnulls (target_person_id) = 1\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0)\n        OR (kind = 'AdminBan'\n        AND num_nonnulls (target_person_id, target_instance_id) = 2\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0)\n        OR (kind = 'ModRemovePost'\n        AND num_nonnulls (target_post_id, target_person_id) = 2\n        AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModRemoveComment'\n        AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3\n        AND num_nonnulls (target_community_id, target_instance_id) = 0)\n        OR (kind = 'ModLockComment'\n        AND num_nonnulls (target_comment_id, target_person_id) = 2\n        AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0)\n        OR (kind = 'ModLockPost'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminRemoveCommunity'\n        AND num_nonnulls (target_community_id) = 1\n        -- target_person_id (community owner) can be either null or not null here\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModChangeCommunityVisibility'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'ModBanFromCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModAddToCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModTransferCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminAllowInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminBlockInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeComment'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePost'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeCommunity'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePerson'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModFeaturePostCommunity'\n        AND num_nonnulls (target_post_id, target_community_id) = 2\n        AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'AdminFeaturePostSite'\n        AND num_nonnulls (target_post_id) = 1\n        AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0));\n\n"
  },
  {
    "path": "migrations/2026-02-03-235249-0000_add_moderator_warn_constraint_check/up.sql",
    "content": "-- add ModWarn to constraint checks\nALTER TABLE modlog\n    DROP CONSTRAINT IF EXISTS modlog_check;\n\nALTER TABLE modlog\n    ADD CHECK ((kind = 'AdminAdd'\n        AND num_nonnulls (target_person_id, target_instance_id) = 2\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0)\n        OR (kind = 'AdminBan'\n        AND num_nonnulls (target_person_id, target_instance_id) = 2\n        AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0)\n        OR (kind = 'ModRemovePost'\n        AND num_nonnulls (target_post_id, target_community_id, target_person_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModRemoveComment'\n        AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4\n        AND num_nonnulls (target_instance_id) = 0)\n        OR (kind = 'ModLockComment'\n        AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4\n        AND num_nonnulls (target_instance_id) = 0)\n        OR (kind = 'ModWarnComment'\n        AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4\n        AND num_nonnulls (target_instance_id) = 0)\n        OR (kind = 'ModLockPost'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModWarnPost'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminRemoveCommunity'\n        AND num_nonnulls (target_community_id, target_instance_id) = 2\n        -- target_person_id (community owner) can be either null or not null here\n        AND num_nonnulls (target_post_id, target_comment_id) = 0)\n        OR (kind = 'ModChangeCommunityVisibility'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'ModBanFromCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModAddToCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModTransferCommunity'\n        AND num_nonnulls (target_community_id, target_person_id) = 2\n        AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminAllowInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminBlockInstance'\n        AND num_nonnulls (target_instance_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeComment'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3\n        AND num_nonnulls (target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePost'\n        AND num_nonnulls (target_community_id) = 1\n        AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgeCommunity'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'AdminPurgePerson'\n        AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0)\n        OR (kind = 'ModFeaturePostCommunity'\n        AND num_nonnulls (target_post_id, target_community_id) = 2\n        AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0)\n        OR (kind = 'AdminFeaturePostSite'\n        AND num_nonnulls (target_post_id, target_community_id, target_instance_id) = 3\n        AND num_nonnulls (target_person_id, target_comment_id) = 0));\n\n"
  },
  {
    "path": "migrations/2026-02-19-120000-0000_add_bulk_to_modlog/down.sql",
    "content": "DROP INDEX IF EXISTS idx_modlog_bulk_action_parent_id;\n\nALTER TABLE modlog\n    DROP COLUMN bulk_action_parent_id;\n\n"
  },
  {
    "path": "migrations/2026-02-19-120000-0000_add_bulk_to_modlog/up.sql",
    "content": "ALTER TABLE modlog\n    ADD COLUMN bulk_action_parent_id int REFERENCES modlog (id) ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE INDEX idx_modlog_bulk_action_parent_id ON modlog (bulk_action_parent_id);\n\n"
  },
  {
    "path": "migrations/2026-02-19-192014-0000_rename_suggested_communities/down.sql",
    "content": "ALTER TABLE local_site RENAME COLUMN suggested_multi_community_id TO suggested_communities;\n\n"
  },
  {
    "path": "migrations/2026-02-19-192014-0000_rename_suggested_communities/up.sql",
    "content": "ALTER TABLE local_site RENAME COLUMN suggested_communities TO suggested_multi_community_id;\n\n"
  },
  {
    "path": "migrations/2026-02-24-205759-0000_add_notification_creator_id/down.sql",
    "content": "ALTER TABLE notification\n    DROP COLUMN creator_id;\n\n"
  },
  {
    "path": "migrations/2026-02-24-205759-0000_add_notification_creator_id/up.sql",
    "content": "-- Add a creator_id column to notifications.\nALTER TABLE notification\n    ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE;\n\n-- Update the data\n-- Private messages\nUPDATE\n    notification n\nSET\n    creator_id = p.creator_id\nFROM\n    private_message p\nWHERE\n    n.private_message_id = p.id;\n\n-- Posts\nUPDATE\n    notification n\nSET\n    creator_id = p.creator_id\nFROM\n    post p\nWHERE\n    n.post_id = p.id;\n\n-- Comments\nUPDATE\n    notification n\nSET\n    creator_id = c.creator_id\nFROM\n    comment c\nWHERE\n    n.comment_id = c.id;\n\n-- Mod actions\nUPDATE\n    notification n\nSET\n    creator_id = m.mod_id\nFROM\n    modlog m\nWHERE\n    n.modlog_id = m.id;\n\n-- Make column not null\nALTER TABLE notification\n    ALTER COLUMN creator_id SET NOT NULL;\n\n-- Create an index\nCREATE INDEX idx_notification_creator ON notification (creator_id);\n\n"
  },
  {
    "path": "migrations/2026-03-02-231448-0000_add_multi_community_sidebar/down.sql",
    "content": "ALTER TABLE multi_community\n    DROP COLUMN sidebar;\n\n"
  },
  {
    "path": "migrations/2026-03-02-231448-0000_add_multi_community_sidebar/up.sql",
    "content": "ALTER TABLE multi_community\n    ADD COLUMN sidebar text;\n\n"
  },
  {
    "path": "migrations/2026-03-03-211442-0000_move_config_pictrs_to_db/down.sql",
    "content": "ALTER TABLE local_site\n    DROP COLUMN image_mode,\n    DROP COLUMN image_proxy_bypass_domains,\n    DROP COLUMN image_upload_timeout_seconds,\n    DROP COLUMN image_max_thumbnail_size,\n    DROP COLUMN image_max_avatar_size,\n    DROP COLUMN image_max_banner_size,\n    DROP COLUMN image_max_upload_size,\n    DROP COLUMN image_allow_video_uploads,\n    DROP COLUMN image_upload_disabled;\n\nDROP TYPE image_mode_enum;\n\n"
  },
  {
    "path": "migrations/2026-03-03-211442-0000_move_config_pictrs_to_db/up.sql",
    "content": "-- This moves a few pictrs related settings in the config, to the database\nCREATE TYPE image_mode_enum AS enum (\n    'None',\n    'StoreLinkPreviews',\n    'ProxyAllImages'\n);\n\nALTER TABLE local_site\n    ADD COLUMN image_mode image_mode_enum NOT NULL DEFAULT 'ProxyAllImages',\n    ADD COLUMN image_proxy_bypass_domains text,\n    ADD COLUMN image_upload_timeout_seconds int NOT NULL DEFAULT 30,\n    ADD COLUMN image_max_thumbnail_size int NOT NULL DEFAULT 512,\n    ADD COLUMN image_max_avatar_size int NOT NULL DEFAULT 512,\n    ADD COLUMN image_max_banner_size int NOT NULL DEFAULT 1024,\n    ADD COLUMN image_max_upload_size int NOT NULL DEFAULT 1024,\n    ADD COLUMN image_allow_video_uploads boolean NOT NULL DEFAULT TRUE,\n    ADD COLUMN image_upload_disabled boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2026-03-04-143123-0000_add_deleted_by_recip_to_pm/down.sql",
    "content": "ALTER TABLE private_message\n    DROP COLUMN deleted_by_recipient;\n\n"
  },
  {
    "path": "migrations/2026-03-04-143123-0000_add_deleted_by_recip_to_pm/up.sql",
    "content": "ALTER TABLE private_message\n    ADD COLUMN deleted_by_recipient boolean NOT NULL DEFAULT FALSE;\n\n"
  },
  {
    "path": "migrations/2026-03-08-021022-0000_fixup_post_action_indexes/down.sql",
    "content": "DROP INDEX idx_post_actions_person_hidden;\n\nCREATE INDEX idx_post_actions_hidden_not_null ON post_actions (person_id, post_id)\nWHERE\n    hidden_at IS NOT NULL;\n\nDROP INDEX idx_post_actions_person_read;\n\nCREATE INDEX idx_post_actions_read_not_null ON post_actions (person_id, post_id)\nWHERE\n    read_at IS NOT NULL;\n\nCREATE INDEX idx_post_actions_on_read_read_not_null ON post_actions (person_id, read_at, post_id)\nWHERE\n    read_at IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2026-03-08-021022-0000_fixup_post_action_indexes/up.sql",
    "content": "-- Remove a pointless hidden index, and add a better one.\nDROP INDEX idx_post_actions_hidden_not_null;\n\nCREATE INDEX idx_post_actions_person_hidden ON post_actions (person_id, hidden_at DESC, post_id DESC)\nWHERE\n    hidden_at IS NOT NULL;\n\n-- Remove 2 pointless read_at indexes, create a better one.\nDROP INDEX idx_post_actions_read_not_null, idx_post_actions_on_read_read_not_null;\n\nCREATE INDEX idx_post_actions_person_read ON post_actions (person_id, read_at DESC, post_id DESC)\nWHERE\n    read_at IS NOT NULL;\n\n"
  },
  {
    "path": "migrations/2026-03-08-202630-0000_add_modlog_foreign_keys/down.sql",
    "content": "ALTER TABLE modlog\n    DROP CONSTRAINT modlog_mod_fkey,\n    DROP CONSTRAINT modlog_target_person_fkey,\n    DROP CONSTRAINT modlog_target_community_fkey,\n    DROP CONSTRAINT modlog_target_post_fkey,\n    DROP CONSTRAINT modlog_target_comment_fkey,\n    DROP CONSTRAINT modlog_target_instance_fkey;\n\nDROP INDEX idx_modlog_mod, idx_modlog_kind, idx_modlog_target_person, idx_modlog_target_community, idx_modlog_target_post, idx_modlog_target_comment, idx_modlog_target_instance, idx_modlog_published_id\n"
  },
  {
    "path": "migrations/2026-03-08-202630-0000_add_modlog_foreign_keys/up.sql",
    "content": "-- Use on delete set null for all these (except require mod_id), so that if an item is purged, the modlog rows will still remain.\nALTER TABLE modlog\n    ADD CONSTRAINT modlog_mod_fkey FOREIGN KEY (mod_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_target_person_fkey FOREIGN KEY (target_person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_target_community_fkey FOREIGN KEY (target_community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_target_post_fkey FOREIGN KEY (target_post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_target_comment_fkey FOREIGN KEY (target_comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE,\n    ADD CONSTRAINT modlog_target_instance_fkey FOREIGN KEY (target_instance_id) REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE INDEX idx_modlog_kind ON modlog (kind);\n\nCREATE INDEX idx_modlog_mod ON modlog (mod_id);\n\nCREATE INDEX idx_modlog_target_person ON modlog (target_person_id)\nWHERE\n    target_person_id IS NOT NULL;\n\nCREATE INDEX idx_modlog_target_community ON modlog (target_community_id)\nWHERE\n    target_community_id IS NOT NULL;\n\nCREATE INDEX idx_modlog_target_post ON modlog (target_post_id)\nWHERE\n    target_post_id IS NOT NULL;\n\nCREATE INDEX idx_modlog_target_comment ON modlog (target_comment_id)\nWHERE\n    target_comment_id IS NOT NULL;\n\nCREATE INDEX idx_modlog_target_instance ON modlog (target_instance_id)\nWHERE\n    target_instance_id IS NOT NULL;\n\nCREATE INDEX idx_modlog_published_id ON modlog (published_at DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2026-03-09-014616-0000_add_resolved_report_combined/down.sql",
    "content": "DROP INDEX idx_report_combined_published_asc;\n\nALTER TABLE report_combined\n    DROP COLUMN resolved;\n\nCREATE INDEX idx_report_combined_published_asc ON report_combined (reverse_timestamp_sort (published_at) DESC, id DESC);\n\n"
  },
  {
    "path": "migrations/2026-03-09-014616-0000_add_resolved_report_combined/up.sql",
    "content": "-- Adds resolved to the report combined table to speed up queries.\nALTER TABLE report_combined\n    ADD COLUMN resolved boolean NOT NULL DEFAULT FALSE;\n\n-- post\nUPDATE\n    report_combined AS rc\nSET\n    resolved = r.resolved\nFROM\n    post_report r\nWHERE\n    rc.post_report_id = r.id;\n\n-- comment\nUPDATE\n    report_combined AS rc\nSET\n    resolved = r.resolved\nFROM\n    comment_report r\nWHERE\n    rc.comment_report_id = r.id;\n\n-- community\nUPDATE\n    report_combined AS rc\nSET\n    resolved = r.resolved\nFROM\n    community_report r\nWHERE\n    rc.community_report_id = r.id;\n\n-- private message\nUPDATE\n    report_combined AS rc\nSET\n    resolved = r.resolved\nFROM\n    private_message_report r\nWHERE\n    rc.private_message_report_id = r.id;\n\n-- For unresolved, its an asc query\nDROP INDEX idx_report_combined_published_asc;\n\nCREATE INDEX idx_report_combined_published_asc ON report_combined (resolved, published_at, id);\n\n"
  },
  {
    "path": "readmes/README.es.md",
    "content": "<div align=\"center\">\n\n![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)\n[![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)\n[![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)\n\n</div>\n\n<p align=\"center\">\n  <a href=\"../README.md\">English</a> |\n  <span>Español</span> |\n  <a href=\"README.ru.md\">Русский</a> |\n  <a href=\"README.zh.hans.md\">汉语</a> |\n  <a href=\"README.zh.hant.md\">漢語</a> |\n  <a href=\"README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    Un agregador de enlaces / alternativa a Menéame - Reddit para el fediverso. \n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">Unirse a Lemmy</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/es/index.html\">Documentación</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Reportar Errores (bugs)</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Solicitar Características</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">Lanzamientos</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/es/code_of_conduct.html\">Código de Conducta</a>\n  </p>\n</p>\n\n## Sobre El Proyecto\n\n| Escritorio                                                                                                      | Móvil                                                                                                       |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) es similar a sitios como [Menéame](https://www.meneame.net/), [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), o [Hacker News](https://news.ycombinator.com/): te subscribes a los foros que te interesan, publicas enlaces y debates, luego votas y comentas en ellos. Entre bastidores, es muy diferente; cualquiera puede gestionar fácilmente un servidor, y todos estos servidores son federados (piensa en el correo electrónico), y conectados al mismo universo, llamado [Fediverso](https://es.wikipedia.org/wiki/Fediverso).\n\nPara un agregador de enlaces, esto significa que un usuario registrado en un servidor puede suscribirse a los foros de otro servidor, y puede mantener discusiones con usuarios registrados en otros lugares.\n\nEl objetivo general es crear una alternativa a reddit y otros agregadores de enlaces, fácilmente auto-hospedada, descentralizada, fuera de su control e intromisión corporativa.\n\nCada servidor lemmy puede establecer su propia política de moderación; nombrando a los administradores del sitio y a los moderadores de la comunidad para mantener alejados a los trolls, y fomentar un entorno saludable y no tóxico en el que puedan sentirse cómodos contribuyendo.\n\n_Nota: Las APIs WebSocket y HTTP actualmente son inestables_\n\n### ¿Por qué se llama Lemmy?\n\n- Cantante principal de [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).\n- El [videojuego de la vieja escuela](https://es.wikipedia.org/wiki/Lemmings).\n- El [Koopa de Super Mario](https://www.mariowiki.com/Lemmy_Koopa).\n- Los [roedores peludos](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).\n\n### Creado Con\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n# Características\n\n- Código abierto, [Licencia AGPL](/LICENSE).\n- Auto-hospedado, fácil de desplegar (deploy).\n  - Viene con [Docker](#docker) y [Ansible](#ansible).\n- Interfaz limpia y fácil de usar. Apta para dispositivos móviles.\n  - Sólo se requiere como mínimo un nombre de usuario y una contraseñar para inscribirse!\n  - Soporte de avatar de usuario.\n  - Hilos de comentarios actualizados en directo.\n  - Puntuaciones completas de los votos `(+/-)` como en el antiguo reddit.\n  - Temas, incluidos los claros, los oscuros, y los solarizados.\n  - Emojis con soporte de autocompletado. Empieza tecleando `:`\n    - _Ejemplo_ `miau :cat:` => `miau 🐈`\n  - Etiquetado de Usuarios con `@`, etiquetado de Comunidades con `!`.\n    - _Ejemplo_ `@miguel@lemmy.ml me invitó a la comunidad !gaming@lemmy.ml`\n  - Carga de imágenes integrada tanto en las publicaciones como en los comentarios.\n  - Una publicación puede consistir en un título y cualquier combinación de texto propio, una URL o nada más.\n  - Notificaciones, sobre las respuestas a los comentarios y cuando te etiquetan.\n    - Las notificaciones se pueden enviar por correo electrónico.\n    - Soporte para mensajes privados.\n  - Soporte de i18n / internacionalización.\n  - Fuentes RSS / Atom para Todo `All`, Suscrito `Subscribed`, Bandeja de entrada `inbox`, Usuario `User`, y Comunidad `Community`.\n- Soporte para la publicación cruzada (cross-posting).\n  - **búsqueda de publicaciones similares** al crear una nueva. Ideal para comunidades de preguntas y respuestas.\n- Capacidades de moderación.\n  - Registros públicos de moderación.\n  - Puedes pegar las publicaciones a la parte superior de las comunidades.\n  - Tanto los administradores del sitio, como los moderadores de la comunidad, pueden nombrar a otros moderadores.\n  - Puedes bloquear, eliminar y restaurar publicaciones y comentarios.\n  - Puedes banear y desbanear usuarios de las comunidades y del sitio.\n  - Puedes transferir el sitio y las comunidades a otros.\n- Puedes borrar completamente tus datos, reemplazando todas las publicaciones y comentarios.\n- Soporte para publicaciones y comunidades NSFW.\n- Alto rendimiento.\n  - El servidor está escrito en rust.\n  - El front end está comprimido (gzipped) en `~80kB`.\n  - El front end funciona sin javascript (sólo lectura).\n  - Soporta arm64 / Raspberry Pi.\n\n## Instalación\n\n- [Docker](https://join-lemmy.org/docs/es/administration/install_docker.html)\n- [Ansible](https://join-lemmy.org/docs/es/administration/install_ansible.html)\n\n## Proyectos de Lemmy\n\n### Aplicaciones\n\n- [lemmy-ui - La aplicación web oficial para lemmy](https://github.com/LemmyNet/lemmy-ui)\n- [Lemmur - Un cliente móvil para Lemmy (Android, Linux, Windows)](https://github.com/LemmurOrg/lemmur)\n- [Remmel - Una aplicación IOS nativa](https://github.com/uuttff8/Lemmy-iOS)\n\n### Librerías\n\n- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)\n- [Kotlin API ( en desarrollo )](https://github.com/eiknat/lemmy-client)\n- [Dart API client ( en desarrollo )](https://github.com/LemmurOrg/lemmy_api_client)\n\n## Apoyo / Donación\n\nLemmy es un software libre y de código abierto, lo que significa que no hay publicidad, monetización o capital de riesgo, nunca. Tus donaciones apoyan directamente el desarrollo a tiempo completo del proyecto.\n\n- [Apoya en Liberapay](https://liberapay.com/Lemmy).\n- [Apoya en Ko-fi](https://ko-fi.com/lemmynet).\n- [Apoya en Patreon](https://www.patreon.com/dessalines).\n- [Apoya en OpenCollective](https://opencollective.com/lemmy).\n- [Lista de patrocinadores](https://join-lemmy.org/sponsors).\n\n### Crypto\n\n- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n\n## Contribuir\n\n- [Instrucciones para contribuir](https://join-lemmy.org/docs/es/contributing/contributing.html)\n- [Desarrollo por Docker](https://join-lemmy.org/docs/es/contributing/docker_development.html)\n- [Desarrollo Local](https://join-lemmy.org/docs/es/contributing/local_development.html)\n\n### Traducciones\n\nSi quieres ayudar con la traducción, echa un vistazo a [Weblate](https://weblate.yerbamate.ml/projects/lemmy/). También puedes ayudar [traduciendo la documentación](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).\n\n## Contacto\n\n- [Mastodon](https://mastodon.social/@LemmyDev)\n- [Matrix](https://matrix.to/#/#lemmy:matrix.org)\n\n## Repositorios del código\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://yerbamate.ml/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## Creditos\n\nLogo hecho por Andy Cuccaro (@andycuccaro) bajo la licencia CC-BY-SA 4.0.\n"
  },
  {
    "path": "readmes/README.ja.md",
    "content": "<div align=\"center\">\n\n![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)\n[![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)\n[![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)\n\n</div>\n\n<p align=\"center\">\n  <a href=\"../README.md\">English</a> |\n  <a href=\"README.ru.md\">Español</a> |\n  <a href=\"README.ru.md\">Русский</a> |\n  <a href=\"README.zh.hans.md\">汉语</a> |\n  <a href=\"README.zh.hant.md\">漢語</a> |\n  <span>日本語</span>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    フェディバースのリンクアグリゲーターとフォーラムです。\n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">Lemmy に参加</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/index.html\">ドキュメント</a>\n    ·\n    <a href=\"https://matrix.to/#/#lemmy-space:matrix.org\">マトリックスチャット</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">バグを報告</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">機能リクエスト</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">リリース</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/code_of_conduct.html\">行動規範</a>\n  </p>\n</p>\n\n## プロジェクトについて\n\n| デスクトップ                                                                                                    | モバイル                                                                                                    |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) は、[Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs)、[Hacker News](https://news.ycombinator.com/) といったサイトに似ています。興味のあるフォーラムを購読してリンクや議論を掲載し、投票したり、コメントしたりしています。誰でも簡単にサーバーを運営することができ、これらのサーバーはすべて連合しており（電子メールを考えてください）、[Fediverse](https://en.wikipedia.org/wiki/Fediverse) と呼ばれる同じ宇宙に接続されています。\n\nリンクアグリゲーターの場合、あるサーバーに登録したユーザーが他のサーバーのフォーラムを購読し、他のサーバーに登録したユーザーとディスカッションができることを意味します。\n\nReddit や他のリンクアグリゲーターに代わる、企業の支配や干渉を受けない、簡単にセルフホスティングできる分散型の代替手段です。\n\n各 Lemmy サーバーは、独自のモデレーションポリシーを設定することができます。サイト全体の管理者やコミュニティモデレーターを任命し、荒らしを排除し、誰もが安心して貢献できる健全で毒気のない環境を育みます。\n\n### なぜ Lemmy というのか？\n\n- [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g) のリードシンガー。\n- 古くは[ビデオゲーム](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>)。\n- [スーパーマリオのクッパ](https://www.mariowiki.com/Lemmy_Koopa)。\n- [毛むくじゃらの齧歯類](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。\n\n### こちらでビルド\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n## 特徴\n\n- オープンソース、[AGPL License](/LICENSE) です。\n- セルフホスティングが可能で、デプロイが容易です。\n  - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) と [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) が付属しています。\n- クリーンでモバイルフレンドリーなインターフェイス。\n  - サインアップに必要なのは、最低限のユーザー名とパスワードのみ！\n  - ユーザーアバター対応\n  - ライブ更新のコメントスレッド\n  - 古い Reddit のような完全な投票スコア `(+/-)`.\n  - ライト、ダーク、ソラライズなどのテーマがあります。\n  - オートコンプリートをサポートする絵文字。`:` と入力することでスタート\n  - ユーザータグは `@` で、コミュニティタグは `!` で入力できます。\n  - 投稿とコメントの両方で、画像のアップロードが可能です。\n  - 投稿は、タイトルと自己テキスト、URL、またはそれ以外の任意の組み合わせで構成できます。\n  - コメントの返信や、タグ付けされたときに、通知します。\n    - 通知はメールで送ることができます。\n    - プライベートメッセージのサポート\n  - i18n / 国際化のサポート\n  - `All`、`Subscribed`、`Inbox`、`User`、`Community` の RSS / Atom フィードを提供します。\n- クロスポストのサポート。\n  - 新しい投稿を作成する際の _類似投稿検索_。質問/回答コミュニティに最適です。\n- モデレーション機能。\n  - モデレーションのログを公開。\n  - コミュニティのトップページにスティッキー・ポストを貼ることができます。\n  - サイト管理者、コミュニティモデレーターの両方が、他のモデレーターを任命することができます。\n  - 投稿やコメントのロック、削除、復元が可能。\n  - コミュニティやサイトの利用を禁止したり、禁止を解除したりすることができます。\n  - サイトとコミュニティを他者に譲渡することができます。\n- すべての投稿とコメントを削除し、データを完全に消去することができます。\n- NSFW 投稿/コミュニティサポート\n- 高いパフォーマンス。\n  - サーバーは Rust で書かれています。\n  - フロントエンドは `~80kB` gzipped です。\n  - arm64 / Raspberry Pi をサポートします。\n\n## インストール\n\n- [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html)\n- [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)\n\n## Lemmy プロジェクト\n\n### アプリ\n\n- [lemmy-ui - Lemmy の公式ウェブアプリ](https://github.com/LemmyNet/lemmy-ui)\n- [lemmyBB -phpBB をベースにした Lemmy フォーラム UI](https://github.com/LemmyNet/lemmyBB)\n- [Jerboa - Lemmy の開発者が作った Android ネイティブアプリ](https://github.com/dessalines/jerboa)\n- [Mlem - iOS 用 Lemmy クライアント](https://github.com/buresdv/Mlem)\n\n### ライブラリ\n\n- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)\n- [lemmy-rust-client](https://github.com/LemmyNet/lemmy/tree/main/crates/api_common)\n- [go-lemmy](https://gitea.arsenm.dev/Arsen6331/go-lemmy)\n- [Dart API client](https://github.com/LemmurOrg/lemmy_api_client)\n- [Lemmy-Swift-Client](https://github.com/rrainn/Lemmy-Swift-Client)\n- [Reddit -> Lemmy Importer](https://github.com/rileynull/RedditLemmyImporter)\n- [lemmy-bot - Lemmy のボットを簡単に作るための Typescript ライブラリ](https://github.com/SleeplessOne1917/lemmy-bot)\n- [Lemmy の Reddit API ラッパー](https://github.com/derivator/tafkars)\n- [Pythörhead - Lemmy API と統合するための Python パッケージ](https://pypi.org/project/pythorhead/)\n\n## サポート / 寄付\n\nLemmy はフリーでオープンソースのソフトウェアです。つまり、広告やマネタイズ、ベンチャーキャピタルは一切ありません。あなたの寄付は、直接プロジェクトのフルタイム開発をサポートします。\n\n- [Liberapay でのサポート](https://liberapay.com/Lemmy)。\n- [Ko-fi でのサポート](https://ko-fi.com/lemmynet).\n- [Patreon でのサポート](https://www.patreon.com/dessalines)。\n- [OpenCollective でのサポート](https://opencollective.com/lemmy)。\n- [スポンサーのリスト](https://join-lemmy.org/donate)。\n\n### 暗号通貨\n\n- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n\n## コントリビュート\n\n- [コントリビュート手順](https://join-lemmy.org/docs/en/contributors/01-overview.html)\n- [Docker 開発](https://join-lemmy.org/docs/en/contributors/03-docker-development.html)\n- [Local 開発](https://join-lemmy.org/docs/en/contributors/02-local-development.html)\n\n### 翻訳について\n\n- 翻訳を手伝いたい方は、[Weblate](https://weblate.join-lemmy.org/projects/lemmy/) を見てみてください。また、[ドキュメントを翻訳する](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)ことでも支援できます。\n\n## お問い合わせ\n\n- [Mastodon](https://mastodon.social/@LemmyDev)\n- [Lemmy サポートフォーラム](https://lemmy.ml/c/lemmy_support)\n\n## コードのミラー\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## クレジット\n\nロゴは Andy Cuccaro (@andycuccaro) が CC-BY-SA 4.0 ライセンスで作成しました。\n"
  },
  {
    "path": "readmes/README.ru.md",
    "content": "<div align=\"center\">\n\n![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)\n[![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)\n[![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)\n\n</div>\n\n<p align=\"center\">\n  <a href=\"../README.md\">English</a> |\n  <a href=\"README.es.md\">Español</a> |\n  <span>Русский</span> |\n  <a href=\"README.zh.hans.md\">汉语</a> |\n  <a href=\"README.zh.hant.md\">漢語</a> |\n  <a href=\"README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    Агрегатор ссылок / Клон Reddit для федиверс.\n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">Присоединяйтесь к Lemmy</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/index.html\">Документация</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Сообщить об Ошибке</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">Запросить функционал</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">Релизы</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/code_of_conduct.html\">Нормы поведения</a>\n  </p>\n</p>\n\n## О проекте\n\n| Десктоп                                                                                                         | Мобильный                                                                                                   |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) это аналог таких сайтов как [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), или [Hacker News](https://news.ycombinator.com/): вы подписываетесь на форумы, которые вас интересуют , размещаете ссылки и дискутируете, затем голосуете и комментируете их. Однако за кулисами всё совсем по-другому; любой может легко запустить сервер, и все эти серверы объединены (например электронная почта) и подключены к одной вселенной, именуемой [Федиверс](https://ru.wikipedia.org/wiki/Fediverse).\n\nДля агрегатора ссылок это означает, что пользователь, зарегистрированный на одном сервере, может подписаться на форумы на любом другом сервере и может вести обсуждения с пользователями, зарегистрированными в другом месте.\n\nОсновная цель - создать легко размещаемую, децентрализованную альтернативу Reddit и другим агрегаторам ссылок, вне их корпоративного контроля и вмешательства.\n\nКаждый сервер Lemmy может устанавливать свою собственную политику модерации; назначать администраторов всего сайта и модераторов сообщества для защиты от троллей и создания здоровой, нетоксичной среды, в которой каждый может чувствовать себя комфортно.\n\n_Примечание: API-интерфейсы WebSocket и HTTP в настоящее время нестабильны_\n\n### Почему назвали Lemmy (рус.Лемми)?\n\n- Ведущий певец из [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).\n- Старая школа [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).\n- Это [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).\n- Это [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).\n\n### Содержит\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n## Возможности\n\n- Открытое программное обеспечение, [Лицензия AGPL](/LICENSE).\n- Возможность самостоятельного размещения, простота развёртывания.\n  - Работает на [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) и [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html).\n- Понятый и удобный интерфейс для мобильных устройств.\n  - Для регистрации требуется минимум: имя пользователя и пароль!\n  - Поддержка аватара пользователя.\n  - Обновление цепочек комментариев в реальном времени.\n  - Полный подсчёт голосов `(+/-)` как в старом reddit.\n  - Темы, включая светлые, темные и солнечные.\n  - Эмодзи с поддержкой автозаполнения. Напечатайте `:`\n  - Упоминание пользователя тегом `@`, Упоминание сообщества тегом `!`.\n  - Интегрированная загрузка изображений как в сообщениях, так и в комментариях.\n  - Сообщение может состоять из заголовка и любой комбинации собственного текста, URL-адреса или чего-либо еще.\n  - Уведомления, ответы на комментарии и когда вас отметили.\n    - Уведомления могут быть отправлены на электронную почту.\n    - Поддержка личных сообщений.\n  - i18n / поддержка интернационализации.\n  - RSS / Atom ленты для `Все`, `Подписок`, `Входящих`, `Пользователь`, and `Сообщества`.\n- Поддержка кросс-постинга.\n  - _Поиск похожих постов_ при создании новых. Отлично подходит для вопросов / ответов сообществ.\n- Возможности модерации.\n  - Журналы (Логи) Публичной Модерации.\n  - Можно прикреплять посты в топ сообщества.\n  - Оба и администраторы сайта и модераторы сообщества, могут назначать других модераторов.\n  - Можно блокировать, удалять и восстанавливать сообщения и комментарии.\n  - Можно банить и разблокировать пользователей в сообществе и на сайте.\n  - Можно передавать сайт и сообщества другим.\n- Можно полностью стереть ваши данные, удалив все посты и комментарии.\n- NSFW (аббр. Небезопасный/неподходящий для работы) пост / поддерживается сообществом.\n- Поддержка OEmbed через Iframely.\n- Высокая производительность.\n  - Сервер написан на rust.\n  - Фронтэнд (клиентская сторона пользовательского интерфейса) всего `~80kB` архив gzipp.\n  - Поддерживается архитектура arm64 / устройства Raspberry Pi.\n\n## Установка\n\n- [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html)\n- [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)\n\n## Проекты Lemmy\n\n### Приложения\n\n- [lemmy-ui - Официальное веб приложение для lemmy](https://github.com/LemmyNet/lemmy-ui)\n- [Lemmur - Мобильные клиенты Lemmy для (Android, Linux, Windows)](https://github.com/LemmurOrg/lemmur)\n- [Remmel - Оригинальное приложение для iOS](https://github.com/uuttff8/Lemmy-iOS)\n\n### Библиотеки\n\n- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)\n- [Kotlin API ( в разработке )](https://github.com/eiknat/lemmy-client)\n- [Dart API client ( в разработке )](https://github.com/LemmurOrg/lemmy_api_client)\n\n## Поддержать / Пожертвовать\n\nLemmy - бесплатное программное обеспечение с открытым исходным кодом, что означает отсутствие рекламы, монетизации и даже венчурного капитала. Ваши пожертвования, напрямую поддерживают постоянное развитие проекта.\n\n- [Поддержать на Liberapay](https://liberapay.com/Lemmy).\n- [Поддержать на Ko-fi](https://ko-fi.com/lemmynet).\n- [Поддержать на Patreon](https://www.patreon.com/dessalines).\n- [Поддержать на OpenCollective](https://opencollective.com/lemmy).\n- [Список Спонсоров](https://join-lemmy.org/sponsors).\n\n### Криптовалюты\n\n- bitcoin (Биткоин): `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- ethereum (Эфириум): `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- monero (Монеро): `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n\n## Вклад\n\n- [Инструкции по внесению вклада](https://join-lemmy.org/docs/en/contributing/contributing.html)\n- [Docker разработка](https://join-lemmy.org/docs/en/contributing/docker_development.html)\n- [Локальное развитие](https://join-lemmy.org/docs/en/contributing/local_development.html)\n\n### Переводы\n\nЕсли вы хотите помочь с переводом, взгляните на [Weblate](https://weblate.yerbamate.ml/projects/lemmy/joinlemmy/ru/). Вы также можете помочь нам [перевести документацию](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).\n\n## Контакт\n\n- [Mastodon](https://mastodon.social/@LemmyDev)\n- [Matrix](https://matrix.to/#/#lemmy:matrix.org)\n\n## Зеркала с кодом\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://yerbamate.ml/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## Благодарность\n\nЛоготип сделан Andy Cuccaro (@andycuccaro) под лицензией CC-BY-SA 4.0.\n"
  },
  {
    "path": "readmes/README.zh.hans.md",
    "content": "<div align=\"center\">\n\n![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)\n[![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)\n[![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)\n\n</div>\n\n<p align=\"center\">\n  <a href=\"../README.md\">English</a> |\n  <a href=\"README.es.md\">Español</a> |\n  <a href=\"README.ru.md\">Русский</a> |\n  <span>汉语</span> |\n  <a href=\"README.zh.hant.md\">漢語</a> |\n  <a href=\"README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    一个联邦宇宙的链接聚合器和论坛。\n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">加入 Lemmy</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/index.html\">文档</a>\n    ·\n    <a href=\"https://matrix.to/#/#lemmy-space:matrix.org\">Matrix 群组</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">报告缺陷</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">请求新特性</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">发行版</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/code_of_conduct.html\">行为准则</a>\n  </p>\n</p>\n\n## 关于项目\n\n| 桌面应用                                                                                                        | 移动应用                                                                                                    |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) 与 [Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs) 或 [Hacker News](https://news.ycombinator.com/) 等网站类似：你可以订阅你感兴趣的论坛，发布链接和讨论，然后进行投票或评论。但在幕后，Lemmy 和他们不同——任何人都可以很容易地运行一个服务器，所有服务器都是联邦式的（想想电子邮件），并连接到 [联邦宇宙](https://zh.wikipedia.org/wiki/%E8%81%94%E9%82%A6%E5%AE%87%E5%AE%99)。\n\n对于一个链接聚合器来说，这意味着在一个服务器上注册的用户可以订阅任何其他服务器上的论坛，并可以与其他地方注册的用户进行讨论。\n\n它是 Reddit 和其他链接聚合器的一个易于自托管的、分布式的替代方案，不受公司的控制和干涉。\n\n每个 Lemmy 服务器都可以设置自己的管理政策；任命全站管理员和社区版主来阻止引战和钓鱼的用户，并培养一个健康、无毒的环境，让所有人都能放心地作出贡献。\n\n### 为什么叫 Lemmy？\n\n- 来自 [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U) 的主唱。\n- 老式的 [电子游戏](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>)。\n- [超级马里奥中的库巴](https://www.mariowiki.com/Lemmy_Koopa)。\n- [毛茸茸的啮齿动物](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。\n\n### 采用以下项目构建\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n## 特性\n\n- 开源，采用 [AGPL 协议](/LICENSE)。\n- 可自托管，易于部署。\n  - 附带 [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) 或 [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)。\n- 干净、移动设备友好的界面。\n  - 仅需用户名和密码就可以注册!\n  - 支持用户头像。\n  - 实时更新的评论串。\n  - 类似旧版 Reddit 的评分功能 `(+/-)`。\n  - 主题，有深色 / 浅色主题和 Solarized 主题。\n  - Emoji 和自动补全。输入 `:` 开始。\n  - 通过 `@` 提及用户，`!` 提及社区。\n  - 在帖子和评论中都集成了图片上传功能。\n  - 一个帖子可以由一个标题和自我文本的任何组合组成，一个 URL，或没有其他。\n  - 评论回复和提及时的通知。\n    - 通知可通过电子邮件发送。\n    - 支持私信。\n  - i18n（国际化）支持。\n  - `All`、`Subscribed`、`Inbox`、`User` 和 `Community` 的 RSS / Atom 订阅。\n- 支持多重发布。\n  - 在创建新的帖子时，有 _相似帖子_ 的建议，对问答式社区很有帮助。\n- 监管能力。\n  - 公开的修改日志。\n  - 可以把帖子在社区置顶。\n  - 既有网站管理员，也有可以任命其他版主社区版主。\n  - 可以锁定、删除和恢复帖子和评论。\n  - 可以禁止和解禁社区和网站的用户。\n  - 可以将网站和社区转让给其他人。\n- 可以完全删除你的数据，替换所有的帖子和评论。\n- NSFW 帖子 / 社区支持。\n- 高性能。\n  - 服务器采用 Rust 编写。\n  - 前端 gzip 后约 `~80kB`。\n  - 支持 arm64 架构和树莓派。\n\n## 安装\n\n- [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html)\n- [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)\n\n## Lemmy 项目\n\n### 应用\n\n- [lemmy-ui - Lemmy 的官方网页应用](https://github.com/LemmyNet/lemmy-ui)\n- [Lemmur - 一个 Lemmy 的移动客户端（支持安卓、Linux、Windows）](https://github.com/LemmurOrg/lemmur)\n- [Jerboa - 一个由 Lemmy 的开发者打造的原生 Android 应用](https://github.com/dessalines/jerboa)\n- [Remmel - 一个原生 iOS 应用](https://github.com/uuttff8/Lemmy-iOS)\n\n### 库\n\n- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)\n- [Kotlin API (尚在开发)](https://github.com/eiknat/lemmy-client)\n- [Dart API client](https://github.com/LemmurOrg/lemmy_api_client)\n\n## 支持和捐助\n\nLemmy 是免费的开源软件，无广告，无营利，无风险投资。您的捐款直接支持我们全职开发这一项目。\n\n- [在 Liberapay 上支持](https://liberapay.com/Lemmy)。\n- [在 Ko-fi 上支持](https://ko-fi.com/lemmynet).\n- [在 Patreon 上支持](https://www.patreon.com/dessalines)。\n- [在 OpenCollective 上支持](https://opencollective.com/lemmy)。\n- [赞助者列表](https://join-lemmy.org/sponsors)。\n\n### 加密货币\n\n- 比特币：`1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- 以太坊: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- 门罗币：`41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n- 艾达币：`addr1q858t89l2ym6xmrugjs0af9cslfwvnvsh2xxp6x4dcez7pf5tushkp4wl7zxfhm2djp6gq60dk4cmc7seaza5p3slx0sakjutm`\n\n## 贡献\n\n- [贡献指南](https://join-lemmy.org/docs/en/contributing/contributing.html)\n- [Docker 开发](https://join-lemmy.org/docs/en/contributing/docker_development.html)\n- [本地开发](https://join-lemmy.org/docs/en/contributing/local_development.html)\n\n### 翻译\n\n如果你想帮助翻译，请至 [Weblate](https://weblate.yerbamate.ml/projects/lemmy/)；也可以 [翻译文档](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)。\n\n## 联系\n\n- [Mastodon](https://mastodon.social/@LemmyDev)\n- [Lemmy 支持论坛](https://lemmy.ml/c/lemmy_support)\n\n## 代码镜像\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://yerbamate.ml/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## 致谢\n\nLogo 由 Andy Cuccaro (@andycuccaro) 制作，采用 CC-BY-SA 4.0 协议释出。\n"
  },
  {
    "path": "readmes/README.zh.hant.md",
    "content": "<!-- This Chinese variant is generated from ./README.zh.hans.md via OpenCC and then proofread. Regional difference may occur, though. -->\n<div align=\"center\">\n\n![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)\n[![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/)\n[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)\n[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)\n[![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/)\n[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)\n![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)\n[![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)\n\n</div>\n\n<p align=\"center\">\n  <a href=\"../README.md\">English</a> |\n  <a href=\"README.es.md\">Español</a> |\n  <a href=\"README.ru.md\">Русский</a> |\n  <a href=\"README.zh.hans.md\">汉语</a> |\n  <span>漢語</span> |\n  <a href=\"README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://join-lemmy.org/\" rel=\"noopener\">\n <img width=200px height=200px src=\"https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg\"></a>\n\n <h3 align=\"center\"><a href=\"https://join-lemmy.org\">Lemmy</a></h3>\n  <p align=\"center\">\n    一個聯邦宇宙的連結聚合器和論壇。\n    <br />\n    <br />\n    <a href=\"https://join-lemmy.org\">加入 Lemmy</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/index.html\">文檔</a>\n    ·\n    <a href=\"https://matrix.to/#/#lemmy-space:matrix.org\">Matrix 群組</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">報告缺陷</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/issues\">請求新特性</a>\n    ·\n    <a href=\"https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md\">發行版</a>\n    ·\n    <a href=\"https://join-lemmy.org/docs/en/code_of_conduct.html\">行為準則</a>\n  </p>\n</p>\n\n## 關於專案\n\n| 桌面設備                                                                                                        | 行動裝置                                                                                                    |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |\n\n[Lemmy](https://github.com/LemmyNet/lemmy) 與 [Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs) 或 [Hacker News](https://news.ycombinator.com/) 等網站類似：你可以訂閱你感興趣的論壇，釋出連結和討論，然後進行投票或評論。但在幕後，Lemmy 和他們不同——任何人都可以很容易地架設一個伺服器，所有伺服器都是聯邦式的（想想電子郵件），並與 [聯邦宇宙](https://zh.wikipedia.org/wiki/%E8%81%94%E9%82%A6%E5%AE%87%E5%AE%99) 互聯。\n\n對於一個連結聚合器來說，這意味著在一個伺服器上註冊的使用者可以訂閱任何其他伺服器上的論壇，並可以與其他地方註冊的使用者進行討論。\n\n它是 Reddit 和其他連結聚合器的一個易於自託管的、分散式的替代方案，不受公司的控制和干涉。\n\n每個 Lemmy 伺服器都可以設定自己的管理政策；任命全站管理員和社群版主來阻止網路白目，並培養一個健康、無毒的環境，讓所有人都能放心地作出貢獻。\n\n### 為什麼叫 Lemmy？\n\n- 來自 [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U) 的主唱。\n- 老式的 [電子遊戲](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>)。\n- [超級馬里奧中的庫巴](https://www.mariowiki.com/Lemmy_Koopa)。\n- [毛茸茸的齧齒動物](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。\n\n### 採用以下專案構建\n\n- [Rust](https://www.rust-lang.org)\n- [Actix](https://actix.rs/)\n- [Diesel](http://diesel.rs/)\n- [Inferno](https://infernojs.org)\n- [Typescript](https://www.typescriptlang.org/)\n\n## 特性\n\n- 開源，採用 [AGPL 協議](/LICENSE)。\n- 可自託管，易於部署。\n  - 附帶 [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) 或 [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)。\n- 乾淨、移動裝置友好的介面。\n  - 僅需使用者名稱和密碼就可以註冊!\n  - 支援使用者頭像。\n  - 實時更新的評論串。\n  - 類似舊版 Reddit 的評分功能 `(+/-)`。\n  - 主題，有深色 / 淺色主題和 Solarized 主題。\n  - Emoji 和自動補全。輸入 `:` 開始。\n  - 透過 `@` 提及使用者，`!` 提及社群。\n  - 在帖子和評論中都集成了圖片上傳功能。\n  - 一個帖子可以由一個標題和自我文字的任何組合組成，一個 URL，或沒有其他。\n  - 評論回覆和提及時的通知。\n    - 通知可透過電子郵件傳送。\n    - 支援私信。\n  - i18n（國際化）支援。\n  - `All`、`Subscribed`、`Inbox`、`User` 和 `Community` 的 RSS / Atom 訂閱。\n- 支援多重發布。\n  - 在建立新的帖子時，有 _相似帖子_ 的建議，對問答式社群很有幫助。\n- 監管能力。\n  - 公開的修改日誌。\n  - 可以把帖子在社群置頂。\n  - 既有網站管理員，也有可以任命其他版主社群版主。\n  - 可以鎖定、刪除和恢復帖子和評論。\n  - 可以封鎖和解除封鎖社群和網站的使用者。\n  - 可以將網站和社群轉讓給其他人。\n- 可以完全刪除你的資料，替換所有的帖子和評論。\n- NSFW 帖子 / 社群支援。\n- 高效能。\n  - 伺服器採用 Rust 編寫。\n  - 前端 gzip 後約 `~80kB`。\n  - 支援 arm64 架構和樹莓派。\n\n## 安裝\n\n- [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html)\n- [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)\n\n## Lemmy 專案\n\n### 應用\n\n- [lemmy-ui - Lemmy 的官方網頁應用](https://github.com/LemmyNet/lemmy-ui)\n- [Lemmur - 一個 Lemmy 的行動應用程式（支援安卓、Linux、Windows）](https://github.com/LemmurOrg/lemmur)\n- [Jerboa - 一個由 Lemmy 的開發者打造的原生 Android 應用程式](https://github.com/dessalines/jerboa)\n- [Remmel - 一個原生 iOS 應用程式](https://github.com/uuttff8/Lemmy-iOS)\n\n### 庫\n\n- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)\n- [Kotlin API (尚在開發)](https://github.com/eiknat/lemmy-client)\n- [Dart API client](https://github.com/LemmurOrg/lemmy_api_client)\n\n## 支援和捐助\n\nLemmy 是免費的開放原始碼軟體，無廣告，無營利，無風險投資。您的捐款直接支援我們全職開發這一專案。\n\n- [在 Liberapay 上支援](https://liberapay.com/Lemmy)。\n- [在 Ko-fi 上支援](https://ko-fi.com/lemmynet).\n- [在 Patreon 上支援](https://www.patreon.com/dessalines)。\n- [在 OpenCollective 上支援](https://opencollective.com/lemmy)。\n- [贊助者列表](https://join-lemmy.org/sponsors)。\n\n### 加密貨幣\n\n- 比特幣：`1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`\n- 以太坊：`0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`\n- 門羅幣：`41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`\n- 艾達幣：`addr1q858t89l2ym6xmrugjs0af9cslfwvnvsh2xxp6x4dcez7pf5tushkp4wl7zxfhm2djp6gq60dk4cmc7seaza5p3slx0sakjutm`\n\n## 貢獻\n\n- [貢獻指南](https://join-lemmy.org/docs/en/contributing/contributing.html)\n- [Docker 開發](https://join-lemmy.org/docs/en/contributing/docker_development.html)\n- [本地開發](https://join-lemmy.org/docs/en/contributing/local_development.html)\n\n### 翻譯\n\n如果你想幫助翻譯，請至 [Weblate](https://weblate.yerbamate.ml/projects/lemmy/)；也可以 [翻譯文檔](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)。\n\n## 聯絡\n\n- [Mastodon](https://mastodon.social/@LemmyDev)\n- [Lemmy 支援論壇](https://lemmy.ml/c/lemmy_support)\n\n## 程式碼鏡像\n\n- [GitHub](https://github.com/LemmyNet/lemmy)\n- [Gitea](https://yerbamate.ml/LemmyNet/lemmy)\n- [Codeberg](https://codeberg.org/LemmyNet/lemmy)\n\n## 致謝\n\nLogo 由 Andy Cuccaro (@andycuccaro) 製作，採用 CC-BY-SA 4.0 協議釋出。\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.94\"\n"
  },
  {
    "path": "scripts/alpine_install_pg_formatter.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nversion=5.9\nwget https://github.com/darold/pgFormatter/archive/refs/tags/v${version}.tar.gz -q\ntar xzf v${version}.tar.gz\ncd pgFormatter-${version}/\nperl Makefile.PL\nmake && make install\ncd ../ && rm -rf v${version}.tar.gz && rm -rf pgFormatter-${version} #clean up\n"
  },
  {
    "path": "scripts/clean-workspace.sh",
    "content": "#!/bin/bash\nset -e\n\n# Run `cargo clean -p` for each workspace member. This allows to accurately measure the time for\n# an incremental build.\nclear && cargo metadata --no-deps | jq .packages.[].name | sed 's/.*/-p &/' | xargs cargo clean\n"
  },
  {
    "path": "scripts/clear_db.sh",
    "content": "#!/usr/bin/env bash\n\npsql -U lemmy -c \"DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA utils CASCADE;\"\n"
  },
  {
    "path": "scripts/compilation_benchmark.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\ntimes=3\nduration=0\nfor ((i = 0; i < times; i++)); do\n  echo \"Starting iteration $i\"\n  echo \"cargo clean\"\n  # to benchmark incremental compilation time, do a full build with the same compiler version first,\n  # and use the following clean command:\n  cargo clean -p lemmy_utils\n  #cargo clean\n  echo \"cargo build\"\n  start=$(date +%s.%N)\n  RUSTC_WRAPPER='' cargo build -q\n  end=$(date +%s.%N)\n  echo \"Finished iteration $i after $(bc <<<\"scale=0; $end - $start\") seconds\"\n  duration=$(bc <<<\"$duration + $end - $start\")\ndone\n\naverage=$(bc <<<\"scale=0; $duration / $times\")\n\necho \"Average compilation time over $times runs is $average seconds\"\n"
  },
  {
    "path": "scripts/db-init.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# Default configurations\nusername=lemmy\npassword=password\ndbname=lemmy\nport=5432\n\nyes_no_prompt_invalid() {\n  echo \"Invalid input. Please enter either \\\"y\\\" or \\\"n\\\".\" 1>&2\n}\n\nprint_config() {\n  echo \"  database name: $dbname\"\n  echo \"  username: $username\"\n  echo \"  password: $password\"\n  echo \"  port: $port\"\n}\n\nask_for_db_config() {\n  echo \"The default database configuration is:\"\n  print_config\n  echo\n\n  default_config_final=0\n  default_config_valid=0\n  while [ \"$default_config_valid\" == 0 ]; do\n    read -p \"Use this configuration (y/n)? \" default_config\n    case \"$default_config\" in\n    [yY]*)\n      default_config_valid=1\n      default_config_final=1\n      ;;\n    [nN]*)\n      default_config_valid=1\n      default_config_final=0\n      ;;\n    *) yes_no_prompt_invalid ;;\n    esac\n    echo\n  done\n\n  if [ \"$default_config_final\" == 0 ]; then\n    config_ok_final=0\n    while [ \"$config_ok_final\" == 0 ]; do\n      read -p \"Database name:  \" dbname\n      read -p \"Username:  \" username\n      read -p \"Password: password\"\n      read -p \"Port:  \" port\n      #echo\n\n      #echo \"The database configuration is:\"\n      #print_config\n      #echo\n\n      config_ok_valid=0\n      while [ \"$config_ok_valid\" == 0 ]; do\n        read -p \"Use this configuration (y/n)? \" config_ok\n        case \"$config_ok\" in\n        [yY]*)\n          config_ok_valid=1\n          config_ok_final=1\n          ;;\n        [nN]*)\n          config_ok_valid=1\n          config_ok_final=0\n          ;;\n        *) yes_no_prompt_invalid ;;\n        esac\n        echo\n      done\n    done\n  fi\n}\n\nask_for_db_config\n\npsql -c \"CREATE USER $username WITH PASSWORD '$password' SUPERUSER;\" -U postgres\npsql -c \"CREATE DATABASE $dbname WITH OWNER $username;\" -U postgres\nexport LEMMY_DATABASE_URL=postgres://$username:$password@localhost:$port/$dbname\n\necho \"The database URL is $LEMMY_DATABASE_URL\"\n"
  },
  {
    "path": "scripts/db_perf.sh",
    "content": "#!/usr/bin/env bash\n\n# This script runs crates/db_views/post/src/db_perf/mod.rs, which lets you see info related to database query performance, such as query plans.\n\nset -e\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\nsource scripts/start_dev_db.sh\n\nexport LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson\nexport RUST_BACKTRACE=1\n\ncargo test -p lemmy_db_views_post --features full --no-fail-fast db_perf -- --nocapture\n\npg_ctl stop --silent\n\n# $PGDATA directory is kept so log can be seen\n"
  },
  {
    "path": "scripts/dump_schema.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# Dumps database schema, not including things that are added outside of migrations\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\nsource scripts/start_dev_db.sh\n\ncargo run --package lemmy_diesel_utils\npg_dump --no-owner --no-privileges --no-table-access-method --schema-only --exclude-schema=r --no-sync -f schema.sqldump\n\npg_ctl stop\nrm -rf $PGDATA\n"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# Set the database variable to the default first.\n# Don't forget to change this string to your actual database parameters\n# if you don't plan to initialize the database in this script.\nexport LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy\n\n# Set other environment variables\nexport JWT_SECRET=changeme\nexport HOSTNAME=rrr\n\nyes_no_prompt_invalid() {\n  echo \"Invalid input. Please enter either \\\"y\\\" or \\\"n\\\".\" 1>&2\n}\n\nask_to_init_db() {\n  init_db_valid=0\n  init_db_final=0\n  while [ \"$init_db_valid\" == 0 ]; do\n    read -p \"Initialize database (y/n)? \" init_db\n    case \"$init_db\" in\n    [yY]*)\n      init_db_valid=1\n      init_db_final=1\n      ;;\n    [nN]*)\n      init_db_valid=1\n      init_db_final=0\n      ;;\n    *) yes_no_prompt_invalid ;;\n    esac\n    echo\n  done\n  if [ \"$init_db_final\" = 1 ]; then\n    source ./db-init.sh\n    read -n 1 -s -r -p \"Press ANY KEY to continue execution of this script, press CTRL+C to quit...\"\n    echo\n  fi\n}\n\nask_to_auto_reload() {\n  auto_reload_valid=0\n  auto_reload_final=0\n  while [ \"$auto_reload_valid\" == 0 ]; do\n    echo \"Automagically reload the project when source files are changed?\"\n    echo \"ONLY ENABLE THIS FOR DEVELOPMENT!\"\n    read -p \"(y/n) \" auto_reload\n    case \"$auto_reload\" in\n    [yY]*)\n      auto_reload_valid=1\n      auto_reload_final=1\n      ;;\n    [nN]*)\n      auto_reload_valid=1\n      auto_reload_final=0\n      ;;\n    *) yes_no_prompt_invalid ;;\n    esac\n    echo\n  done\n  if [ \"$auto_reload_final\" = 1 ]; then\n    cd ui && pnpm dev\n    cd server && cargo watch -x run\n  fi\n}\n\n# Optionally initialize the database\nask_to_init_db\n\n# Build the web client\ncd ui\npnpm i\npnpm prebuild:prod\npnpm build:prod\n\n# Build and run the backend\ncd ../server\nRUST_LOG=debug cargo run\n\n# For live coding, where both the front and back end, automagically reload on any save\nask_to_auto_reload\n"
  },
  {
    "path": "scripts/lint.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\n# Format rust files\ncargo +nightly fmt\n\n# Format toml files\ntaplo format\n\n# Format sql files\nfind migrations crates/diesel_utils/replaceable_schema -type f -name '*.sql' -print0 | xargs -0 -P 10 -L 10 pg_format -i\n\ncargo clippy --workspace --fix --allow-staged --allow-dirty --tests --all-targets --all-features -- -D warnings\n"
  },
  {
    "path": "scripts/postgres_12_to_15_upgrade.sh",
    "content": "#!/bin/sh\nset -e\n\necho \"Updating docker-compose to use postgres version 16.\"\nsudo sed -i \"s/image: .*postgres:.*/image: pgautoupgrade\\/pgautoupgrade:16-alpine/\" ./docker-compose.yml\n\necho \"Starting up lemmy...\"\nsudo docker-compose up -d\n"
  },
  {
    "path": "scripts/postgres_15_to_16_upgrade.sh",
    "content": "#!/bin/sh\nset -e\n\necho \"Updating docker-compose to use postgres version 16.\"\nsudo sed -i \"s/image: .*postgres:.*/image: pgautoupgrade\\/pgautoupgrade:16-alpine/\" ./docker-compose.yml\n\necho \"Starting up lemmy...\"\nsudo docker-compose up -d\n"
  },
  {
    "path": "scripts/query_testing/apache_bench_report.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\ndeclare -a arr=(\n  \"https://mastodon.social/\"\n  \"https://peertube.social/\"\n  \"https://lemmy.ml/\"\n  \"https://lemmy.ml/feeds/all.xml\"\n  \"https://lemmy.ml/.well-known/nodeinfo\"\n  \"https://fediverse.blog/.well-known/nodeinfo\"\n  \"https://torrents-csv.ml/service/search?q=wheel&page=1&type_=torrent\"\n)\n\n## check if ab installed\nif ! [ -x \"$(command -v ab)\" ]; then\n  echo 'Error: ab (Apache Bench) is not installed. https://httpd.apache.org/docs/2.4/programs/ab.html' >&2\n  exit 1\nfi\n\n## now loop through the above array\nfor i in \"${arr[@]}\"; do\n  ab -c 10 -t 10 \"$i\" >out.abtest\n  grep \"Server Hostname:\" out.abtest\n  grep \"Document Path:\" out.abtest\n  grep \"Requests per second\" out.abtest\n  grep \"(mean, across all concurrent requests)\" out.abtest\n  grep \"Transfer rate:\" out.abtest\n  echo \"---\"\ndone\n\nrm *.abtest\n"
  },
  {
    "path": "scripts/query_testing/api_benchmark.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# By default, this script runs against `http://127.0.0.1:8536`, but you can pass a different Lemmy instance,\n# eg `./api_benchmark.sh \"https://example.com\"`.\nDOMAIN=${1:-\"http://127.0.0.1:8536\"}\n\ndeclare -a arr=(\n  \"/api/v1/site\"\n  \"/api/v1/categories\"\n  \"/api/v1/modlog\"\n  \"/api/v1/search?q=test&type_=Posts&sort=Hot\"\n  \"/api/v1/community\"\n  \"/api/v1/community/list?sort=Hot\"\n  \"/api/v1/post/list?sort=Hot&type_=All\"\n)\n\n## check if ab installed\nif ! [ -x \"$(command -v ab)\" ]; then\n  echo 'Error: ab (Apache Bench) is not installed. https://httpd.apache.org/docs/2.4/programs/ab.html' >&2\n  exit 1\nfi\n\n## now loop through the above array\nfor path in \"${arr[@]}\"; do\n  URL=\"$DOMAIN$path\"\n  printf \"\\n\\n\\n\"\n  echo \"testing $URL\"\n  curl --show-error --fail --silent \"$URL\" >/dev/null\n  ab -c 64 -t 10 \"$URL\" >out.abtest\n  grep \"Server Hostname:\" out.abtest\n  grep \"Document Path:\" out.abtest\n  grep \"Requests per second\" out.abtest\n  grep \"(mean, across all concurrent requests)\" out.abtest\n  grep \"Transfer rate:\" out.abtest\n  echo \"---\"\ndone\n\nrm *.abtest\n"
  },
  {
    "path": "scripts/query_testing/bulk_upsert_timings.md",
    "content": "# post_read -> post_actions bulk upsert timings\n\n## normal, 1 month: 491s\n\nInsert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=169235.026..169235.026 rows=0 loops=1) Conflict Resolution: UPDATE Conflict Arbiter Indexes: post_actions_pkey Tuples Inserted: 5175253 Conflicting Tuples: 0 -> Index Scan using idx_post_read_published_desc on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=47.762..39310.551 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.234 ms Trigger for constraint post_actions_person_id_fkey: time=118828.666 calls=5175253 Trigger for constraint post_actions_post_id_fkey: time=203098.355 calls=5175253 JIT: Functions: 6 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.448 ms, Inlining 0.000 ms, Optimization 0.201 ms, Emission 44.721 ms, Total 45.369 ms Execution Time: 491991.365 ms (15 rows)\n\n## disabled triggers, keep pkey, on conflict: 167s\n\nInsert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=167261.176..167261.176 rows=0 loops=1) Conflict Resolution: UPDATE Conflict Arbiter Indexes: post_actions_pkey Tuples Inserted: 5175253 Conflicting Tuples: 0 -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=5.604..59193.030 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.147 ms JIT: Functions: 6 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.490 ms, Inlining 0.000 ms, Optimization 0.197 ms, Emission 3.989 ms, Total 4.675 ms Execution Time: 167261.807 ms\n\n## disabled triggers, with pkey, insert only: 91s\n\nInsert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=91820.768..91820.769 rows=0 loops=1) -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=5.482..40066.185 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.098 ms JIT: Functions: 5 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.490 ms, Inlining 0.000 ms, Optimization 0.208 ms, Emission 3.894 ms, Total 4.592 ms Execution Time: 91821.724 ms\n\n## disabled triggers, no pkey, insert only: 57s\n\nInsert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=56797.431..56797.432 rows=0 loops=1) -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=4.827..27903.829 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.096 ms JIT: Functions: 5 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.390 ms, Inlining 0.000 ms, Optimization 0.232 ms, Emission 3.373 ms, Total 3.994 ms Execution Time: 56798.022 ms\n\n## disabled triggers, merge instead of upsert: 77s\n\nMerge on post_actions pa (cost=34.06..280379.97 rows=0 width=0) (actual time=76988.823..76988.825 rows=0 loops=1) Tuples: inserted=5175253 -> Hash Left Join (cost=34.06..280379.97 rows=1098137 width=28) (actual time=8.109..12202.884 rows=5175253 loops=1) Hash Cond: ((post_read.person_id = pa.person_id) AND (post_read.post_id = pa.post_id)) -> Index Scan using idx_tmp_1 on post_read (cost=0.56..274581.25 rows=1098137 width=22) (actual time=8.094..11432.132 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) -> Hash (cost=19.40..19.40 rows=940 width=14) (actual time=0.003..0.004 rows=0 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 8kB -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.003..0.003 rows=0 loops=1) Planning Time: 0.468 ms JIT: Functions: 17 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.897 ms, Inlining 0.000 ms, Optimization 0.399 ms, Emission 7.650 ms, Total 8.946 ms Execution Time: 76989.946 ms\n\n## disabled triggers, merge, no pkey: 39s\n\nMerge on post_actions pa (cost=297488.30..303957.64 rows=0 width=0) (actual time=39009.474..39009.477 rows=0 loops=1) Tuples: inserted=5175253 -> Hash Right Join (cost=297488.30..303957.64 rows=1098137 width=28) (actual time=3412.832..5353.677 rows=5175253 loops=1) Hash Cond: ((pa.person_id = post_read.person_id) AND (pa.post_id = post_read.post_id)) -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.004..0.005 rows=0 loops=1) -> Hash (cost=274581.25..274581.25 rows=1098137 width=22) (actual time=3412.178..3412.180 rows=5175253 loops=1) Buckets: 131072 (originally 131072) Batches: 64 (originally 16) Memory Usage: 7169kB -> Index Scan using idx_tmp_1 on post_read (cost=0.56..274581.25 rows=1098137 width=22) (actual time=8.495..2299.278 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.465 ms JIT: Functions: 17 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.988 ms, Inlining 0.000 ms, Optimization 0.350 ms, Emission 8.127 ms, Total 9.465 ms Execution Time: 39011.515 ms\n\n## same as above, full table: 425s\n\nMerge on post_actions pa (cost=1478580.50..1520165.83 rows=0 width=0) (actual time=425751.243..425751.245 rows=0 loops=1) Tuples: inserted=33519660 -> Hash Right Join (cost=1478580.50..1520165.83 rows=7091220 width=28) (actual time=72968.237..120866.662 rows=33519660 loops=1) Hash Cond: ((pa.person_id = pr.person_id) AND (pa.post_id = pr.post_id)) -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.004..0.004 rows=0 loops=1) -> Hash (cost=1330661.20..1330661.20 rows=7091220 width=22) (actual time=72967.590..72967.591 rows=33519660 loops=1) Buckets: 131072 (originally 131072) Batches: 256 (originally 64) Memory Usage: 7927kB -> Seq Scan on post_read pr (cost=0.00..1330661.20 rows=7091220 width=22) (actual time=103.545..51892.728 rows=33519660 loops=1) Planning Time: 0.393 ms JIT: Functions: 14 Options: Inlining true, Optimization true, Expressions true, Deforming true Timing: Generation 0.840 ms, Inlining 11.303 ms, Optimization 45.211 ms, Emission 40.003 ms, Total 97.357 ms Execution Time: 425753.438 ms\n\n## disabled triggers, merge, with pkey, full table: 587s\n\nMerge on post_actions pa (cost=19.47..1367909.58 rows=0 width=0) (actual time=587295.757..587295.759 rows=0 loops=1) Tuples: inserted=33519660 -> Hash Left Join (cost=19.47..1367909.58 rows=7091220 width=28) (actual time=77.291..46496.679 rows=33519660 loops=1) Hash Cond: ((pr.person_id = pa.person_id) AND (pr.post_id = pa.post_id)) -> Seq Scan on post_read pr (cost=0.00..1330661.20 rows=7091220 width=22) (actual time=77.266..41178.528 rows=33519660 loops=1) -> Hash (cost=19.40..19.40 rows=5 width=14) (actual time=0.006..0.007 rows=0 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 8kB -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=5 width=14) (actual time=0.006..0.006 rows=0 loops=1) Filter: (read IS NULL) Planning Time: 0.428 ms JIT: Functions: 16 Options: Inlining true, Optimization true, Expressions true, Deforming true Timing: Generation 0.922 ms, Inlining 6.324 ms, Optimization 37.862 ms, Emission 33.076 ms, Total 78.183 ms Execution Time: 587297.207 ms (15 rows)\n\n## disabled triggers, merge, no pkey, full table: 359s\n\n## disabled triggers, merge, no pkey, person_post_aggs after post_read: 1260s\n\n## disabled triggers, no pkey, post_read + person_post_aggs union all with group by insert (no upsert or merge): 402s\n\n### Merge example:\n\n```sql\nEXPLAIN ANALYZE MERGE INTO post_actions pa\nUSING post_read pr ON (pa.person_id = pr.person_id\n    AND pa.post_id = pr.post_id\n)\nWHEN MATCHED THEN\n    UPDATE SET\n        read = pr.published\nWHEN NOT MATCHED THEN\n    INSERT (person_id, post_id, read)\n        VALUES (pr.person_id, pr.post_id, pr.published);\n```\n\n## comment aggregate bulk update: 3881s / 65m\n"
  },
  {
    "path": "scripts/query_testing/post_query_hot_rank.sh",
    "content": "#!/bin/bash\n\nsudo docker exec -i docker-postgres-1 psql -Ulemmy -c \"EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) SELECT post.id, post.name, post.url, post.body, post.creator_id, post.community_id, post.removed, post.locked, post.published, post.updated, post.deleted, post.nsfw, post.embed_title, post.embed_description, post.embed_video_url, post.thumbnail_url, post.ap_id, post.local, post.language_id, post.featured_community, post.featured_local, person.id, person.name, person.display_name, person.avatar, person.banned, person.published, person.updated, person.actor_id, person.bio, person.local, person.banner, person.deleted, person.inbox_url, person.shared_inbox_url, person.matrix_user_id, person.admin, person.bot_account, person.ban_expires, person.instance_id, community.id, community.name, community.title, community.description, community.removed, community.published, community.updated, community.deleted, community.nsfw, community.actor_id, community.local, community.icon, community.banner, community.hidden, community.posting_restricted_to_mods, community.instance_id, community_person_ban.id, community_person_ban.community_id, community_person_ban.person_id, community_person_ban.published, community_person_ban.expires, post_aggregates.id, post_aggregates.post_id, post_aggregates.comments, post_aggregates.score, post_aggregates.upvotes, post_aggregates.downvotes, post_aggregates.published, post_aggregates.newest_comment_time_necro, post_aggregates.newest_comment_time, post_aggregates.featured_community, post_aggregates.featured_local, community_follower.id, community_follower.community_id, community_follower.person_id, community_follower.published, community_follower.pending, post_saved.id, post_saved.post_id, post_saved.person_id, post_saved.published, post_read.id, post_read.post_id, post_read.person_id, post_read.published, person_block.id, person_block.person_id, person_block.target_id, person_block.published, post_like.score, coalesce((post_aggregates.comments - person_post_aggregates.read_comments), post_aggregates.comments) FROM ((((((((((((post INNER JOIN person ON (post.creator_id = person.id)) INNER JOIN community ON (post.community_id = community.id)) LEFT OUTER JOIN community_person_ban ON (((post.community_id = community_person_ban.community_id) AND (community_person_ban.person_id = post.creator_id)) AND ((community_person_ban.expires IS NULL) OR (community_person_ban.expires > CURRENT_TIMESTAMP)))) INNER JOIN post_aggregates ON (post_aggregates.post_id = post.id)) LEFT OUTER JOIN community_follower ON ((post.community_id = community_follower.community_id) AND (community_follower.person_id = '33517'))) LEFT OUTER JOIN post_saved ON ((post.id = post_saved.post_id) AND (post_saved.person_id = '33517'))) LEFT OUTER JOIN post_read ON ((post.id = post_read.post_id) AND (post_read.person_id = '33517'))) LEFT OUTER JOIN person_block ON ((post.creator_id = person_block.target_id) AND (person_block.person_id = '33517'))) LEFT OUTER JOIN community_block ON ((community.id = community_block.community_id) AND (community_block.person_id = '33517'))) LEFT OUTER JOIN post_like ON ((post.id = post_like.post_id) AND (post_like.person_id = '33517'))) LEFT OUTER JOIN person_post_aggregates ON ((post.id = person_post_aggregates.post_id) AND (person_post_aggregates.person_id = '33517'))) LEFT OUTER JOIN local_user_language ON ((post.language_id = local_user_language.language_id) AND (local_user_language.local_user_id = '11402'))) WHERE ((((((((((community_follower.person_id IS NOT NULL) AND (post.nsfw = 'f')) AND (community.nsfw = 'f')) AND (local_user_language.language_id IS NOT NULL)) AND (community_block.person_id IS NULL)) AND (person_block.person_id IS NULL)) AND (post.removed = 'f')) AND (post.deleted = 'f')) AND (community.removed = 'f')) AND (community.deleted = 'f')) ORDER BY post_aggregates.featured_local DESC , post_aggregates.hot_rank DESC LIMIT '40' OFFSET '0';\" >query_results.json\n"
  },
  {
    "path": "scripts/query_testing/views_old/generate_reports.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# You can import these to http://tatiyants.com/pev/#/plans/new\n\npushd reports\n\n# Do the views first\n\nPSQL_CMD=\"docker exec -i dev_postgres_1 psql -qAt -U lemmy\"\n\necho \"explain (analyze, format json) select * from user_fast limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >user_fast.json\n\necho \"explain (analyze, format json) select * from post_view where user_id is null order by hot_rank desc, published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_view.json\n\necho \"explain (analyze, format json) select * from post_fast_view where user_id is null order by hot_rank desc, published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_fast_view.json\n\necho \"explain (analyze, format json) select * from comment_view where user_id is null limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >comment_view.json\n\necho \"explain (analyze, format json) select * from comment_fast_view where user_id is null limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >comment_fast_view.json\n\necho \"explain (analyze, format json) select * from community_view where user_id is null order by hot_rank desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >community_view.json\n\necho \"explain (analyze, format json) select * from community_fast_view where user_id is null order by hot_rank desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >community_fast_view.json\n\necho \"explain (analyze, format json) select * from site_view limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >site_view.json\n\necho \"explain (analyze, format json) select * from reply_fast_view where user_id = 34 and recipient_id = 34 limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >reply_fast_view.json\n\necho \"explain (analyze, format json) select * from user_mention_view where user_id = 34 and recipient_id = 34 limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >user_mention_view.json\n\necho \"explain (analyze, format json) select * from user_mention_fast_view where user_id = 34 and recipient_id = 34 limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >user_mention_fast_view.json\n\ngrep \"Execution Time\" *.json >../timings-$(date +%Y-%m-%d_%H-%M-%S).out\n\nrm explain.sql\n\npopd\n"
  },
  {
    "path": "scripts/query_testing/views_old/timings-2021-01-05_21-06-37.out",
    "content": "comment_fast_view.json:    \"Execution Time\": 93.165\ncomment_view.json:    \"Execution Time\": 4513.485\ncommunity_fast_view.json:    \"Execution Time\": 3.998\ncommunity_view.json:    \"Execution Time\": 561.814\npost_fast_view.json:    \"Execution Time\": 1604.543\npost_view.json:    \"Execution Time\": 11630.471\nreply_fast_view.json:    \"Execution Time\": 85.708\nsite_view.json:    \"Execution Time\": 27.264\nuser_fast.json:    \"Execution Time\": 0.135\nuser_mention_fast_view.json:    \"Execution Time\": 6.665\nuser_mention_view.json:    \"Execution Time\": 4996.688\n"
  },
  {
    "path": "scripts/query_testing/views_to_diesel_migration/generate_reports.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# You can import these to http://tatiyants.com/pev/#/plans/new\n\npushd reports\n\nPSQL_CMD=\"docker exec -i dev_postgres_1 psql -qAt -U lemmy\"\n\necho \"explain (analyze, format json) select * from user_ limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >user_.json\n\necho \"explain (analyze, format json) select * from post p limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by hot_rank(pa.score, pa.published) desc, pa.published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_rank.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, hot_rank(pa.score, pa.published) desc, pa.published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_rank.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.score desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_score.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, pa.score desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_score.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_published.json\n\necho \"explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, pa.published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_published.json\n\necho \"explain (analyze, format json) select * from comment limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >comment.json\n\necho \"explain (analyze, format json) select * from community limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >community.json\n\necho \"explain (analyze, format json) select * from community c, community_aggregates ca where c.id = ca.community_id order by hot_rank(ca.subscribers, ca.published) desc, ca.published desc limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >community_ordered_by_subscribers.json\n\necho \"explain (analyze, format json) select * from site s\" >explain.sql\ncat explain.sql | $PSQL_CMD >site.json\n\necho \"explain (analyze, format json) select * from user_mention limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >user_mention.json\n\necho \"explain (analyze, format json) select * from private_message limit 100\" >explain.sql\ncat explain.sql | $PSQL_CMD >private_message.json\n\ngrep \"Execution Time\" *.json >../timings-$(date +%Y-%m-%d_%H-%M-%S).out\n\nrm explain.sql\n\npopd\n"
  },
  {
    "path": "scripts/query_testing/views_to_diesel_migration/timings-2021-01-05_21-32-54.out",
    "content": "comment.json:    \"Execution Time\": 0.136\ncommunity.json:    \"Execution Time\": 0.157\ncommunity_ordered_by_subscribers.json:    \"Execution Time\": 16.036\npost.json:    \"Execution Time\": 0.129\npost_ordered_by_rank.json:    \"Execution Time\": 15.969\nprivate_message.json:    \"Execution Time\": 0.133\nsite.json:    \"Execution Time\": 0.056\nuser_.json:    \"Execution Time\": 0.300\nuser_mention.json:    \"Execution Time\": 0.122\n"
  },
  {
    "path": "scripts/release.bash",
    "content": "#!/bin/sh\nset -e\n#git checkout main\n\n# Creating the new tag\nnew_tag=\"$1\"\nthird_semver=$(echo $new_tag | cut -d \".\" -f 3)\n\n# Goto the upper route\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\ncd \"$CWD/../\"\n\n# The docker installs should only update for non release-candidates\n# IE, when the third semver is a number, not '2-rc'\nif [ ! -z \"${third_semver##*[!0-9]*}\" ]; then\n  pushd docker\n  sed -i \"s/dessalines\\/lemmy:.*/dessalines\\/lemmy:$new_tag/\" docker-compose.yml\n  sed -i \"s/dessalines\\/lemmy-ui:.*/dessalines\\/lemmy-ui:$new_tag/\" docker-compose.yml\n  sed -i \"s/dessalines\\/lemmy-ui:.*/dessalines\\/lemmy-ui:$new_tag/\" federation/docker-compose.yml\n  git add docker-compose.yml\n  git add federation/docker-compose.yml\n  popd\nfi\n\n# Update crate versions\nold_tag=$(grep version Cargo.toml | head -1 | cut -d'\"' -f 2)\nsed -i \"s/{ version = \\\"=$old_tag\\\", path/{ version = \\\"=$new_tag\\\", path/g\" Cargo.toml\nsed -i \"s/version = \\\"$old_tag\\\"/version = \\\"$new_tag\\\"/g\" Cargo.toml\n\n# Update the submodules\ngit submodule update --remote\n\n# Run check to ensure translations are valid and lockfile is updated\ncargo check\n\n# The commit\ngit add Cargo.toml Cargo.lock crates/email/translations\ngit commit -m\"Version $new_tag\"\ngit tag $new_tag\n\n# export COMPOSE_DOCKER_CLI_BUILD=1\n# export DOCKER_BUILDKIT=1\n\n# Push\ngit push origin $new_tag\ngit push\n\n# Pushing to any ansible deploys\n# cd ../../../lemmy-ansible || exit\n# ansible-playbook -i prod playbooks/site.yml --vault-password-file vault_pass\n"
  },
  {
    "path": "scripts/restore_db.sh",
    "content": "#!/usr/bin/env bash\n\npsql -U lemmy -c \"DROP SCHEMA public CASCADE; CREATE SCHEMA public;\"\ncat docker/lemmy_dump_2021-01-29_16_13_40.sqldump | psql -U lemmy\npsql -U lemmy -c \"alter user lemmy with password 'password'\"\n"
  },
  {
    "path": "scripts/sql_format_check.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# This check is only used for CI.\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\n# Copy the files to a temp dir\nTMP_DIR=$(mktemp -d)\ncp -a migrations/. $TMP_DIR/migrations\ncp -a crates/diesel_utils/replaceable_schema/. $TMP_DIR/replaceable_schema\n\n# Format the new files\nfind $TMP_DIR -type f -name '*.sql' -print0 | xargs -0 -P 10 -L 10 pg_format -i\n\n# Diff the directories\ndiff -r migrations $TMP_DIR/migrations\ndiff -r crates/diesel_utils/replaceable_schema $TMP_DIR/replaceable_schema\n"
  },
  {
    "path": "scripts/start_dev_db.sh",
    "content": "# This script is meant to be run with `source` so it can set environment variables.\n\nexport PGDATA=\"$PWD/target/dev_pgdata\"\nexport PGHOST=\"$PWD/target\"\n\n# Necessary to encode the dev db path into proper URL params\nexport ENCODED_HOST=$(printf $PGHOST | jq -sRr @uri)\n\nexport PGUSER=postgres\nexport DATABASE_URL=\"postgresql://lemmy:password@$ENCODED_HOST/lemmy\"\nexport LEMMY_DATABASE_URL=$DATABASE_URL\nexport PGDATABASE=lemmy\n\n# If cluster exists, stop the server and delete the cluster\nif [[ -d $PGDATA ]]; then\n  # Only stop server if it is running\n  pg_status_exit_code=0\n  (pg_ctl status >/dev/null) || pg_status_exit_code=$?\n  if [[ ${pg_status_exit_code} -ne 3 ]]; then\n    pg_ctl stop --silent\n  fi\n\n  rm -rf $PGDATA\nfi\n\nconfig_args=(\n  # Only listen to socket in current directory\n  -c listen_addresses=\n  -c unix_socket_directories=$PGHOST\n\n  # Write logs to a file in $PGDATA/log\n  -c logging_collector=on\n\n  # Allow auto_explain to be turned on\n  -c session_preload_libraries=auto_explain\n\n  # Include actual row amounts and run times for query plan nodes\n  -c auto_explain.log_analyze=on\n\n  # Don't log parameter values\n  -c auto_explain.log_parameter_max_length=0\n\n  # Disable fsync, a feature that prevents corruption on crash (doesn't matter on a temporary test database) and slows things down, especially migration tests\n  -c fsync=off\n)\n\n# Create cluster\npg_ctl init --silent --options=\"--username=postgres --auth=trust --no-instructions --no-sync\"\n\n# Start server\npg_ctl start --silent --options=\"${config_args[*]}\"\n\n# Setup database\nPGDATABASE=postgres psql --quiet -c \"CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;\"\nPGDATABASE=postgres psql --quiet -c \"CREATE DATABASE lemmy WITH OWNER lemmy;\"\n"
  },
  {
    "path": "scripts/test-with-coverage.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\nPACKAGE=\"$1\"\necho \"$PACKAGE\"\n\nsource scripts/start_dev_db.sh\n\n# tests are executed in working directory crates/api (or similar),\n# so to load the config we need to traverse to the repo root\nexport LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson\nexport RUST_BACKTRACE=1\n\ncargo install cargo-llvm-cov\n\n# Create lcov.info file, which is used by things like the Coverage Gutters extension for VS Code\ncargo llvm-cov --workspace --all-features --no-fail-fast --lcov --output-path target/lcov.info\n\n# Add this to do printlns: -- --nocapture\n\npg_ctl stop --silent\nrm -rf $PGDATA\n"
  },
  {
    "path": "scripts/test.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\nPACKAGE=\"$1\"\nTEST=\"$2\"\n\nsource scripts/start_dev_db.sh\n\n# tests are executed in working directory crates/api (or similar),\n# so to load the config we need to traverse to the repo root\nexport LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson\nexport RUST_BACKTRACE=1\nexport LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min\n\nif [ -n \"$PACKAGE\" ]; then\n  cargo test -p $PACKAGE --features full $TEST\nelse\n  cargo test --workspace --features full\nfi\n\n# Add this to do printlns: -- --nocapture\n\npg_ctl stop --silent\nrm -rf $PGDATA\n"
  },
  {
    "path": "scripts/update_config_defaults.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\ndest=${1-config/defaults.hjson}\n\ncargo run --manifest-path crates/utils/Cargo.toml --features full >\"$dest\"\n"
  },
  {
    "path": "scripts/update_schema_file.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nCWD=\"$(cd -P -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)\"\n\ncd \"$CWD/../\"\n\nsource scripts/start_dev_db.sh\n\ncargo run --package lemmy_diesel_utils --features full\ndiesel print-schema >crates/db_schema_file/src/schema.rs\ncargo +nightly fmt --package lemmy_db_schema_file\n\npg_ctl stop\nrm -rf $PGDATA\n"
  },
  {
    "path": "scripts/update_translations.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\npushd ../../lemmy-translations\ngit fetch weblate\ngit merge weblate/main\ngit push\npopd\n\ngit submodule update --remote\ngit add ../crates/utils/translations\ngit commit -m\"Updating translations.\"\ngit push\n"
  },
  {
    "path": "scripts/upgrade_deps.sh",
    "content": "#!/usr/bin/env bash\n\npushd ../\n\n# Check unused deps\ncargo udeps --all-targets\n\n# Update deps first\ncargo update\n\n# Upgrade deps\ncargo upgrade\n\n# Run clippy\ncargo clippy\n\npopd\n"
  }
]