diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 906b1167..35468b11 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,6 +7,12 @@ on: branches: - main +# env settings for github releases +# docker image push +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: dependencies: runs-on: ubuntu-latest @@ -162,44 +168,83 @@ jobs: AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - release: - needs: [test, static_code_analysis] - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3.6.0 - - name: Semantic Release - uses: cycjimmy/semantic-release-action@v4.0.0 - with: - branches: | - [ - 'main', - { - name: 'prerelease', - prerelease: true - }, - ] - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - elixir_docs: - if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/elixir-docs' }} - name: Generate project documentation + # release: + # needs: [test, static_code_analysis] + # name: Release + # runs-on: ubuntu-latest + # steps: + # - name: Checkout + # uses: actions/checkout@v3.6.0 + # - name: Semantic Release + # uses: cycjimmy/semantic-release-action@v4.0.0 + # with: + # branches: | + # [ + # 'main', + # { + # name: 'prerelease', + # prerelease: true + # }, + # ] + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # elixir_docs: + # if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/elixir-docs' }} + # name: Generate project documentation + # runs-on: ubuntu-latest + # steps: + # - name: Checkout + # uses: actions/checkout@v3.6.0 + # - name: Sets up an Erlang/OTP environment + # uses: erlef/setup-beam@v1 + # with: + # version-file: .tool-versions + # version-type: strict + # - name: Build docs + # uses: lee-dohm/generate-elixir-docs@v1.0.1 + # - name: Publish to Pages + # uses: peaceiris/actions-gh-pages@v3.9.3 + # with: + # deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} + # external_repository: epochtalk/server.epochtalk.github.io + # publish_dir: ./doc + # publish_branch: gh-pages + + # build and push image to github container registry + build-and-push-image: + needs: [static_code_analysis, test] runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write steps: - - name: Checkout - uses: actions/checkout@v3.6.0 - - name: Sets up an Erlang/OTP environment - uses: erlef/setup-beam@v1 - with: - version-file: .tool-versions - version-type: strict - - name: Build docs - uses: lee-dohm/generate-elixir-docs@v1.0.1 - - name: Publish to Pages - uses: peaceiris/actions-gh-pages@v3.9.3 - with: - deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} - external_repository: epochtalk/server.epochtalk.github.io - publish_dir: ./doc - publish_branch: gh-pages + - name: Checkout repository + uses: actions/checkout@v4.2.1 + - name: Log in to the Container registry + uses: docker/login-action@v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6.9.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1.4.3 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.gitignore b/.gitignore index a5795582..dc338a57 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ priv/repo/seeds/permissions.json # Ignore secret config files config/*.secret.exs + +#Ignore env file +/.env diff --git a/.tool-versions b/.tool-versions index d8df7710..95179337 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ elixir 1.17.3-otp-27 erlang 27.1.2 +php 8.3.7 diff --git a/Dockerfile b/Dockerfile index ab78109a..adc5f7bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,21 @@ -FROM elixir:1.14.0 +FROM elixir:1.17.3 +# install php +RUN curl -sSL https://packages.sury.org/php/README.txt | bash -x +RUN apt update +RUN apt install -y php8.3 + # work in /app instead of / RUN mkdir -p /app WORKDIR /app RUN mix local.hex --force RUN mix local.rebar --force -ADD . . -RUN mix deps.get # compile for production ENV MIX_ENV=prod +COPY mix.exs . +COPY mix.lock . +RUN mix deps.get +COPY . . RUN mix compile CMD until mix ecto.setup; do sleep 1; done diff --git a/bbcode.php b/bbcode.php new file mode 100644 index 00000000..93bbd313 --- /dev/null +++ b/bbcode.php @@ -0,0 +1,4 @@ + diff --git a/config/config.exs b/config/config.exs index 907a5fba..244623a5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,6 +10,9 @@ import Config # Set ecto repos config :epochtalk_server, ecto_repos: [EpochtalkServer.Repo] +# Configure Porcelain +config :porcelain, driver: Porcelain.Driver.Basic + # Set Guardian.DB to GuardianRedis config :guardian, Guardian.DB, repo: GuardianRedis.Repo diff --git a/config/runtime.exs b/config/runtime.exs index 45406476..bc1cb528 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -404,3 +404,124 @@ if config_env() == :prod do port: get_env_cast_integer_with_default.("EMAILER_SMTP_PORT", "465") end end + +##### PROXY REPO CONFIGURATIONS ##### + +bbc_parser_poolboy_config = [ + name: {:local, :bbc_parser}, + worker_module: EpochtalkServer.BBCParser, + size: 50, + max_overflow: 2, + strategy: :fifo +] + +config :epochtalk_server, bbc_parser_poolboy_config: bbc_parser_poolboy_config + +# conditionally show debug logs in prod +if config_env() == :prod do + logger_level = + System.get_env("LOGGER_LEVEL") + |> case do + "DEBUG" -> :debug + _ -> :info + end + + config :logger, level: logger_level +end + +# Configure proxy sequences and boards blacklist +proxy_config = + case config_env() do + :prod -> + id_board_blacklist = + get_env_or_raise_with_message.( + "ID_BOARD_BLACKLIST", + "environment variable ID_BOARD_BLACKLIST is missing." + ) + + id_board_blacklist = + id_board_blacklist + |> String.split() + |> Enum.map(&String.to_integer(&1)) + + %{ + threads_seq: System.get_env("THREADS_SEQ") || "6000000", + boards_seq: System.get_env("BOARDS_SEQ") || "500", + id_board_blacklist: id_board_blacklist + } + + :dev -> + %{ + threads_seq: "6000000", + boards_seq: "500", + id_board_blacklist: [] + } + + # default thread/board seq and empty blacklist + _ -> + %{ + threads_seq: "-10", + boards_seq: "-10", + id_board_blacklist: [] + } + end + +config :epochtalk_server, proxy_config: proxy_config + +# Configure SmfRepo for proxy +# - do not configure for testing +if config_env() != :test do + smf_repo_username = + get_env_or_raise_with_message.( + "SMF_REPO_USERNAME", + "environment variable SMF_REPO_USERNAME is missing." + ) + + smf_repo_password = + get_env_or_raise_with_message.( + "SMF_REPO_PASSWORD", + "environment variable SMF_REPO_PASSWORD is missing." + ) + + smf_repo_hostname = + get_env_or_raise_with_message.( + "SMF_REPO_HOSTNAME", + "environment variable SMF_REPO_HOSTNAME is missing." + ) + + smf_repo_database = + get_env_or_raise_with_message.( + "SMF_REPO_DATABASE", + "environment variable SMF_REPO_DATABASE is missing." + ) + + proxy_repo_config = + case config_env() do + :prod -> + [ + username: smf_repo_username, + password: smf_repo_password, + hostname: smf_repo_hostname, + database: smf_repo_database, + port: get_env_cast_integer_with_default.("SMF_REPO_PORT", "3306"), + stacktrace: get_env_cast_bool_with_default.("SMF_REPO_STACKTRACE", "TRUE"), + show_sensitive_data_on_connection_error: + get_env_cast_bool_with_default.("SMF_REPO_SENSITIVE_DATA_ON_ERROR", "FALSE"), + pool_size: get_env_cast_integer_with_default.("SMF_REPO_POOL_SIZE", "10") + ] + + _ -> + [ + username: smf_repo_username, + password: smf_repo_password, + hostname: smf_repo_hostname, + database: smf_repo_database, + port: get_env_cast_integer_with_default.("SMF_REPO_PORT", "3306"), + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: get_env_cast_integer_with_default.("SMF_REPO_POOL_SIZE", "10") + ] + end + + config :epochtalk_server, EpochtalkServer.SmfRepo, proxy_repo_config +end diff --git a/lib/epochtalk_server/application.ex b/lib/epochtalk_server/application.ex index 15e929cf..d9de576b 100644 --- a/lib/epochtalk_server/application.ex +++ b/lib/epochtalk_server/application.ex @@ -19,6 +19,10 @@ defmodule EpochtalkServer.Application do {Redix, host: redix_config()[:host], name: redix_config()[:name]}, # Start the Ecto repository EpochtalkServer.Repo, + # Start the Smf repository + EpochtalkServer.SmfRepo, + # Start the BBC Parser + :poolboy.child_spec(:bbc_parser, bbc_parser_poolboy_config()), # Start Role Cache EpochtalkServer.Cache.Role, # Warm frontend_config variable (referenced by api controllers) @@ -38,15 +42,17 @@ defmodule EpochtalkServer.Application do # {EpochtalkServer.Worker, arg} ] - # don't run config_warmer during tests + # adjust supervised processes for testing children = - if Application.get_env(:epochtalk_server, :env) == :test, - do: - List.delete( - children, - {Task, &EpochtalkServer.Models.Configuration.warm_frontend_config/0} - ), - else: children + if Application.get_env(:epochtalk_server, :env) == :test do + children + # don't run config warmer during tests + |> List.delete({Task, &EpochtalkServer.Models.Configuration.warm_frontend_config/0}) + # don't run SmfRepo during tests + |> List.delete(EpochtalkServer.SmfRepo) + else + children + end # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options @@ -64,4 +70,7 @@ defmodule EpochtalkServer.Application do # fetch redix config defp redix_config(), do: Application.get_env(:epochtalk_server, :redix) + + defp bbc_parser_poolboy_config, + do: Application.get_env(:epochtalk_server, :bbc_parser_poolboy_config) end diff --git a/lib/epochtalk_server/bbc_parser.ex b/lib/epochtalk_server/bbc_parser.ex new file mode 100644 index 00000000..17142f9e --- /dev/null +++ b/lib/epochtalk_server/bbc_parser.ex @@ -0,0 +1,97 @@ +defmodule EpochtalkServer.BBCParser do + use GenServer + require Logger + alias Porcelain.Process, as: Proc + + # poolboy genserver call timeout (ms) + # should be greater than internal porcelain php call + @call_timeout 500 + # porcelain php parser call timeout (ms) + @receive_timeout 400 + + @moduledoc """ + `BBCParser` genserver, runs interactive php shell to call bbcode parser + """ + + ## === genserver functions ==== + + @impl true + def init(:ok), do: {:ok, load()} + + @impl true + def handle_call({:parse, ""}, _from, {proc, pid}), + do: {:reply, {:ok, ""}, {proc, pid}} + + def handle_call({:parse, bbcode_data}, _from, {proc, pid}) when is_binary(bbcode_data) do + Proc.send_input(proc, "echo parse_bbc('#{bbcode_data}');\n") + + parsed = + receive do + {^pid, :data, :out, data} -> {:ok, data} + after + # time out after not receiving any data + @receive_timeout -> {:timeout, bbcode_data} + end + + {:reply, parsed, {proc, pid}} + end + + ## === parser api functions ==== + + @doc """ + Start genserver and create a reference for supervision tree + """ + def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok) + + @doc """ + Uses poolboy to call parser + """ + def parse(bbcode_data) do + :poolboy.transaction( + :bbc_parser, + fn pid -> + try do + Logger.debug("#{__MODULE__}(parse): #{inspect(pid)}") + + GenServer.call(pid, {:parse, bbcode_data}, @call_timeout) + |> case do + # on success, return parsed data + {:ok, parsed} -> + parsed + + # on parse timeout, log and return unparsed data + {:timeout, unparsed} -> + Logger.error("#{__MODULE__}(parse timeout): #{inspect(pid)}, #{inspect(unparsed)}") + + "
((bbcode parse timeout))
" <> + unparsed + end + catch + e, r -> + # something went wrong, log the error + Logger.error( + "#{__MODULE__}(parse poolboy): #{inspect(pid)}, #{inspect(e)}, #{inspect(r)}" + ) + + bbcode_data + end + end, + @call_timeout + ) + end + + ## === private functions ==== + + # returns loaded interactive php shell + defp load() do + proc = %Proc{pid: pid} = Porcelain.spawn_shell("php -a", in: :receive, out: {:send, self()}) + Proc.send_input(proc, "require 'parsing.php';\n") + Logger.debug("#{__MODULE__}(LOAD): #{inspect(pid)}") + # clear initial php interactive shell message + receive do + {^pid, :data, :out, data} -> Logger.debug("#{__MODULE__}: #{inspect(data)}") + end + + {proc, pid} + end +end diff --git a/lib/epochtalk_server/models/thread.ex b/lib/epochtalk_server/models/thread.ex index ca57f578..e2aa9549 100644 --- a/lib/epochtalk_server/models/thread.ex +++ b/lib/epochtalk_server/models/thread.ex @@ -724,29 +724,6 @@ defmodule EpochtalkServer.Models.Thread do first_post != nil and first_post.user_id == user_id and first_post.moderated end - @doc """ - Used to obtain breadcrumb data for a specific `Thread` given it's `slug` - """ - @spec breadcrumb(slug :: String.t()) :: - {:ok, thread :: t()} | {:error, :thread_does_not_exist} - def breadcrumb(slug) when is_binary(slug) do - post_query = - from p in Post, - order_by: p.created_at, - limit: 1 - - thread_query = - from t in Thread, - where: t.slug == ^slug, - preload: [:board, posts: ^post_query] - - thread = Repo.one(thread_query) - - if thread, - do: {:ok, thread}, - else: {:error, :thread_does_not_exist} - end - @doc """ Used to get first `Post` data given a `Thread` id """ diff --git a/lib/epochtalk_server/smf_loader.ex b/lib/epochtalk_server/smf_loader.ex new file mode 100644 index 00000000..d99bcbb1 --- /dev/null +++ b/lib/epochtalk_server/smf_loader.ex @@ -0,0 +1,203 @@ +defmodule EpochtalkServer.SMFLoader do + @moduledoc """ + Load data from smf mysql output, format for import + and create/insert mappings for categories and boards + """ + alias EpochtalkServer.Models.BoardMapping + + # converts smf_boards tsv file to epochtalk boards tsv file + def convert_smf_boards_tsv_file(path) do + {boards, board_mappings} = + load_from_tsv_file(path) + |> map_boards_stream() + + # write boards to file for import + boards + |> tabulate_boards_map() + |> write_to_tsv_file("boards.tsv") + + # return board mappings for later insertion + board_mappings + end + + def load_categories_mappings_from_tsv_file(path) do + # load categories (in postgres format) + # and return category mappings for later insertion + load_from_tsv_file(path) + |> Enum.map(fn category -> + # generate category mapping + %{ + type: "category", + id: category["id"] |> String.to_integer(), + name: category["name"], + view_order: category["view_order"] |> String.to_integer() + } + end) + end + + def insert_board_mappings(board_mappings) do + # board mappings + board_mappings + |> BoardMapping.update() + end + + # loads smf data from a tsv file + def load_from_tsv_file(path) do + with true <- if(File.exists?(path), do: true, else: "ファイルがない"), + {:ok, file} <- File.read(path), + file_lines <- file |> String.trim() |> String.split("\n"), + [header_line | file_lines] <- file_lines, + # clean line + headers <- + header_line + |> String.trim() + |> String.split("\t"), + smf_boards <- + file_lines + |> Enum.map( + &(&1 + # clean line + |> String.trim() + |> String.split("\t")) + ) + |> Enum.map(fn line -> + # map each line to headers + Enum.zip(headers, line) + |> Enum.into(%{}) + end) do + smf_boards + else + problem -> IO.puts("問題がある: #{inspect(problem)}") + end + end + + def map_boards_stream(boards_stream) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_string() + + {board_mapping_pairs, _slug_duplicate_index} = + boards_stream + |> Enum.map_reduce(%{}, fn smf_board, slugs -> + slug = + smf_board["name"] + |> HtmlEntities.decode() + |> String.replace(~r{[ /]}, "-") + |> String.slice(0..99) + + # handle duplicate slugs + slug_duplicate_index = Map.get(slugs, slug) + + {slugs, slug} = + if slug_duplicate_index == nil do + # keep track of used slugs, return original slug + {Map.put(slugs, slug, 0), slug} + else + # replace last characters with index + new_slug = + slug |> String.slice(0..(99 - (1 + Integer.floor_div(slug_duplicate_index, 10)))) + + new_slug = new_slug <> to_string(slug_duplicate_index) + # increment used slugs, return new slug + {Map.put(slugs, slug, slug_duplicate_index + 1), new_slug} + end + + # build board + board = + %{} + |> Map.put(:id, smf_board["ID_BOARD"]) + |> Map.put(:name, smf_board["name"] |> HtmlEntities.decode()) + |> Map.put(:description, smf_board["description"] |> HtmlEntities.decode()) + |> Map.put(:post_count, smf_board["numPosts"]) + |> Map.put(:thread_count, smf_board["numTopics"]) + |> Map.put(:viewable_by, "") + |> Map.put(:postable_by, "") + |> Map.put(:created_at, now) + |> Map.put(:imported_at, now) + |> Map.put(:updated_at, now) + |> Map.put( + :meta, + "\"{\"\"disable_self_mod\"\": false, \"\"disable_post_edit\"\": null, \"\"disable_signature\"\": false}\"" + ) + |> Map.put(:right_to_left, "f") + |> Map.put(:slug, slug) + + # build board mapping + board_mapping = + case {smf_board["childLevel"], smf_board["ID_PARENT"]} do + # top level board, map under category + {"0", "0"} -> + %{ + type: "board", + id: smf_board["ID_BOARD"] |> String.to_integer(), + name: smf_board["name"] |> HtmlEntities.decode(), + category_id: smf_board["ID_CAT"] |> String.to_integer(), + view_order: smf_board["boardOrder"] |> String.to_integer() + } + + _ -> + %{ + type: "board", + id: smf_board["ID_BOARD"] |> String.to_integer(), + name: smf_board["name"] |> HtmlEntities.decode(), + parent_id: smf_board["ID_PARENT"] |> String.to_integer(), + view_order: smf_board["boardOrder"] |> String.to_integer() + } + end + + {{board, board_mapping}, slugs} + end) + + Enum.unzip(board_mapping_pairs) + end + + def tabulate_boards_map(boards_map) do + data = + boards_map + |> Enum.map(fn board -> + [ + board[:id], + board[:name], + board[:description], + board[:post_count], + board[:thread_count], + board[:viewable_by], + board[:postable_by], + board[:created_at], + board[:imported_at], + board[:updated_at], + board[:meta], + board[:right_to_left], + board[:slug] + ] + |> Enum.join("\t") + end) + + header = + [ + "id", + "name", + "description", + "post_count", + "thread_count", + "viewable_by", + "postable_by", + "created_at", + "imported_at", + "updated_at", + "meta", + "right_to_left", + "slug" + ] + |> Enum.join("\t") + + [header | data] + end + + def write_to_tsv_file(data, path) do + with false <- if(File.exists?(path), do: "ファイルがもうある", else: false), + file <- File.stream!(path) do + data |> Enum.into(file, fn line -> line <> "\n" end) + else + problem -> IO.puts("問題がある: #{inspect(problem)}") + end + end +end diff --git a/lib/epochtalk_server/smf_repo.ex b/lib/epochtalk_server/smf_repo.ex new file mode 100644 index 00000000..af431a36 --- /dev/null +++ b/lib/epochtalk_server/smf_repo.ex @@ -0,0 +1,8 @@ +defmodule EpochtalkServer.SmfRepo do + @moduledoc """ + SmfRepo, for connecting to old btct DB for proxy data pulling + """ + use Ecto.Repo, + otp_app: :epochtalk_server, + adapter: Ecto.Adapters.MyXQL +end diff --git a/lib/epochtalk_server_web/controllers/board.ex b/lib/epochtalk_server_web/controllers/board.ex index 6bb34e40..445d2bb5 100644 --- a/lib/epochtalk_server_web/controllers/board.ex +++ b/lib/epochtalk_server_web/controllers/board.ex @@ -11,6 +11,9 @@ defmodule EpochtalkServerWeb.Controllers.Board do alias EpochtalkServerWeb.ErrorHelpers alias EpochtalkServerWeb.Helpers.Validate alias EpochtalkServerWeb.Helpers.ACL + alias EpochtalkServerWeb.Helpers.ProxyConversion + + plug :check_proxy when action in [:slug_to_id, :by_category] @doc """ Used to retrieve categorized boards @@ -102,4 +105,65 @@ defmodule EpochtalkServerWeb.Controllers.Board do ) end end + + defp check_proxy(conn, _) do + %{boards_seq: boards_seq} = Application.get_env(:epochtalk_server, :proxy_config) + boards_seq = boards_seq |> String.to_integer() + + conn = + case conn.private.phoenix_action do + :slug_to_id -> + case Integer.parse(conn.params["slug"]) do + {_, ""} -> + slug_as_id = Validate.cast(conn.params, "slug", :integer, required: true) + + if slug_as_id < boards_seq do + conn + |> render(:slug_to_id, id: slug_as_id) + |> halt() + else + conn + end + + _ -> + conn + end + + :by_category -> + if boards_seq > 0 do + conn + |> proxy_by_category(conn.params) + |> halt() + else + conn + end + + _ -> + conn + end + + conn + end + + defp proxy_by_category(conn, attrs) do + with :ok <- ACL.allow!(conn, "boards.allCategories"), + stripped <- Validate.cast(attrs, "stripped", :boolean, default: false), + user_priority <- ACL.get_user_priority(conn), + board_mapping <- BoardMapping.all(stripped: stripped), + {:ok, board_moderators} <- ProxyConversion.build_model("boards.moderators"), + {:ok, board_counts} <- ProxyConversion.build_model("boards.counts"), + {:ok, board_last_post_info} <- ProxyConversion.build_model("boards.last_post_info"), + categories <- Category.all() do + render(conn, :proxy_by_category, %{ + categories: categories, + board_moderators: board_moderators, + board_mapping: board_mapping, + user_priority: user_priority, + board_counts: board_counts, + board_last_post_info: board_last_post_info + }) + else + _ -> ErrorHelpers.render_json_error(conn, 400, "Error, cannot fetch boards") + end + end end diff --git a/lib/epochtalk_server_web/controllers/breadcrumb.ex b/lib/epochtalk_server_web/controllers/breadcrumb.ex index 8ca0f077..f77a448b 100644 --- a/lib/epochtalk_server_web/controllers/breadcrumb.ex +++ b/lib/epochtalk_server_web/controllers/breadcrumb.ex @@ -12,7 +12,7 @@ defmodule EpochtalkServerWeb.Controllers.Breadcrumb do Used to return breadcrumbs to the frontend` """ def breadcrumbs(conn, attrs) do - with id <- parse_id(attrs, attrs["id"]), + with id <- parse_id(attrs), type <- Validate.cast(attrs, "type", :string, required: true), {:ok, breadcrumbs} <- Breadcrumbs.build_crumbs(id, type, []) do render(conn, :breadcrumbs, breadcrumbs: breadcrumbs) @@ -25,9 +25,13 @@ defmodule EpochtalkServerWeb.Controllers.Breadcrumb do end end - defp parse_id(attrs, id) when is_integer(id), - do: Validate.cast(attrs, "id", :integer, min: 1, required: true) + defp parse_id(attrs) do + case Integer.parse(attrs["id"]) do + {_, ""} -> + Validate.cast(attrs, "id", :integer, min: 1, required: true) - defp parse_id(attrs, id) when is_binary(id), - do: Validate.cast(attrs, "id", :string, required: true) + _ -> + Validate.cast(attrs, "id", :string, required: true) + end + end end diff --git a/lib/epochtalk_server_web/controllers/notification.ex b/lib/epochtalk_server_web/controllers/notification.ex index d3b084ee..a94d6091 100644 --- a/lib/epochtalk_server_web/controllers/notification.ex +++ b/lib/epochtalk_server_web/controllers/notification.ex @@ -14,12 +14,12 @@ defmodule EpochtalkServerWeb.Controllers.Notification do Used to retrieve `Notification` counts for a specific `User` """ def counts(conn, attrs) do - with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with user <- Guardian.Plug.current_resource(conn), :ok <- ACL.allow!(conn, "notifications.counts"), max <- Validate.cast(attrs, "max", :integer, min: 1) do render(conn, :counts, data: Notification.counts_by_user_id(user.id, max: max || 99)) else - {:auth, nil} -> + _ -> ErrorHelpers.render_json_error( conn, 400, @@ -32,19 +32,12 @@ defmodule EpochtalkServerWeb.Controllers.Notification do Used to dismiss `Notification` counts for a specific `User` """ def dismiss(conn, %{"id" => id}) do - with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with user <- Guardian.Plug.current_resource(conn), :ok <- ACL.allow!(conn, "notifications.dismiss"), {_count, nil} <- Notification.dismiss(id) do EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "refreshMentions", %{}) render(conn, :dismiss, success: true) else - {:auth, nil} -> - ErrorHelpers.render_json_error( - conn, - 400, - "Not logged in, cannot dismiss notification counts" - ) - _ -> ErrorHelpers.render_json_error( conn, @@ -55,19 +48,12 @@ defmodule EpochtalkServerWeb.Controllers.Notification do end def dismiss(conn, %{"type" => type}) do - with {:auth, %{} = user} <- {:auth, Guardian.Plug.current_resource(conn)}, + with user <- Guardian.Plug.current_resource(conn), :ok <- ACL.allow!(conn, "notifications.dismiss"), {_count, nil} <- Notification.dismiss_type_by_user_id(user.id, type) do EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "refreshMentions", %{}) render(conn, :dismiss, success: true) else - {:auth, nil} -> - ErrorHelpers.render_json_error( - conn, - 400, - "Not logged in, cannot dismiss notification counts" - ) - {:error, :invalid_notification_type} -> ErrorHelpers.render_json_error(conn, 400, "Cannot dismiss, invalid notification type") diff --git a/lib/epochtalk_server_web/controllers/poll.ex b/lib/epochtalk_server_web/controllers/poll.ex index 071c24e1..a8afa708 100644 --- a/lib/epochtalk_server_web/controllers/poll.ex +++ b/lib/epochtalk_server_web/controllers/poll.ex @@ -350,7 +350,7 @@ defmodule EpochtalkServerWeb.Controllers.Poll do ErrorHelpers.render_json_error(conn, 403, "Unauthorized, you are banned from this board") _ -> - ErrorHelpers.render_json_error(conn, 400, "Error, cannot cast vote") + ErrorHelpers.render_json_error(conn, 400, "Error, cannot delete vote") end end diff --git a/lib/epochtalk_server_web/controllers/post.ex b/lib/epochtalk_server_web/controllers/post.ex index 9f32d9e5..6adaa4fd 100644 --- a/lib/epochtalk_server_web/controllers/post.ex +++ b/lib/epochtalk_server_web/controllers/post.ex @@ -10,6 +10,7 @@ defmodule EpochtalkServerWeb.Controllers.Post do alias EpochtalkServerWeb.Helpers.ACL alias EpochtalkServerWeb.Helpers.Sanitize alias EpochtalkServerWeb.Helpers.Parse + alias EpochtalkServerWeb.Helpers.ProxyConversion alias EpochtalkServer.Models.Profile alias EpochtalkServer.Models.Post alias EpochtalkServer.Models.Poll @@ -31,6 +32,8 @@ defmodule EpochtalkServerWeb.Controllers.Post do @max_post_title_length 255 + plug :check_proxy when action in [:by_thread, :by_username] + @doc """ Used to create posts """ @@ -520,4 +523,83 @@ defmodule EpochtalkServerWeb.Controllers.Post do !Board.is_post_edit_disabled_by_thread_id(post.thread_id, post.created_at), true ) + + defp check_proxy(conn, _) do + conn = + case conn.private.phoenix_action do + :by_thread -> + %{threads_seq: threads_seq} = Application.get_env(:epochtalk_server, :proxy_config) + threads_seq = threads_seq |> String.to_integer() + + if Validate.cast(conn.params, "thread_id", :integer, required: true) < threads_seq do + conn + |> proxy_by_thread(conn.params) + |> halt() + else + conn + end + + :by_username -> + conn + |> proxy_by_username(conn.params) + |> halt() + + _ -> + conn + end + + conn + end + + defp proxy_by_username(conn, attrs) do + # Parameter Validation + with user_id <- Validate.cast(attrs, "id", :integer, required: true), + page <- Validate.cast(attrs, "page", :integer, default: 1, min: 1), + limit <- Validate.cast(attrs, "limit", :integer, default: 25, min: 1, max: 100), + desc <- Validate.cast(attrs, "desc", :boolean, default: true), + {:ok, posts, data} <- + ProxyConversion.build_model("posts.by_user", user_id, page, limit, desc) do + render(conn, :proxy_by_username, %{ + posts: posts, + count: data.total_records, + limit: data.per_page, + page: data.page, + desc: desc + }) + else + _ -> + ErrorHelpers.render_json_error(conn, 400, "Error, cannot get posts by username") + end + end + + defp proxy_by_thread(conn, attrs) do + with thread_id <- Validate.cast(attrs, "thread_id", :integer, required: true), + page <- Validate.cast(attrs, "page", :integer, default: 1), + limit <- Validate.cast(attrs, "limit", :integer, default: 5), + user <- Guardian.Plug.current_resource(conn), + user_priority <- ACL.get_user_priority(conn), + :ok <- ACL.allow!(conn, "posts.byThread"), + board_mapping <- BoardMapping.all(), + board_moderators <- BoardModerator.all(), + thread <- ProxyConversion.build_model("thread", thread_id), + poll <- ProxyConversion.build_model("poll.by_thread", thread_id), + {:ok, posts, data} <- + ProxyConversion.build_model("posts.by_thread", thread_id, page, limit) do + render(conn, :by_thread_proxy, %{ + posts: posts, + poll: poll, + thread: thread, + user: user, + user_priority: user_priority, + board_mapping: board_mapping, + board_moderators: board_moderators, + page: page, + limit: limit, + pagination_data: data + }) + else + _ -> + ErrorHelpers.render_json_error(conn, 400, "Error, cannot get posts by thread") + end + end end diff --git a/lib/epochtalk_server_web/controllers/thread.ex b/lib/epochtalk_server_web/controllers/thread.ex index c8878dbd..55a9e38e 100644 --- a/lib/epochtalk_server_web/controllers/thread.ex +++ b/lib/epochtalk_server_web/controllers/thread.ex @@ -26,6 +26,9 @@ defmodule EpochtalkServerWeb.Controllers.Thread do alias EpochtalkServer.Models.UserActivity alias EpochtalkServer.Models.ThreadSubscription alias EpochtalkServer.Models.Mention + alias EpochtalkServerWeb.Helpers.ProxyConversion + + plug :check_proxy when action in [:by_board, :by_username, :slug_to_id, :viewed, :recent] @doc """ Used to retrieve recent threads @@ -729,6 +732,128 @@ defmodule EpochtalkServerWeb.Controllers.Thread do defp update_user_thread_view_count(user, thread_id), do: UserThreadView.upsert(user.id, thread_id) + # check proxy for :by_board action + defp check_proxy(%{private: %{phoenix_action: :by_board}} = conn, _) do + %{boards_seq: boards_seq} = Application.get_env(:epochtalk_server, :proxy_config) + boards_seq = boards_seq |> String.to_integer() + + if Validate.cast(conn.params, "board_id", :integer, required: true) < boards_seq do + conn + |> proxy_by_board(conn.params) + |> halt() + else + conn + end + end + + # check proxy for :slug_to_id action + defp check_proxy(%{private: %{phoenix_action: :slug_to_id}} = conn, _) do + case Integer.parse(conn.params["slug"]) do + {_, ""} -> + slug_as_id = Validate.cast(conn.params, "slug", :integer, required: true) + + %{threads_seq: threads_seq} = Application.get_env(:epochtalk_server, :proxy_config) + threads_seq = threads_seq |> String.to_integer() + + if slug_as_id < threads_seq do + conn + |> render(:slug_to_id, id: slug_as_id) + |> halt() + else + conn + end + + _ -> + conn + end + end + + # check proxy for :viewed action + defp check_proxy(%{private: %{phoenix_action: :viewed}} = conn, _) do + conn + |> send_resp(200, []) + |> halt() + end + + # check proxy for :recent action + defp check_proxy(%{private: %{phoenix_action: :recent}} = conn, _) do + conn + |> proxy_recent(conn.params) + |> halt() + end + + # check proxy for :recent action + defp check_proxy(%{private: %{phoenix_action: :by_username}} = conn, _) do + conn + |> proxy_by_username(conn.params) + |> halt() + end + + # check proxy default + defp check_proxy(%{private: %{phoenix_action: _}} = conn, _) do + conn + end + + defp proxy_by_username(conn, attrs) do + # Parameter Validation + with user_id <- Validate.cast(attrs, "id", :integer, required: true), + page <- Validate.cast(attrs, "page", :integer, default: 1, min: 1), + limit <- Validate.cast(attrs, "limit", :integer, default: 25, min: 1, max: 100), + desc <- Validate.cast(attrs, "desc", :boolean, default: true), + {:ok, threads, data} <- + ProxyConversion.build_model("threads.by_user", user_id, page, limit, desc) do + render(conn, :proxy_by_username, %{ + threads: threads, + next: data.next, + prev: data.prev, + limit: data.per_page, + page: data.page, + desc: desc + }) + else + _ -> + ErrorHelpers.render_json_error(conn, 400, "Error, cannot get threads by username") + end + end + + defp proxy_by_board(conn, attrs) do + with board_id <- Validate.cast(attrs, "board_id", :integer, required: true), + page <- Validate.cast(attrs, "page", :integer, default: 1), + limit <- Validate.cast(attrs, "limit", :integer, default: 5), + user <- Guardian.Plug.current_resource(conn), + user_priority <- ACL.get_user_priority(conn), + :ok <- ACL.allow!(conn, "threads.byBoard"), + board_mapping <- BoardMapping.all(), + {:ok, board_moderators} <- ProxyConversion.build_model("boards.moderators"), + {:ok, board_counts} <- ProxyConversion.build_model("boards.counts"), + {:ok, board_last_post_info} <- ProxyConversion.build_model("boards.last_post_info"), + {:ok, threads, data} <- + ProxyConversion.build_model("threads.by_board", board_id, page, limit) do + render(conn, :by_board_proxy, %{ + threads: threads, + user: user, + user_priority: user_priority, + board_id: board_id, + board_mapping: board_mapping, + board_moderators: board_moderators, + page: page, + limit: limit, + pagination_data: data, + board_counts: board_counts, + board_last_post_info: board_last_post_info + }) + else + _ -> + ErrorHelpers.render_json_error(conn, 400, "Error, cannot get threads by board") + end + end + + defp proxy_recent(conn, _attrs) do + with threads <- ProxyConversion.build_model("threads.recent") do + render(conn, :recent, %{threads: threads}) + end + end + # === Private Authorization Helpers Functions === defp handle_check_user_can_moderate(user, moderated) do diff --git a/lib/epochtalk_server_web/controllers/user.ex b/lib/epochtalk_server_web/controllers/user.ex index e587e4d1..a496895e 100644 --- a/lib/epochtalk_server_web/controllers/user.ex +++ b/lib/epochtalk_server_web/controllers/user.ex @@ -17,6 +17,9 @@ defmodule EpochtalkServerWeb.Controllers.User do alias EpochtalkServerWeb.CustomErrors.InvalidPayload alias EpochtalkServerWeb.Helpers.ACL alias EpochtalkServerWeb.Helpers.Validate + alias EpochtalkServerWeb.Helpers.ProxyConversion + + plug :check_proxy when action in [:find] @doc """ Used to check if a username has already been taken @@ -196,14 +199,12 @@ defmodule EpochtalkServerWeb.Controllers.User do Logs out the logged in `User` """ def logout(conn, _attrs) do - with {:auth, true} <- {:auth, Guardian.Plug.authenticated?(conn)}, - user <- Guardian.Plug.current_resource(conn), + with user <- Guardian.Plug.current_resource(conn), token <- Guardian.Plug.current_token(conn), {:ok, conn} <- Session.delete(conn) do EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "logout", %{token: token}) render(conn, :data, data: %{success: true}) else - {:auth, false} -> ErrorHelpers.render_json_error(conn, 400, "Not logged in") {:error, error} -> ErrorHelpers.render_json_error(conn, 500, error) end end @@ -253,4 +254,24 @@ defmodule EpochtalkServerWeb.Controllers.User do end def login(_conn, _attrs), do: raise(InvalidPayload) + + ## === Private Helper Functions === + + defp check_proxy(conn, _) do + case conn.private.phoenix_action do + :find -> + conn + |> proxy_find(conn.params) + |> halt() + + _ -> + conn + end + end + + defp proxy_find(conn, attrs) do + with user <- ProxyConversion.build_model("user.find", attrs["id"]) do + render(conn, :find_proxy, %{user: user}) + end + end end diff --git a/lib/epochtalk_server_web/helpers/breadcrumbs.ex b/lib/epochtalk_server_web/helpers/breadcrumbs.ex index fa3b2f2c..23e07bc5 100644 --- a/lib/epochtalk_server_web/helpers/breadcrumbs.ex +++ b/lib/epochtalk_server_web/helpers/breadcrumbs.ex @@ -2,6 +2,7 @@ defmodule EpochtalkServerWeb.Helpers.Breadcrumbs do @moduledoc """ Helper for creating breadcrumbs """ + alias EpochtalkServerWeb.Helpers.ProxyConversion alias EpochtalkServer.Models.Category alias EpochtalkServer.Models.Board alias EpochtalkServer.Models.Thread @@ -69,24 +70,35 @@ defmodule EpochtalkServerWeb.Helpers.Breadcrumbs do end defp build_thread_crumbs(id, crumbs) do - case Thread.breadcrumb(id) do - {:ok, thread} -> - post = Enum.at(thread.posts, 0) - - crumbs = [ - %{ - label: post.content["title"], - routeName: "Posts", - opts: %{slug: id, locked: post.locked} - } - | crumbs - ] - - next_data = get_next_data(thread, "thread") - build_crumbs(next_data[:id], next_data[:type], crumbs) - - {:error, :thread_does_not_exist} -> - build_crumbs(nil, nil, crumbs) + %{threads_seq: threads_seq} = Application.get_env(:epochtalk_server, :proxy_config) + threads_seq = threads_seq |> String.to_integer() + + thread = + cond do + is_integer(id) && id < threads_seq -> + ProxyConversion.build_model("thread", id) + + is_binary(id) -> + Thread.find(id) + + true -> + nil + end + + if is_nil(thread) do + build_crumbs(nil, nil, crumbs) + else + crumbs = [ + %{ + label: thread.title, + routeName: "Posts", + opts: %{slug: id, locked: thread.locked} + } + | crumbs + ] + + next_data = get_next_data(thread, "thread") + build_crumbs(next_data[:id], next_data[:type], crumbs) end end @@ -106,7 +118,8 @@ defmodule EpochtalkServerWeb.Helpers.Breadcrumbs do end "thread" -> - %{id: object.board.slug, type: "board"} + {:ok, board} = Board.find_by_id(object.board_id) + %{id: board.slug, type: "board"} _ -> nil diff --git a/lib/epochtalk_server_web/helpers/proxy_conversion.ex b/lib/epochtalk_server_web/helpers/proxy_conversion.ex new file mode 100644 index 00000000..477d6d9c --- /dev/null +++ b/lib/epochtalk_server_web/helpers/proxy_conversion.ex @@ -0,0 +1,613 @@ +defmodule EpochtalkServerWeb.Helpers.ProxyConversion do + import Ecto.Query + alias EpochtalkServer.SmfRepo + alias EpochtalkServerWeb.Helpers.ProxyPagination + + @moduledoc """ + Helper for pulling and formatting data from SmfRepo + """ + + def build_model(model_type, ids, _, _) when is_nil(model_type) or is_nil(ids) do + {:ok, %{}, %{}} + end + + def build_model(_, ids, _, _) when length(ids) > 25 do + {:error, "Limit too large, please try again"} + end + + def build_model(model_type, id, page, per_page) when is_integer(id) do + case model_type do + "threads.by_board" -> + build_threads_by_board(id, page, per_page) + + "posts.by_thread" -> + build_posts_by_thread(id, page, per_page) + + _ -> + build_model(nil, nil, nil, nil) + end + end + + def build_model(model_type, id, page, per_page, desc) when is_integer(id) do + case model_type do + "threads.by_user" -> + build_threads_by_user(id, page, per_page, desc) + + "posts.by_user" -> + build_posts_by_user(id, page, per_page, desc) + + _ -> + build_model(nil, nil, nil, nil) + end + end + + def build_model(model_type, id) do + case model_type do + "category" -> + build_category(id) + + "board" -> + build_board(id) + + "thread" -> + build_thread(id) + + "post" -> + build_post(id) + + "poll.by_thread" -> + build_poll(id) + + "user.find" -> + build_user(id) + + _ -> + build_model(nil, nil, nil, nil) + end + end + + def build_model(model_type) do + case model_type do + "boards.counts" -> + build_board_counts() + + "boards.last_post_info" -> + build_board_last_post_info() + + "boards.moderators" -> + build_board_moderators() + + "threads.recent" -> + build_recent_threads() + + _ -> + build_model(nil, nil, nil, nil) + end + end + + def build_user(user_id) do + from(u in "smf_members", where: u.id_member == ^user_id) + |> join(:left, [u], a in "smf_attachments", + on: u.id_member == a.id_member and a.attachmentType == 1 + ) + |> join(:left, [u], m in "smf_membergroups", on: u.id_group != 0 and u.id_group == m.id_group) + |> join(:left, [u], g in "smf_membergroups", on: u.id_post_group == g.id_group) + |> select([u, a, m, g], %{ + activity: u.activity, + created_at: u.dateRegistered * 1000, + dob: u.birthdate, + gender: u.gender, + id: u.id_member, + language: nil, + location: u.location, + merit: u.merit, + id_group: u.id_group, + id_post_group: u.id_post_group, + signature: u.signature, + post_count: u.posts, + name: u.realName, + username: u.realName, + title: u.usertitle, + website: u.websiteUrl, + last_login: u.lastLogin * 1000, + show_online: u.showOnline, + group_name: m.groupName, + group_name_2: g.groupName, + group_color: m.onlineColor, + group_color_2: g.onlineColor, + avatar: + fragment( + "if(? <>'',concat('https://bitcointalk.org/avatars/',?),ifnull(concat('https://bitcointalk.org/useravatars/',?),''))", + u.avatar, + u.avatar, + a.filename + ) + }) + |> SmfRepo.one() + end + + def build_poll(thread_id) do + from(t in "smf_topics", + where: t.id_topic == ^thread_id + ) + |> join(:left, [t], p in "smf_polls", on: t.id_poll == p.id_poll) + |> select([t, p], %{ + id: t.id_poll, + change_vote: p.changeVote, + display_mode: "always", + expiration: p.expireTime * 1000, + has_voted: false, + locked: p.votingLocked == 1, + max_answers: p.maxVotes, + question: p.question + }) + |> SmfRepo.one() + |> case do + [] -> + {:error, "Poll for thread not found"} + + poll -> + from(t in "smf_topics", + where: t.id_topic == ^thread_id + ) + |> join(:left, [t], pc in "smf_poll_choices", on: t.id_poll == pc.id_poll) + |> select([t, pc], %{ + id: pc.id_choice, + selected: false, + votes: pc.votes, + answer: pc.label + }) + |> SmfRepo.all() + |> case do + [] -> + {:error, "Poll for thread not found"} + + answers -> + if poll.id > 0, do: Map.put(poll, :answers, answers), else: nil + end + end + end + + def build_recent_threads() do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(t in "smf_topics", + limit: 5, + where: t.id_board not in ^id_board_blacklist, + order_by: [desc: t.id_last_msg] + ) + |> join(:left, [t], m in "smf_messages", on: t.id_first_msg == m.id_msg) + |> join(:left, [t], m in "smf_messages", on: t.id_last_msg == m.id_msg) + |> join(:left, [t], b in "smf_boards", on: t.id_board == b.id_board) + |> select([t, f, l, b], %{ + id: t.id_topic, + slug: t.id_topic, + board_id: t.id_board, + board_name: b.name, + board_slug: t.id_board, + sticky: t.isSticky, + locked: t.locked, + poll: t.id_poll > 0, + moderated: t.selfModerated, + first_post_id: t.id_first_msg, + last_post_id: t.id_last_msg, + title: f.subject, + updated_at: l.posterTime * 1000, + last_post_created_at: l.posterTime * 1000, + last_post_user_id: l.id_member, + last_post_username: l.posterName, + view_count: t.numViews, + last_post_position: nil, + last_post_deleted: false, + last_post_user_deleted: false, + new_post_id: nil, + new_post_position: nil, + is_proxy: true + }) + |> SmfRepo.all() + |> case do + [] -> + {:error, "Recent threads not found"} + + threads -> + threads + end + end + + def build_category(id) do + from(c in "smf_categories", + where: c.id_cat == ^id, + select: %{ + id: c.id_cat, + name: c.name, + view_order: c.catOrder + } + ) + |> SmfRepo.one() + |> case do + [] -> + {:error, "Category not found for id: #{id}"} + + category -> + category + end + end + + def build_board_counts() do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(b in "smf_boards", + where: b.id_board not in ^id_board_blacklist, + select: %{ + id: b.id_board, + thread_count: b.numTopics, + post_count: b.numPosts + } + ) + |> SmfRepo.all() + |> case do + [] -> + {:error, "Boards not found"} + + boards -> + return_tuple(boards) + end + end + + def build_board_last_post_info() do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(b in "smf_boards", + where: b.id_board not in ^id_board_blacklist + ) + |> join(:left, [b], m in "smf_messages", on: b.id_last_msg == m.id_msg) + |> join(:left, [b, m], t in "smf_topics", on: m.id_topic == t.id_topic) + |> join(:left, [b, m, t], u in "smf_members", on: m.id_member == u.id_member) + |> join(:left, [b, m, t, u], a in "smf_attachments", + on: m.id_member == a.id_member and a.attachmentType == 1 + ) + |> select([b, m, t, u, a], %{ + id: b.id_board, + last_post_created_at: m.posterTime * 1000, + last_post_position: t.numReplies, + last_post_username: m.posterName, + last_post_user_id: m.id_member, + last_post_avatar: + fragment( + "if(? <>'',concat('https://bitcointalk.org/avatars/',?),ifnull(concat('https://bitcointalk.org/useravatars/',?),''))", + u.avatar, + u.avatar, + a.filename + ), + last_thread_created_at: t.id_member_started, + last_thread_id: t.id_topic, + last_thread_post_count: t.numReplies, + last_thread_slug: t.id_topic, + last_thread_title: m.subject, + last_thread_updated_at: m.posterTime * 1000 + }) + |> SmfRepo.all() + |> case do + [] -> + {:error, "Boards not found"} + + boards -> + return_tuple(boards) + end + end + + def build_board_moderators() do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(b in "smf_boards", + where: b.id_board not in ^id_board_blacklist + ) + |> join(:inner, [b], mod in "smf_moderators", on: b.id_board == mod.id_board) + |> join(:inner, [b, mod], m in "smf_members", on: mod.id_member == m.id_member) + |> select([b, mod, m], %{ + board_id: b.id_board, + user_id: m.id_member, + user: %{ + username: m.realname + } + }) + |> SmfRepo.all() + |> case do + [] -> + {:error, "Board Moderators not found"} + + moderators -> + return_tuple(moderators) + end + end + + def build_board(id) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(b in "smf_boards", + where: b.id_board == ^id and b.id_board not in ^id_board_blacklist, + select: %{ + id: b.id_board, + slug: b.id_board, + cat_id: b.id_cat, + name: b.name, + description: b.description, + thread_count: b.numTopics, + post_count: b.numPosts + } + ) + |> SmfRepo.one() + |> case do + [] -> + {:error, "Board not found for id: #{id}"} + + board -> + board + end + end + + def build_threads_by_board(id, page, per_page) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + count_query = + from t in "smf_topics", + where: t.id_board == ^id and t.id_board not in ^id_board_blacklist, + select: %{count: count(t.id_topic)} + + from(t in "smf_topics", + where: t.id_board == ^id and t.id_board not in ^id_board_blacklist, + order_by: [desc: t.id_last_msg] + ) + |> join(:left, [t], f in "smf_messages", on: t.id_first_msg == f.id_msg) + |> join(:left, [t], l in "smf_messages", on: t.id_last_msg == l.id_msg) + |> join(:left, [t, f, l], m in "smf_members", on: l.id_member == m.id_member) + |> join(:left, [t, f, l, m], a in "smf_attachments", + on: m.id_member == a.id_member and a.attachmentType == 1 + ) + |> select([t, f, l, m, a], %{ + id: t.id_topic, + slug: t.id_topic, + board_id: t.id_board, + sticky: t.isSticky, + locked: t.locked, + view_count: t.numViews, + first_post_id: t.id_first_msg, + last_post_id: t.id_last_msg, + started_user_id: t.id_member_started, + last_post_user_id: t.id_member_updated, + moderated: t.selfModerated, + post_count: t.numReplies, + title: f.subject, + user_id: f.id_member, + username: f.posterName, + created_at: f.posterTime * 1000, + user_deleted: false, + last_post_created_at: l.posterTime * 1000, + last_post_deleted: false, + last_post_user_id: l.id_member, + last_post_username: l.posterName, + last_post_user_deleted: false, + last_post_avatar: + fragment( + "if(? <>'',concat('https://bitcointalk.org/avatars/',?),ifnull(concat('https://bitcointalk.org/useravatars/',?),''))", + m.avatar, + m.avatar, + a.filename + ), + last_viewed: nil, + is_proxy: true + }) + |> ProxyPagination.page_simple(count_query, page, per_page: per_page, desc: true) + end + + def build_thread(id) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(t in "smf_topics", + where: t.id_topic == ^id and t.id_board not in ^id_board_blacklist + ) + # get first and last message for thread + |> join(:left, [t], m in "smf_messages", on: t.id_first_msg == m.id_msg) + |> join(:left, [t], m in "smf_messages", on: t.id_last_msg == m.id_msg) + |> select([t, f, l], %{ + id: t.id_topic, + slug: t.id_topic, + board_id: t.id_board, + sticky: t.isSticky, + locked: t.locked, + view_count: t.numViews, + first_post_id: t.id_first_msg, + last_post_id: t.id_last_msg, + started_user_id: t.id_member_started, + updated_user_id: t.id_member_updated, + moderated: t.selfModerated, + post_count: t.numReplies, + title: f.subject, + user_id: f.id_member, + username: f.posterName, + created_at: f.posterTime * 1000, + user_deleted: false, + last_post_created_at: l.posterTime * 1000, + last_post_deleted: false, + last_post_user_id: l.id_member, + last_post_username: l.posterName, + last_post_user_deleted: false, + last_viewed: nil + }) + |> SmfRepo.one() + |> case do + [] -> + {:error, "Thread not found for id: #{id}"} + + thread -> + thread + end + end + + def build_post(id) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + from(m in "smf_messages", + where: m.id_msg == ^id and m.id_board not in ^id_board_blacklist, + select: %{ + id: m.id_msg, + thread_id: m.id_topic, + board_id: m.id_board, + user_id: m.id_member, + title: m.subject, + body: m.body, + updated_at: m.modifiedTime + } + ) + |> SmfRepo.one() + |> case do + [] -> + {:error, "Post not found for id: #{id}"} + + post -> + post + end + end + + def build_posts_by_thread(id, page, per_page) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + count_query = + from m in "smf_messages", + where: m.id_topic == ^id and m.id_board not in ^id_board_blacklist, + select: %{count: count(m.id_topic)} + + from(m in "smf_messages", + limit: ^per_page, + where: m.id_topic == ^id and m.id_board not in ^id_board_blacklist, + order_by: [asc: m.id_msg] + ) + |> join(:left, [m], u in "smf_members", on: m.id_member == u.id_member) + |> join(:left, [m, u], a in "smf_attachments", + on: u.id_member == a.id_member and a.attachmentType == 1 + ) + |> select([m, u, a], %{ + id: m.id_msg, + thread_id: m.id_topic, + board_id: m.id_board, + title: m.subject, + body: m.body, + updated_at: m.modifiedTime, + username: m.posterName, + created_at: m.posterTime * 1000, + modified_time: m.modifiedTime, + avatar: + fragment( + "if(? <>'',concat('https://bitcointalk.org/avatars/',?),ifnull(concat('https://bitcointalk.org/useravatars/',?),''))", + u.avatar, + u.avatar, + a.filename + ), + user: %{ + id: m.id_member, + username: m.posterName, + signature: u.signature, + activity: u.activity, + merit: u.merit, + title: u.usertitle + } + }) + |> ProxyPagination.page_simple(count_query, page, per_page: per_page, desc: true) + |> case do + {:ok, [], _} -> + {:error, "Posts not found for thread_id: #{id}"} + + {:ok, posts, data} -> + return_tuple(posts, data) + end + end + + def build_posts_by_user(id, page, per_page, desc) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + direction = if desc, do: :desc, else: :asc + + count_query = + from u in "smf_members", + where: u.id_member == ^id, + select: %{count: u.posts} + + from(m in "smf_messages", + limit: ^per_page, + where: m.id_member == ^id and m.id_board not in ^id_board_blacklist, + order_by: [{^direction, m.id_msg}] + ) + |> select([m], %{ + id: m.id_msg, + thread_id: m.id_topic, + board_id: m.id_board, + thread_title: m.subject, + thread_slug: m.id_topic, + position: 0, + body_html: m.body, + updated_at: m.modifiedTime, + created_at: m.posterTime * 1000, + user: %{ + id: m.id_member + } + }) + |> ProxyPagination.page_simple(count_query, page, per_page: per_page, desc: desc) + end + + def build_threads_by_user(id, page, per_page, desc) do + %{id_board_blacklist: id_board_blacklist} = + Application.get_env(:epochtalk_server, :proxy_config) + + direction = if desc, do: :desc, else: :asc + + from(t in "smf_topics", + where: t.id_member_started == ^id and t.id_board not in ^id_board_blacklist, + order_by: [{^direction, t.id_topic}] + ) + |> join(:left, [t], f in "smf_messages", on: t.id_first_msg == f.id_msg) + |> join(:left, [t], l in "smf_messages", on: t.id_last_msg == l.id_msg) + |> select([t, f, l], %{ + thread_id: t.id_topic, + thread_slug: t.id_topic, + board_id: t.id_board, + sticky: t.isSticky, + locked: t.locked, + user: %{id: t.id_member_started, deleted: false}, + moderated: t.selfModerated, + post_count: t.numReplies, + thread_title: f.subject, + body: f.body, + created_at: f.posterTime * 1000, + updated_at: l.posterTime * 1000, + board_visible: true, + is_proxy: true + }) + |> ProxyPagination.page_next_prev(page, per_page: per_page, desc: desc) + end + + defp return_tuple(object) do + if length(object) > 1 do + {:ok, object} + else + {:ok, List.first(object)} + end + end + + defp return_tuple(object, data) do + if length(object) > 1 do + {:ok, object, data} + else + {:ok, List.first(object), data} + end + end +end diff --git a/lib/epochtalk_server_web/helpers/proxy_pagination.ex b/lib/epochtalk_server_web/helpers/proxy_pagination.ex new file mode 100644 index 00000000..a2d239ea --- /dev/null +++ b/lib/epochtalk_server_web/helpers/proxy_pagination.ex @@ -0,0 +1,159 @@ +defmodule EpochtalkServerWeb.Helpers.ProxyPagination do + @moduledoc """ + Helper for paginating database queries + """ + import Ecto.Query + alias EpochtalkServer.SmfRepo + alias EpochtalkServerWeb.Helpers.Validate + + @doc """ + Takes in a query, page and per_page option, returns paginated data and relevant + pagination data for frontend (ex. page, limit, next, prev) + + ## Example + iex> import Ecto.Query + iex> alias EpochtalkServer.Models.{ Mention, Invitation } + iex> alias EpochtalkServerWeb.Helpers.Pagination + iex> Mention + ...> |> order_by(asc: :id) + ...> |> Pagination.page_simple(1, per_page: 25, desc: true) + {:ok, [], %{next: false, + page: 1, + per_page: 25, + prev: false, + total_pages: 1, + total_records: 0, + desc: true}} + iex> Invitation + ...> |> order_by(desc: :email) + ...> |> Pagination.page_simple(1, per_page: 10) + {:ok, [], %{next: false, + page: 1, + per_page: 10, + prev: false, + total_pages: 1, + total_records: 0, + desc: true}} + """ + @spec page_simple( + query :: Ecto.Queryable.t(), + count_query :: Ecto.Queryable.t(), + page :: integer | String.t() | nil, + per_page: integer | String.t() | nil, + desc: boolean + ) :: {:ok, list :: [term()] | [], pagination_data :: map()} | {:error, data :: any()} + def page_simple(query, count_query, nil, per_page: nil, desc: desc), + do: page_simple(query, count_query, 1, per_page: 15, desc: desc) + + def page_simple(query, count_query, page, per_page: nil, desc: desc) when is_integer(page), + do: page_simple(query, count_query, page, per_page: 15, desc: desc) + + def page_simple(query, count_query, nil, per_page: per_page, desc: desc) + when is_integer(per_page), + do: page_simple(query, count_query, 1, per_page: per_page, desc: desc) + + def page_simple(query, count_query, nil, per_page: per_page, desc: desc) + when is_binary(per_page), + do: + page_simple(query, count_query, 1, + per_page: Validate.cast_str(per_page, :integer, key: "limit", min: 1), + desc: desc + ) + + def page_simple(query, count_query, page, per_page: nil, desc: desc) when is_binary(page), + do: + page_simple(query, count_query, Validate.cast_str(page, :integer, key: "page", min: 1), + per_page: 15, + desc: desc + ) + + def page_simple(query, count_query, page, per_page: per_page, desc: desc) + when is_binary(page) and is_binary(per_page), + do: + page_simple(query, count_query, Validate.cast_str(page, :integer, key: "page", min: 1), + per_page: Validate.cast_str(per_page, :integer, key: "limit", min: 1), + desc: desc + ) + + def page_simple(query, count_query, page, per_page: per_page, desc: desc) do + options = [prefix: "public"] + + total_records = + Keyword.get_lazy(options, :total_records, fn -> + total_records(count_query) + end) + + total_pages = total_pages(total_records, per_page) + + result = records(query, page, total_pages, per_page) + + pagination_data = %{ + next: page < total_pages, + prev: page > 1, + page: page, + per_page: per_page, + total_records: total_records, + total_pages: total_pages, + desc: desc + } + + {:ok, result, pagination_data} + end + + def page_next_prev(query, page, per_page: per_page, desc: desc) do + # query one more page to calculate if next page exists + result = records(query, page, nil, per_page + 1) + + next = length(result) > per_page + + pagination_data = %{ + next: next, + prev: page > 1, + page: page, + per_page: per_page, + desc: desc + } + + # remove extra element + result = + if next, + do: result |> Enum.reverse() |> tl() |> Enum.reverse(), + else: result + + {:ok, result, pagination_data} + end + + defp records(query, page, total_pages, per_page) when is_nil(total_pages) do + query + |> limit(^per_page) + |> offset(^(per_page * (page - 1))) + |> SmfRepo.all() + end + + defp records(_, page, total_pages, _) when page > total_pages, do: [] + + defp records(query, page, _, per_page) do + query + |> limit(^per_page) + |> offset(^(per_page * (page - 1))) + |> SmfRepo.all() + end + + defp total_records(query) do + total_records = + query + |> exclude(:preload) + |> exclude(:order_by) + |> SmfRepo.one() + + total_records[:count] || 0 + end + + defp total_pages(0, _), do: 1 + + defp total_pages(total_records, per_page) do + (total_records / per_page) + |> Float.ceil() + |> round + end +end diff --git a/lib/epochtalk_server_web/json/board_json.ex b/lib/epochtalk_server_web/json/board_json.ex index 9f626a1f..b92f52d9 100644 --- a/lib/epochtalk_server_web/json/board_json.ex +++ b/lib/epochtalk_server_web/json/board_json.ex @@ -8,6 +8,35 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do """ def slug_to_id(%{id: id}), do: %{id: id} + @doc """ + Renders proxy version of `Board` data by `Category`. + """ + def proxy_by_category(%{ + categories: categories, + board_moderators: board_moderators, + board_mapping: board_mapping, + user_priority: user_priority, + board_counts: board_counts, + board_last_post_info: board_last_post_info + }) do + board_counts = map_to_id(board_counts) + board_last_post_info = map_to_id(board_last_post_info) + + data = + by_category(%{ + categories: categories, + board_moderators: board_moderators, + board_mapping: board_mapping, + user_priority: user_priority, + board_counts: board_counts, + board_last_post_info: board_last_post_info + }) + + data + end + + defp map_to_id(data), do: Enum.reduce(data, %{}, &(&2 |> Map.put(&1.id, Map.delete(&1, :id)))) + @doc """ Renders `Board` data by `Category`. """ @@ -15,7 +44,10 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do categories: categories, board_moderators: board_moderators, board_mapping: board_mapping, - user_priority: user_priority + user_priority: user_priority, + # board counts and last post info for proxy version + board_counts: board_counts, + board_last_post_info: board_last_post_info }) do # append board moderators to each board in board mapping board_mapping = @@ -45,7 +77,10 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do board_mapping, :boards, category, - user_priority + user_priority, + # board counts and last post info for proxy version + board_counts, + board_last_post_info ) acc ++ [category] @@ -55,6 +90,22 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do %{boards: categories} end + def by_category(%{ + categories: categories, + board_moderators: board_moderators, + board_mapping: board_mapping, + user_priority: user_priority + }) do + by_category(%{ + categories: categories, + board_moderators: board_moderators, + board_mapping: board_mapping, + user_priority: user_priority, + board_counts: nil, + board_last_post_info: nil + }) + end + @doc """ Renders `Board` for find query. """ @@ -72,6 +123,76 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do """ def movelist(%{movelist: movelist}), do: movelist + @doc """ + Proxy version + Board view helper method for mapping childboards and other metadata to board using board mapping and user priority + """ + def proxy_format_board_data_for_find( + board_moderators, + board_mapping, + board_id, + user_priority, + board_counts \\ nil, + board_last_post_info \\ nil + ) do + board_counts = map_to_id(board_counts) + board_last_post_info = map_to_id(board_last_post_info) + # filter out board by id + [board] = Enum.filter(board_mapping, fn bm -> bm.board_id == board_id end) + + # append board moderators to board + moderators = + board_moderators + |> Enum.filter(fn mod -> Map.get(mod, :board_id) == Map.get(board, :board_id) end) + |> Enum.map(fn mod -> %{id: mod.user_id, username: mod.user.username} end) + + board = Map.put(board, :moderators, moderators) + + # flatten needed boards data + board = + board + |> Map.merge(remove_nil(board.board)) + |> Map.merge( + remove_nil(board.stats) + |> Map.delete(:id) + ) + |> Map.merge(board.thread) + |> Map.merge(board.board.meta || board.stats) + |> Map.delete(:meta) + + # delete unneeded properties + board = + board + |> Map.delete(:board) + |> Map.delete(:stats) + |> Map.delete(:thread) + |> Map.delete(:parent) + |> Map.delete(:category) + |> Map.delete(:__meta__) + |> Map.delete(:__struct__) + + # handle deleted last post data + if !!Map.get(board, :post_deleted) or !!Map.get(board, :user_deleted), + do: board |> Map.put(:last_post_username, "deleted"), + else: board + + # iterate each child board, attempt to map nested children from board mapping + board = + process_children_from_board_mapping( + :parent_id, + board_mapping, + :children, + board, + user_priority, + # board counts and last post info for proxy version + board_counts, + board_last_post_info + ) + + # return flattened board data with children, mod and last post data + board + end + @doc """ Board view helper method for mapping childboards and other metadata to board using board mapping and user priority """ @@ -134,7 +255,10 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do board_mapping, child_key, parent, - user_priority + user_priority, + # board counts and last post info for proxy version + board_counts \\ nil, + board_last_post_info \\ nil ) do # get id of parent object could be board or category parent_id = if is_integer(Map.get(parent, :board_id)), do: parent.board_id, else: parent.id @@ -161,6 +285,18 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do |> Map.merge(remove_nil(board.stats)) |> Map.merge(board.thread) + # add board counts for proxy version + board = + if board_counts != nil, + do: Map.merge(board, board_counts[board.id]), + else: board + + # add board last post info for proxy version + board = + if board_last_post_info != nil, + do: Map.merge(board, board_last_post_info[board.id]), + else: board + # delete unneeded properties board = board @@ -190,7 +326,9 @@ defmodule EpochtalkServerWeb.Controllers.BoardJSON do board_mapping, :children, board, - user_priority + user_priority, + board_counts, + board_last_post_info ) acc ++ [board] diff --git a/lib/epochtalk_server_web/json/post_json.ex b/lib/epochtalk_server_web/json/post_json.ex index 1b3f3794..99611f13 100644 --- a/lib/epochtalk_server_web/json/post_json.ex +++ b/lib/epochtalk_server_web/json/post_json.ex @@ -39,6 +39,20 @@ defmodule EpochtalkServerWeb.Controllers.PostJSON do %{parsed_body: parsed_body} end + @doc """ + Renders `Post` data for parsed legacy `Post` data + + ## Example + iex> parsed_body = %{parsed_body: "Hello World
"} + iex> EpochtalkServerWeb.Controllers.PostJSON.preview(parsed_body) + parsed_body + """ + def parse_legacy(%{ + parsed_body: parsed_body + }) do + %{parsed_body: parsed_body} + end + @doc """ Renders all `Post` for a particular `Thread`. """ @@ -99,6 +113,45 @@ defmodule EpochtalkServerWeb.Controllers.PostJSON do } end + def by_thread_proxy(%{ + posts: posts, + poll: poll, + thread: thread, + user_priority: user_priority, + board_mapping: board_mapping, + board_moderators: board_moderators, + page: page, + limit: limit + }) do + # format board data + board = + BoardJSON.format_board_data_for_find( + board_moderators, + board_mapping, + thread.board_id, + user_priority + ) + + thread = Map.put(thread, :poll, poll) + + # convert singular post to list + posts = if is_map(posts), do: [posts], else: posts + + # format post data + posts = + posts + |> Enum.map(&format_proxy_post_data_for_by_thread(&1)) + + # build by_thread results + %{ + posts: posts, + thread: thread, + board: board, + page: page, + limit: limit + } + end + @doc """ Renders all `Post` for a particular `User`. """ @@ -126,6 +179,46 @@ defmodule EpochtalkServerWeb.Controllers.PostJSON do } end + @doc """ + Renders all `Post` for a particular `User`. + """ + def proxy_by_username(%{ + posts: posts, + count: count, + limit: limit, + page: page, + desc: desc + }) + when is_list(posts) do + posts = + posts + |> Enum.map(&format_proxy_post_data_for_by_thread(&1)) + + %{ + posts: posts, + count: count, + limit: limit, + page: page, + desc: desc + } + end + + def proxy_by_username(%{ + posts: posts, + count: count, + limit: limit, + page: page, + desc: desc + }), + do: + proxy_by_username(%{ + posts: [posts], + count: count, + limit: limit, + page: page, + desc: desc + }) + ## === Public Helper Functions === def handle_deleted_posts(posts, thread, user, authed_user_priority, view_deleted_posts) do @@ -350,4 +443,28 @@ defmodule EpochtalkServerWeb.Controllers.PostJSON do |> Map.delete(:highlight_color) |> Map.delete(:role_name) end + + defp format_proxy_post_data_for_by_thread(post) do + body = String.replace(Map.get(post, :body) || Map.get(post, :body_html), "'", "\'") + + # add space to end if the last character is a backslash (fix for parser) + body_len = String.length(body) + last_char = String.slice(body, (body_len - 1)..body_len) + body = if last_char == "\\", do: body <> " ", else: body + + parsed_body = EpochtalkServer.BBCParser.parse(body) + + signature = + if Map.get(post.user, :signature), + do: String.replace(post.user.signature, "'", "\'"), + else: nil + + parsed_signature = + if signature, + do: EpochtalkServer.BBCParser.parse(signature), + else: nil + + user = post.user |> Map.put(:signature, parsed_signature) + post |> Map.put(:body_html, parsed_body) |> Map.put(:user, user) + end end diff --git a/lib/epochtalk_server_web/json/thread_json.ex b/lib/epochtalk_server_web/json/thread_json.ex index 76241ee5..c8bf425b 100644 --- a/lib/epochtalk_server_web/json/thread_json.ex +++ b/lib/epochtalk_server_web/json/thread_json.ex @@ -48,7 +48,7 @@ defmodule EpochtalkServerWeb.Controllers.ThreadJSON do deleted: t.last_post_user_deleted } ), - lastest: + latest: if(t.new_post_id || t.new_post_position, do: %{id: t.new_post_id, position: t.new_post_position || 1} ) @@ -128,6 +128,43 @@ defmodule EpochtalkServerWeb.Controllers.ThreadJSON do if board_banned, do: Map.put(result, :board_banned, board_banned), else: result end + def by_board_proxy(%{ + threads: threads, + user: user, + user_priority: user_priority, + board_id: board_id, + board_mapping: board_mapping, + board_moderators: board_moderators, + page: page, + limit: limit, + board_counts: board_counts, + board_last_post_info: board_last_post_info + }) do + # format board data + board = + BoardJSON.proxy_format_board_data_for_find( + board_moderators, + board_mapping, + board_id, + user_priority, + board_counts, + board_last_post_info + ) + + # format thread data + user_id = if is_nil(user), do: nil, else: user.id + normal = threads |> Enum.map(&format_thread_data(&1, user_id)) + # sticky = threads.sticky |> Enum.map(&format_thread_data(&1, user_id)) + + # build by_board results + %{ + normal: normal, + board: board, + page: page, + limit: limit + } + end + @doc """ Renders paged `Threads` for a particular `User`. """ @@ -162,6 +199,27 @@ defmodule EpochtalkServerWeb.Controllers.ThreadJSON do } end + @doc """ + Renders all `Post` for a particular `User`. + """ + def proxy_by_username(%{ + threads: threads, + next: next, + prev: prev, + limit: limit, + page: page, + desc: desc + }) do + %{ + posts: threads, + next: next, + prev: prev, + limit: limit, + page: page, + desc: desc + } + end + @doc """ Renders sticky `Thread`. @@ -308,6 +366,5 @@ defmodule EpochtalkServerWeb.Controllers.ThreadJSON do thread |> Map.delete(:last_post_deleted) |> Map.delete(:last_post_user_deleted) - |> Map.delete(:last_post_user_id) end end diff --git a/lib/epochtalk_server_web/json/user_json.ex b/lib/epochtalk_server_web/json/user_json.ex index 139d313a..3194528c 100644 --- a/lib/epochtalk_server_web/json/user_json.ex +++ b/lib/epochtalk_server_web/json/user_json.ex @@ -13,6 +13,48 @@ defmodule EpochtalkServerWeb.Controllers.UserJSON do """ def user(%{user: user, token: token}), do: format_user_reply(user, token) + @doc """ + Renders formatted user JSON for find proxy + """ + def find_proxy(%{user: user}) do + parsed_signature = + if user.signature, + do: EpochtalkServer.BBCParser.parse(user.signature), + else: nil + + gender = + case Map.get(user, :gender) do + 1 -> "Male" + 2 -> "Female" + _ -> nil + end + + dob = + case d = Map.get(user, :dob) do + ~D[0001-01-01] -> nil + _ -> d + end + + last_active = calculate_last_active(user) + + position = user.group_name || user.group_name_2 + position_color = user.group_color || user.group_color_2 + + user + |> Map.put(:signature, parsed_signature) + |> Map.put(:gender, gender) + |> Map.put(:dob, dob) + |> Map.put(:last_active, last_active) + |> Map.put(:position, position) + |> Map.put(:position_color, position_color) + |> Map.delete(:last_login) + |> Map.delete(:show_online) + |> Map.delete(:group_name) + |> Map.delete(:group_name_2) + |> Map.delete(:group_color) + |> Map.delete(:group_color_2) + end + @doc """ Renders formatted user JSON for find """ @@ -142,4 +184,11 @@ defmodule EpochtalkServerWeb.Controllers.UserJSON do reply = if malicious_score, do: Map.put(reply, :malicious_score, malicious_score), else: reply reply end + + defp calculate_last_active(user) when is_map(user) do + {:ok, last_login} = DateTime.from_unix(user.last_login, :millisecond) + last_login_past_72_hours = DateTime.diff(DateTime.utc_now(), last_login, :hour) > 72 + + if user.show_online == 1 or last_login_past_72_hours, do: user.last_login + end end diff --git a/mix.exs b/mix.exs index 75db8d3e..de965665 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule EpochtalkServer.MixProject do [ app: :epochtalk_server, version: "0.1.0", - elixir: "~> 1.12", + elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), start_permanent: Mix.env() == :prod, @@ -55,14 +55,18 @@ defmodule EpochtalkServer.MixProject do {:hackney, "~> 1.9"}, {:hammer, "~> 6.2"}, {:hammer_backend_redis, "~> 6.1"}, + {:html_entities, "~> 0.5.2", only: [:dev, :test]}, {:html_sanitize_ex, "~> 1.4"}, {:iteraptor, git: "https://github.com/epochtalk/elixir-iteraptor.git", tag: "1.13.1"}, {:jason, "~> 1.4.0"}, {:mimic, "~> 1.7.4", only: :test}, + {:myxql, "~> 0.6.3"}, {:phoenix, "~> 1.7.2"}, {:phoenix_ecto, "~> 4.4"}, {:phoenix_html, "~> 3.0"}, {:plug_cowboy, "~> 2.5"}, + {:poolboy, "~> 1.5.1"}, + {:porcelain, "~> 2.0"}, {:poison, "~> 3.0"}, {:postgrex, "~> 0.17.1"}, {:redix, "~> 1.2.2"}, diff --git a/mix.lock b/mix.lock index 084b21dc..2b25c9c3 100644 --- a/mix.lock +++ b/mix.lock @@ -35,8 +35,9 @@ "guardian_redis": {:hex, :guardian_redis, "0.1.0", "705f8291346d813755a701e0241acaa845453d662aca675f1de8f0821554a6f9", [:mix], [{:guardian_db, "~> 2.0", [hex: :guardian_db, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "8058f6613f888a2f10836f6b669be3f21c7d303955862b638fc3c1bafaf66bef"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, - "hammer_backend_redis": {:hex, :hammer_backend_redis, "6.1.2", "eb296bb4924928e24135308b2afc189201fd09411c870c6bbadea444a49b2f2c", [:mix], [{:hammer, "~> 6.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "217ea066278910543a5e9b577d5bf2425419446b94fe76bdd9f255f39feec9fa"}, + "hammer_backend_redis": {:hex, :hammer_backend_redis, "6.2.0", "f39a9c8491387cdf719a38593311537e3e0251ca54725b6ee9145406821f39d2", [:mix], [{:hammer, "~> 6.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "9965d55705d7ca7412bb0685f5cd44fc47d103bf388abc50438e71974c36c9fa"}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "iteraptor": {:git, "https://github.com/epochtalk/elixir-iteraptor.git", "d8d1c386c38e06bdfcf60c9ce1abf8e49161cab4", [tag: "1.13.1"]}, @@ -51,11 +52,12 @@ "mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "mochiweb": {:hex, :mochiweb, "3.2.2", "bb435384b3b9fd1f92f2f3fe652ea644432877a3e8a81ed6459ce951e0482ad3", [:rebar3], [], "hexpm", "4114e51f1b44c270b3242d91294fe174ce1ed989100e8b65a1fab58e0cba41d5"}, + "myxql": {:hex, :myxql, "0.6.4", "1502ea37ee23c31b79725b95d4cc3553693c2bda7421b1febc50722fd988c918", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a3307f4671f3009d3708283649adf205bfe280f7e036fc8ef7f16dbf821ab8e9"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "phoenix": {:hex, :phoenix, "1.7.15", "e3021805b8d8d7eefff8e15c8e595eb15461ca8571a84b38c14e7940eb71a2a8", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c6905e200918284575fe8b4aecd39928f87bb1272f5e57310dfdd6677a575fed"}, + "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, @@ -65,6 +67,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "redix": {:hex, :redix, "1.2.4", "8d980da0800262d5e8dd718af09d549bd788b1b08651d03cbc0854f5fb35f0e6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fb6d7327b0d4190e88e35cf68551130b2c24f6662ccc08007bee64cbd312e1c5"}, diff --git a/parse_by_thread.exs b/parse_by_thread.exs new file mode 100644 index 00000000..2170bee7 --- /dev/null +++ b/parse_by_thread.exs @@ -0,0 +1,32 @@ +Mix.install([{:httpoison, "~> 2.0"}, {:logger_file_backend, "~> 0.0.10"}]) + +defmodule ParseByThread do + require Logger + + Logger.add_backend({LoggerFileBackend, :error}) + + Logger.configure_backend({LoggerFileBackend, :error}, + path: "./parse_by_thread_error.log", + level: :error + ) + + @start_id 5_485_059 + @end_id 1 + + def run do + for id <- @start_id..@end_id//-1 do + case HTTPoison.get("http://localhost:4000/api/posts?thread_id=#{id}") do + {:ok, %HTTPoison.Response{status_code: 200, body: _body}} -> + Logger.info("Successfully parsed thread with id (#{id})") + + {:ok, %HTTPoison.Response{status_code: status_code}} -> + Logger.error("Thread with id (#{id}) received response with status code #{status_code}") + + {:error, %HTTPoison.Error{reason: reason}} -> + Logger.error("Thread with id (#{id}) HTTP request failed: #{reason}") + end + end + end +end + +ParseByThread.run() diff --git a/parsing.php b/parsing.php new file mode 100644 index 00000000..99bb44df --- /dev/null +++ b/parsing.php @@ -0,0 +1,1822 @@ + 29 && strlen($message)>500 && php_sapi_name() != 'cli') { + die(); + } + + // Never show smileys for wireless clients. More bytes, can't see it anyway :P. + if (WIRELESS) + $smileys = false; + elseif ($smileys !== null && ($smileys == '1' || $smileys == '0')) + $smileys = (bool) $smileys; + + if (empty($modSettings['enableBBC']) && $message !== false) + { + if ($smileys === true) + parsesmileys($message); + + return $message; + } + + // Just in case it wasn't determined yet whether UTF-8 is enabled. + if (!isset($context['utf8'])) + $context['utf8'] = (empty($modSettings['global_character_set']) ? $txt['lang_character_set'] : $modSettings['global_character_set']) === 'UTF-8'; + + //theymos - disable links and images on pages where we don't want to send a referer to random people + $disabledsecurity=''; + if(isset($_GET['sesc'])) { + $cache_id = ''; + $disabled['img']=true; + $disabled['iurl']=true; + $disabled['url']=true; + $disabled['ftp']=true; + $disabledsecurity=' (FORUM: disabled on this page for security.)'; + } + if(isset($_GET['patrol'])) { + $cache_id = ''; + $disabled['black']=true; + $disabled['color']=true; + } + + //theymos - these tags are aways disabled + $disabled['flash'] = true; + $disabled['move'] = true; + + // Sift out the bbc for a performance improvement. + if (empty($bbc_codes) || $message === false) + { + /*if (!empty($modSettings['disabledBBC'])) + { + $temp = explode(',', strtolower($modSettings['disabledBBC'])); + + foreach ($temp as $tag) + $disabled[trim($tag)] = true; + } + + if (empty($modSettings['enableEmbeddedFlash'])) + $disabled['flash'] = true;*/ + + /* The following bbc are formatted as an array, with keys as follows: + + tag: the tag's name - should be lowercase! + + type: one of... + - (missing): [tag]parsed content[/tag] + - unparsed_equals: [tag=xyz]parsed content[/tag] + - parsed_equals: [tag=parsed data]parsed content[/tag] + - unparsed_content: [tag]unparsed content[/tag] + - closed: [tag], [tag/], [tag /] + - unparsed_commas: [tag=1,2,3]parsed content[/tag] + - unparsed_commas_content: [tag=1,2,3]unparsed content[/tag] + - unparsed_equals_content: [tag=...]unparsed content[/tag] + + parameters: an optional array of parameters, for the form + [tag abc=123]content[/tag]. The array is an associative array + where the keys are the parameter names, and the values are an + array which may contain the following: + - match: a regular expression to validate and match the value. + - quoted: true if the value should be quoted. + - validate: callback to evaluate on the data, which is $data. + - value: a string in which to replace $1 with the data. + either it or validate may be used, not both. + - optional: true if the parameter is optional. + + test: a regular expression to test immediately after the tag's + '=', ' ' or ']'. Typically, should have a \] at the end. + Optional. + + content: only available for unparsed_content, closed, + unparsed_commas_content, and unparsed_equals_content. + $1 is replaced with the content of the tag. Parameters + are repalced in the form {param}. For unparsed_commas_content, + $2, $3, ..., $n are replaced. + + before: only when content is not used, to go before any + content. For unparsed_equals, $1 is replaced with the value. + For unparsed_commas, $1, $2, ..., $n are replaced. + + after: similar to before in every way, except that it is used + when the tag is closed. + + disabled_content: used in place of content when the tag is + disabled. For closed, default is '', otherwise it is '$1' if + block_level is false, '
$1' : '$1') . '
\t", "\t", implode('', $php_parts)); + + // Older browsers are annoying, aren't they? + if ($context['browser']['is_ie4'] || $context['browser']['is_ie5'] || $context['browser']['is_ie5.5']) + $data = str_replace("\t", "
\t", $data); + elseif (!$context['browser']['is_gecko']) + $data = str_replace("\t", "\t", $data); + }}, + 'block_level' => true, + ), + array( + 'tag' => 'code', + 'type' => 'unparsed_equals_content', + 'content' => '
$1' : '$1') . '
\t", "\t", implode('', $php_parts)); + + // Older browsers are annoying, aren't they? + if ($context['browser']['is_ie4'] || $context['browser']['is_ie5'] || $context['browser']['is_ie5.5']) + $data = str_replace("\t", "
\t", $data); + elseif (!$context['browser']['is_gecko']) + $data = str_replace("\t", "\t", $data); + }}, + 'block_level' => true, + ), + array( + 'tag' => 'center', + 'before' => '
' : '', + 'after' => $context['browser']['is_ie'] ? ' |
', + 'after' => '', + ), + array( + 'tag' => 'php', + 'type' => 'unparsed_content', + 'content' => '
\t", $buffer); + + return strtr($buffer, array('\'' => ''', '
' => '', '
' => ''));
+}
+function un_htmlspecialchars($string)
+{
+ return strtr($string, array_flip(get_html_translation_table(HTML_SPECIALCHARS, ENT_QUOTES)) + array(''' => '\'', ' ' => ' '));
+}
+
+// Format a time to make it look purdy.
+function timeformat($logTime, $show_today = true)
+{
+ global $user_info, $txt, $db_prefix, $modSettings, $func;
+
+ // Offset the time.
+ $time = $logTime + ($user_info['time_offset'] + $modSettings['time_offset']) * 3600;
+
+ // We can't have a negative date (on Windows, at least.)
+ if ($time < 0)
+ $time = 0;
+
+ // Today and Yesterday?
+ if ($modSettings['todayMod'] >= 1 && $show_today === true)
+ {
+ // Get the current time.
+ $nowtime = forum_time();
+
+ $then = @getdate($time);
+ $now = @getdate($nowtime);
+ if(!$then)
+ $then = @getdate(0);
+ if(!$now)
+ $now = @getdate(0);
+
+ // Try to make something of a time format string...
+ $s = strpos($user_info['time_format'], '%S') === false ? '' : ':%S';
+ if (strpos($user_info['time_format'], '%H') === false && strpos($user_info['time_format'], '%T') === false)
+ $today_fmt = '%I:%M' . $s . ' %p';
+ else
+ $today_fmt = '%H:%M' . $s;
+
+ // Same day of the year, same year.... Today!
+ if ($then['yday'] == $now['yday'] && $then['year'] == $now['year'])
+ return $txt['smf10'] . timeformat($logTime, $today_fmt);
+
+ // Day-of-year is one less and same year, or it's the first of the year and that's the last of the year...
+ if ($modSettings['todayMod'] == '2' && (($then['yday'] == $now['yday'] - 1 && $then['year'] == $now['year']) || ($now['yday'] == 0 && $then['year'] == $now['year'] - 1) && $then['mon'] == 12 && $then['mday'] == 31))
+ return $txt['smf10b'] . timeformat($logTime, $today_fmt);
+ }
+
+ $str = !is_bool($show_today) ? $show_today : $user_info['time_format'];
+
+ if (setlocale(LC_TIME, $txt['lang_locale']))
+ {
+ foreach (array('%a', '%A', '%b', '%B') as $token)
+ if (strpos($str, $token) !== false)
+ $str = str_replace($token, $func['ucwords'](strftime_updated($token, (int)$time)), $str);
+ }
+ else
+ {
+ // Do-it-yourself time localization. Fun.
+ foreach (array('%a' => 'days_short', '%A' => 'days', '%b' => 'months_short', '%B' => 'months') as $token => $text_label)
+ if (strpos($str, $token) !== false)
+ $str = str_replace($token, $txt[$text_label][(int) strftime_updated($token === '%a' || $token === '%A' ? '%w' : '%m', $time)], $str);
+ if (strpos($str, '%p'))
+ $str = str_replace('%p', (strftime_updated('%H', $time) < 12 ? 'am' : 'pm'), $str);
+ }
+
+ // Format any other characters..
+ return strftime_updated($str, (int)$time);
+}
+
+function forum_time($use_user_offset = true, $timestamp = null)
+{
+ global $user_info, $modSettings;
+
+ if ($timestamp === null)
+ $timestamp = time();
+ elseif ($timestamp == 0)
+ return 0;
+
+ return $timestamp + ($modSettings['time_offset'] + ($use_user_offset ? $user_info['time_offset'] : 0)) * 3600;
+}
+
+function safe_serialize($value)
+{
+ // Make sure we use the byte count for strings even when strlen() is overloaded by mb_strlen()
+ if (function_exists('mb_internal_encoding') &&
+ (((int) ini_get('mbstring.func_overload')) & 2))
+ {
+ $mbIntEnc = mb_internal_encoding();
+ mb_internal_encoding('ASCII');
+ }
+
+ $out = _safe_serialize($value);
+
+ if (isset($mbIntEnc))
+ mb_internal_encoding($mbIntEnc);
+
+ return $out;
+}
+function _safe_serialize($value)
+{
+ if(is_null($value))
+ return 'N;';
+
+ if(is_bool($value))
+ return 'b:'. (int) $value .';';
+
+ if(is_int($value))
+ return 'i:'. $value .';';
+
+ if(is_float($value))
+ return 'd:'. str_replace(',', '.', $value) .';';
+
+ if(is_string($value))
+ return 's:'. strlen($value) .':"'. $value .'";';
+
+ if(is_array($value))
+ {
+ $out = '';
+ foreach($value as $k => $v)
+ $out .= _safe_serialize($k) . _safe_serialize($v);
+
+ return 'a:'. count($value) .':{'. $out .'}';
+ }
+
+ // safe_serialize cannot serialize resources or objects.
+ return false;
+}
+
+// This gets all possible permutations of an array.
+function permute($array)
+{
+ $orders = array($array);
+
+ $n = count($array);
+ $p = range(0, $n);
+ for ($i = 1; $i < $n; null)
+ {
+ $p[$i]--;
+ $j = $i % 2 != 0 ? $p[$i] : 0;
+
+ $temp = $array[$i];
+ $array[$i] = $array[$j];
+ $array[$j] = $temp;
+
+ for ($i = 1; $p[$i] == 0; $i++)
+ $p[$i] = 1;
+
+ $orders[] = $array;
+ }
+
+ return $orders;
+}
+// Fix any URLs posted - ie. remove 'javascript:'.
+function fixTags(&$message)
+{
+ global $modSettings;
+
+ // WARNING: Editing the below can cause large security holes in your forum.
+ // Edit only if you are sure you know what you are doing.
+
+ $fixArray = array(
+ // [img]http://...[/img] or [img width=1]http://...[/img]
+ array(
+ 'tag' => 'img',
+ 'protocols' => array('http', 'https'),
+ 'embeddedUrl' => false,
+ 'hasEqualSign' => false,
+ 'hasExtra' => true,
+ ),
+ // [url]http://...[/url]
+ array(
+ 'tag' => 'url',
+ 'protocols' => array('http', 'https', 'bitcoin:', 'magnet:'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => false,
+ ),
+ // [url=http://...]name[/url]
+ array(
+ 'tag' => 'url',
+ 'protocols' => array('http', 'https', 'bitcoin:', 'magnet:'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => true,
+ ),
+ // [iurl]http://...[/iurl]
+ array(
+ 'tag' => 'iurl',
+ 'protocols' => array('http', 'https', 'bitcoin:', 'magnet:'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => false,
+ ),
+ // [iurl=http://...]name[/iurl]
+ array(
+ 'tag' => 'iurl',
+ 'protocols' => array('http', 'https', 'bitcoin:', 'magnet:'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => true,
+ ),
+ // [ftp]ftp://...[/ftp]
+ array(
+ 'tag' => 'ftp',
+ 'protocols' => array('ftp', 'ftps'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => false,
+ ),
+ // [ftp=ftp://...]name[/ftp]
+ array(
+ 'tag' => 'ftp',
+ 'protocols' => array('ftp', 'ftps'),
+ 'embeddedUrl' => true,
+ 'hasEqualSign' => true,
+ ),
+ // [flash]http://...[/flash]
+ array(
+ 'tag' => 'flash',
+ 'protocols' => array('http', 'https'),
+ 'embeddedUrl' => false,
+ 'hasEqualSign' => false,
+ 'hasExtra' => true,
+ ),
+ );
+
+ // Fix each type of tag.
+ foreach ($fixArray as $param)
+ fixTag($message, $param['tag'], $param['protocols'], $param['embeddedUrl'], $param['hasEqualSign'], !empty($param['hasExtra']));
+
+ // Now fix possible security problems with images loading links automatically...
+ $message = preg_replace_callback('~(\[img.*?\])(.+?)\[/img\]~is', 'action_fix__preg_callback', $message);
+
+ // Limit the size of images posted?
+ if (!empty($modSettings['max_image_width']) || !empty($modSettings['max_image_height']))
+ {
+ // Find all the img tags - with or without width and height.
+ preg_match_all('~\[img(\s+width=\d+)?(\s+height=\d+)?(\s+width=\d+)?\](.+?)\[/img\]~is', $message, $matches, PREG_PATTERN_ORDER);
+
+ $replaces = array();
+ foreach ($matches[0] as $match => $dummy)
+ {
+ // If the width was after the height, handle it.
+ $matches[1][$match] = !empty($matches[3][$match]) ? $matches[3][$match] : $matches[1][$match];
+
+ // Now figure out if they had a desired height or width...
+ $desired_width = !empty($matches[1][$match]) ? (int) substr(trim($matches[1][$match]), 6) : 0;
+ $desired_height = !empty($matches[2][$match]) ? (int) substr(trim($matches[2][$match]), 7) : 0;
+
+ // One was omitted, or both. We'll have to find its real size...
+ if (empty($desired_width) || empty($desired_height))
+ {
+ list ($width, $height) = url_image_size(un_htmlspecialchars($matches[4][$match])); //this is dead code, don't worry about url_image_size
+
+ // They don't have any desired width or height!
+ if (empty($desired_width) && empty($desired_height))
+ {
+ $desired_width = $width;
+ $desired_height = $height;
+ }
+ // Scale it to the width...
+ elseif (empty($desired_width) && !empty($height))
+ $desired_width = (int) (($desired_height * $width) / $height);
+ // Scale if to the height.
+ elseif (!empty($width))
+ $desired_height = (int) (($desired_width * $height) / $width);
+ }
+
+ // If the width and height are fine, just continue along...
+ if ($desired_width <= $modSettings['max_image_width'] && $desired_height <= $modSettings['max_image_height'])
+ continue;
+
+ // Too bad, it's too wide. Make it as wide as the maximum.
+ if ($desired_width > $modSettings['max_image_width'] && !empty($modSettings['max_image_width']))
+ {
+ $desired_height = (int) (($modSettings['max_image_width'] * $desired_height) / $desired_width);
+ $desired_width = $modSettings['max_image_width'];
+ }
+
+ // Now check the height, as well. Might have to scale twice, even...
+ if ($desired_height > $modSettings['max_image_height'] && !empty($modSettings['max_image_height']))
+ {
+ $desired_width = (int) (($modSettings['max_image_height'] * $desired_width) / $desired_height);
+ $desired_height = $modSettings['max_image_height'];
+ }
+
+ $replaces[$matches[0][$match]] = '[img' . (!empty($desired_width) ? ' width=' . $desired_width : '') . (!empty($desired_height) ? ' height=' . $desired_height : '') . ']' . $matches[4][$match] . '[/img]';
+ }
+
+ // If any img tags were actually changed...
+ if (!empty($replaces))
+ $message = strtr($message, $replaces);
+ }
+}
+
+function action_fix__preg_callback($matches)
+{
+ return $matches[1] . preg_replace('~action(=|%3d)(?!dlattach)~i', 'action-', $matches[2]) . '[/img]';
+}
+
+// Fix a specific class of tag - ie. url with =.
+function fixTag(&$message, $myTag, $protocols, $embeddedUrl = false, $hasEqualSign = false, $hasExtra = false)
+{
+ global $boardurl, $scripturl;
+
+ if (preg_match('~^([^:]+://[^/]+)~', $boardurl, $match) != 0)
+ $domain_url = $match[1];
+ else
+ $domain_url = $boardurl . '/';
+
+ $replaces = array();
+
+ if ($hasEqualSign)
+ preg_match_all('~\[(' . $myTag . ')=([^\]]*?)\](?:(.+?)\[/(' . $myTag . ')\])?~is', $message, $matches);
+ else
+ preg_match_all('~\[(' . $myTag . ($hasExtra ? '(?:[^\]]*?)' : '') . ')\](.+?)\[/(' . $myTag . ')\]~is', $message, $matches);
+
+ foreach ($matches[0] as $k => $dummy)
+ {
+ // Remove all leading and trailing whitespace.
+ $replace = trim($matches[2][$k]);
+ $this_tag = $matches[1][$k];
+ $this_close = $hasEqualSign ? (empty($matches[4][$k]) ? '' : $matches[4][$k]) : $matches[3][$k];
+
+ $found = false;
+ foreach ($protocols as $protocol)
+ {
+ if (strpos($protocol, ':') === false)
+ $found = strncasecmp($replace, $protocol . '://', strlen($protocol) + 3) === 0;
+ else
+ $found = strncasecmp($replace, $protocol, strlen($protocol)) === 0;
+ if ($found)
+ break;
+ }
+
+ if (!$found && $protocols[0] == 'http')
+ {
+ if (substr($replace, 0, 1) == '/')
+ $replace = $domain_url . $replace;
+ elseif (substr($replace, 0, 1) == '?')
+ $replace = $scripturl . $replace;
+ elseif (substr($replace, 0, 1) == '#' && $embeddedUrl)
+ {
+ $replace = '#' . preg_replace('~[^A-Za-z0-9_\-#]~', '', substr($replace, 1));
+ $this_tag = 'iurl';
+ $this_close = 'iurl';
+ }
+ else
+ $replace = $protocols[0] . '://' . $replace;
+ }
+ elseif (!$found)
+ $replace = $protocols[0] . '://' . $replace;
+
+ if ($hasEqualSign && $embeddedUrl)
+ $replaces[$matches[0][$k]] = '[' . $this_tag . '=' . $replace . ']' . (empty($matches[4][$k]) ? '' : $matches[3][$k] . '[/' . $this_close . ']');
+ elseif ($hasEqualSign)
+ $replaces['[' . $matches[1][$k] . '=' . $matches[2][$k] . ']'] = '[' . $this_tag . '=' . $replace . ']';
+ elseif ($embeddedUrl)
+ $replaces['[' . $matches[1][$k] . ']' . $matches[2][$k] . '[/' . $matches[3][$k] . ']'] = '[' . $this_tag . '=' . $replace . ']' . $matches[2][$k] . '[/' . $this_close . ']';
+ else
+ $replaces['[' . $matches[1][$k] . ']' . $matches[2][$k] . '[/' . $matches[3][$k] . ']'] = '[' . $this_tag . ']' . $replace . '[/' . $this_close . ']';
+
+ }
+
+ foreach ($replaces as $k => $v)
+ {
+ if ($k == $v)
+ unset($replaces[$k]);
+ }
+
+ if (!empty($replaces))
+ $message = strtr($message, $replaces);
+}
+
+/**
+ * Locale-formatted strftime_updated using \IntlDateFormatter (PHP 8.1 compatible)
+ * This provides a cross-platform alternative to strftime_updated() for when it will be removed from PHP.
+ * Note that output can be slightly different between libc sprintf and this function as it is using ICU.
+ *
+ * Usage:
+ * use function \PHP81_BC\strftime_updated;
+ * echo strftime_updated('%A %e %B %Y %X', new \DateTime('2021-09-28 00:00:00'), 'fr_FR');
+ *
+ * Original use:
+ * \setlocale('fr_FR.UTF-8', LC_TIME);
+ * echo \strftime_updated('%A %e %B %Y %X', strtotime('2021-09-28 00:00:00'));
+ *
+ * @param string $format Date format
+ * @param integer|string|DateTime $timestamp Timestamp
+ * @return string
+ * @author BohwaZ