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
' elsewise. + + disabled_before: used in place of before when disabled. Defaults + to '
' if block_level, '' if not. + + disabled_after: used in place of after when disabled. Defaults + to '
' if block_level, '' if not. + + block_level: set to true the tag is a "block level" tag, similar + to HTML. Block level tags cannot be nested inside tags that are + not block level, and will not be implicitly closed as easily. + One break following a block level tag may also be removed. + + trim: if set, and 'inside' whitespace after the begin tag will be + removed. If set to 'outside', whitespace after the end tag will + meet the same fate. + + validate: except when type is missing or 'closed', a callback to + validate the data as $data. Depending on the tag's type, $data + may be a string or an array of strings (corresponding to the + replacement.) + + quoted: when type is 'unparsed_equals' or 'parsed_equals' only, + may be not set, 'optional', or 'required' corresponding to if + the content may be quoted. This allows the parser to read + [tag="abc]def[esdf]"] properly. + + require_parents: an array of tag names, or not set. If set, the + enclosing tag *must* be one of the listed tags, or parsing won't + occur. + + require_children: similar to require_parents, if set children + won't be parsed if they are not in the list. + + disallow_children: similar to, but very different from, + require_children, if it is set the listed tags will not be + parsed inside the tag. + */ + + $codes = array( + array( + 'tag' => 'abbr', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + 'quoted' => 'optional', + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'acronym', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + 'quoted' => 'optional', + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'anchor', + 'type' => 'unparsed_equals', + 'test' => '[#]?([A-Za-z][A-Za-z0-9_\-]*)\]', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'b', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'black', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'blue', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'br', + 'type' => 'closed', + 'content' => '
', + ), + array( + 'tag' => 'btc', + 'type' => 'closed', + 'content' => 'BTC', + ), + array( + 'tag' => 'code', + 'type' => 'unparsed_content', + 'content' => '
' . $txt['smf238'] . ':
' . ($context['browser']['is_gecko'] ? '
$1
' : '$1') . '
', + // !!! Maybe this can be simplified? + 'validate' => isset($disabled['code']) ? null : function(&$tag, &$data, $disabled) { + global $context; + + if (!isset($disabled['code'])) + { + $php_parts = preg_split('~(<\?php|\?>)~', $data, -1, PREG_SPLIT_DELIM_CAPTURE); + + for ($php_i = 0, $php_n = count($php_parts); $php_i < $php_n; $php_i++) + { + // Do PHP code coloring? + if ($php_parts[$php_i] != '<?php') + continue; + + $php_string = ''; + while ($php_i + 1 < count($php_parts) && $php_parts[$php_i] != '?>') + { + $php_string .= $php_parts[$php_i]; + $php_parts[$php_i++] = ''; + } + $php_parts[$php_i] = highlight_php_code($php_string . $php_parts[$php_i]); + } + + // Fix the PHP code stuff... + $data = str_replace("
\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' => '
' . $txt['smf238'] . ': ($2)
' . ($context['browser']['is_gecko'] ? '
$1
' : '$1') . '
', + // !!! Maybe this can be simplified? + 'validate' => isset($disabled['code']) ? null : function(&$tag, &$data, $disabled) { + global $context; + + if (!isset($disabled['code'])) + { + $php_parts = preg_split('~(<\?php|\?>)~', $data[0], -1, PREG_SPLIT_DELIM_CAPTURE); + + for ($php_i = 0, $php_n = count($php_parts); $php_i < $php_n; $php_i++) + { + // Do PHP code coloring? + if ($php_parts[$php_i] != '<?php') + continue; + + $php_string = ''; + while ($php_i + 1 < count($php_parts) && $php_parts[$php_i] != '?>') + { + $php_string .= $php_parts[$php_i]; + $php_parts[$php_i++] = ''; + } + $php_parts[$php_i] = highlight_php_code($php_string . $php_parts[$php_i]); + } + + // Fix the PHP code stuff... + $data[0] = str_replace("
\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' => '
', + 'block_level' => true, + ), + array( + 'tag' => 'color', + 'type' => 'unparsed_equals', + 'test' => '(#[\da-fA-F]{3}|#[\da-fA-F]{6}|[A-Za-z]{1,12})\]', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'email', + 'type' => 'unparsed_content', + 'content' => '$1', + // !!! Should this respect guest_hideContacts? + 'validate' => function(&$tag, &$data, $disabled) {$data = strtr($data, array('
' => ''));}, + ), + array( + 'tag' => 'email', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + // !!! Should this respect guest_hideContacts? + 'disallow_children' => array('email', 'ftp', 'url', 'iurl'), + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'ftp', + 'type' => 'unparsed_content', + 'content' => '$1', + 'validate' => function(&$tag, &$data, $disabled) { + $data = strtr($data, array('
' => '')); + if (strpos($data, 'ftp://') !== 0 && strpos($data, 'ftps://') !== 0) + $data = 'ftp://' . $data; + }, + ), + array( + 'tag' => 'ftp', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + 'validate' => function(&$tag, &$data, $disabled) { + if (strpos($data, 'ftp://') !== 0 && strpos($data, 'ftps://') !== 0) + $data = 'ftp://' . $data; + }, + 'disallow_children' => array('email', 'ftp', 'url', 'iurl'), + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'font', + 'type' => 'unparsed_equals', + 'test' => '[A-Za-z0-9_,\-\s]+?\]', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'flash', + 'type' => 'unparsed_commas_content', + 'test' => '\d+,\d+\]', + 'content' => ($context['browser']['is_ie'] && !$context['browser']['is_mac_ie'] ? '<a href="$1">$1</a>' : '<a href="$1">$1</a>'), + 'validate' => function(&$tag, &$data, $disabled) { + if (isset($disabled['url'])) + $tag['content'] = '$1'; + elseif (strpos($data[0], 'http://') !== 0 && strpos($data[0], 'https://') !== 0) + $data[0] = 'http://' . $data[0]; + }, + 'disabled_content' => $disabledsecurity ? '$1': '$1', + ), + array( + 'tag' => 'green', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'glow', + 'type' => 'unparsed_commas', + 'test' => '[#0-9a-zA-Z\-]{3,12},([012]\d{1,2}|\d{1,2})(,[^]]+)?\]', + 'before' => $context['browser']['is_ie'] ? '
' : '', + 'after' => $context['browser']['is_ie'] ? '
' : '
', + ), + array( + 'tag' => 'hr', + 'type' => 'closed', + 'content' => '
', + 'block_level' => true, + ), + array( + 'tag' => 'html', + 'type' => 'unparsed_content', + 'content' => '$1', + 'block_level' => true, + 'disabled_content' => '$1', + ), + array( + 'tag' => 'img', + 'type' => 'unparsed_content', + 'parameters' => array( + 'alt' => array('optional' => true), + 'width' => array('optional' => true, 'value' => ' width="$1"', 'match' => '(\d{1,4})'), + 'height' => array('optional' => true, 'value' => ' height="$1"', 'match' => '(\d{1,4})'), + ), + 'content' => '{alt}', + 'validate' => function(&$tag, &$data, $disabled) { + $data = strtr($data, array('
' => '')); + if (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0) + $data = 'http://' . $data; + if(!isset($disabled['img'])) + $data = proxyurl($data); + }, + 'disabled_content' => $disabledsecurity ? ('($1)'.$disabledsecurity) : '$1', + ), + array( + 'tag' => 'img', + 'type' => 'unparsed_content', + 'content' => '', + 'validate' => function(&$tag, &$data, $disabled) { + $data = strtr($data, array('
' => '')); + if (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0) + $data = 'http://' . $data; + if(!isset($disabled['img'])) + $data = proxyurl($data); + }, + 'disabled_content' => $disabledsecurity ? ('($1)'.$disabledsecurity) : '$1', + ), + array( + 'tag' => 'i', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'iurl', + 'type' => 'unparsed_content', + 'content' => '$1', + 'validate' => function(&$tag, &$data, $disabled) { + $data = strtr($data, array('
' => '')); + if (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0 && strpos($data, 'bitcoin:') !== 0 && strpos($data, 'magnet:') !== 0) + $data = 'http://' . $data; + }, + ), + array( + 'tag' => 'iurl', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + 'validate' => function(&$tag, &$data, $disabled) { + if (substr($data, 0, 1) == '#') + $data = '#post_' . substr($data, 1); + elseif (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0 && strpos($data, 'bitcoin:') !== 0 && strpos($data, 'magnet:') !== 0) + $data = 'http://' . $data; + }, + 'disallow_children' => array('email', 'ftp', 'url', 'iurl'), + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'li', + 'before' => '
  • ', + 'after' => '
  • ', + 'trim' => 'outside', + 'require_parents' => array('list'), + 'block_level' => true, + 'disabled_before' => '', + 'disabled_after' => '
    ', + ), + array( + 'tag' => 'list', + 'before' => '', + 'trim' => 'inside', + 'require_children' => array('li'), + 'block_level' => true, + ), + array( + 'tag' => 'list', + 'parameters' => array( + 'type' => array('match' => '(none|disc|circle|square|decimal|decimal-leading-zero|lower-roman|upper-roman|lower-alpha|upper-alpha|lower-greek|lower-latin|upper-latin|hebrew|armenian|georgian|cjk-ideographic|hiragana|katakana|hiragana-iroha|katakana-iroha)'), + ), + 'before' => '', + 'trim' => 'inside', + 'require_children' => array('li'), + 'block_level' => true, + ), + array( + 'tag' => 'left', + 'before' => '
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'ltr', + 'before' => '
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'me', + 'type' => 'unparsed_equals', + 'before' => '
    * $1 ', + 'after' => '
    ', + 'quoted' => 'optional', + 'block_level' => true, + 'disabled_before' => '/me ', + 'disabled_after' => '
    ', + ), + array( + 'tag' => 'move', + 'before' => '', + 'after' => '', + 'block_level' => true, + ), + array( + 'tag' => 'nbsp', + 'type' => 'closed', + 'content' => ' ', + ), + array( + 'tag' => 'nobbc', + 'type' => 'unparsed_content', + 'content' => '$1', + ), + array( + 'tag' => 'pre', + 'before' => '
    ',
    +        'after' => '
    ', + ), + array( + 'tag' => 'php', + 'type' => 'unparsed_content', + 'content' => '
    $1
    ', + 'validate' => isset($disabled['php']) ? null : function(&$tag, &$data, $disabled) { + if (!isset($disabled['php'])) + { + $add_begin = substr(trim($data), 0, 5) != '<?'; + $data = highlight_php_code($add_begin ? '<?php ' . $data . '?>' : $data); + if ($add_begin) + $data = preg_replace(array('~^(.+?)<\?.{0,40}?php( |\s)~', '~\?>((?:)*)$~'), '$1', $data, 2); + }}, + 'block_level' => true, + 'disabled_content' => '$1', + ), + array( + 'tag' => 'quote', + 'before' => '
    ' . $txt['smf240'] . '
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'quote', + 'parameters' => array( + 'author' => array('match' => '(.{1,192}?)', 'quoted' => true, 'validate' => 'parse_bbc'), + ), + 'before' => '
    ' . $txt['smf239'] . ': {author}
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'quote', + 'type' => 'parsed_equals', + 'before' => '
    ' . $txt['smf239'] . ': $1
    ', + 'after' => '
    ', + 'quoted' => 'optional', + 'block_level' => true, + ), + array( + 'tag' => 'quote', + 'parameters' => array( + 'author' => array('match' => '([^<>]{1,192}?)'), + 'link' => array('match' => '(?:board=\d+;)?((?:topic|threadid)=[\dmsg#\./]{1,40}(?:;start=[\dmsg#\./]{1,40})?|action=profile;u=\d+)'), + 'date' => array('match' => '(\d+)', 'validate' => 'timeformat'), + ), + 'before' => '
    ' . $txt['smf239'] . ': {author} ' . $txt[176] . ' {date}
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'quote', + 'parameters' => array( + 'author' => array('match' => '(.{1,192}?)', 'validate' => 'parse_bbc'), + ), + 'before' => '
    ' . $txt['smf239'] . ': {author}
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'right', + 'before' => '
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 'red', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'rtl', + 'before' => '
    ', + 'after' => '
    ', + 'block_level' => true, + ), + array( + 'tag' => 's', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'size', + 'type' => 'unparsed_equals', + 'test' => '([1-9][\d]?p[xt]|(?:x-)?small(?:er)?|(?:x-)?large[r]?)\]', + // !!! line-height + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'size', + 'type' => 'unparsed_equals', + 'test' => '[1-9]\]', + // !!! line-height + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'sub', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'sup', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'shadow', + 'type' => 'unparsed_commas', + 'test' => '[#0-9a-zA-Z\-]{3,12},(left|right|top|bottom|[0123]\d{0,2})\]', + 'before' => $context['browser']['is_ie'] ? '' : '', + 'after' => '', + 'validate' => $context['browser']['is_ie'] ? function(&$tag, &$data, $disabled) { + if ($data[1] == 'left') + $data[1] = 270; + elseif ($data[1] == 'right') + $data[1] = 90; + elseif ($data[1] == 'top') + $data[1] = 0; + elseif ($data[1] == 'bottom') + $data[1] = 180; + else + $data[1] = (int) $data[1];} : function(&$tag, &$data, $disabled) { + if ($data[1] == 'top' || (is_numeric($data[1]) && $data[1] < 50)) + return '0 -2px'; + elseif ($data[1] == 'right' || (is_numeric($data[1]) && $data[1] < 100)) + return '2px 0'; + elseif ($data[1] == 'bottom' || (is_numeric($data[1]) && $data[1] < 190)) + return '0 2px'; + elseif ($data[1] == 'left' || (is_numeric($data[1]) && $data[1] < 280)) + return '-2px 0'; + else + return '0 0';}, + ), + array( + 'tag' => 'time', + 'type' => 'unparsed_content', + 'content' => '$1', + 'validate' => function(&$tag, &$data, $disabled) { + if (is_numeric($data)) + $data = timeformat($data); + else + $tag['content'] = '[time]$1[/time]';}, + ), + array( + 'tag' => 'tt', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'table', + 'before' => '', + 'after' => '
    ', + 'trim' => 'inside', + 'require_children' => array('tr'), + 'block_level' => true, + ), + array( + 'tag' => 'tr', + 'before' => '', + 'after' => '', + 'require_parents' => array('table'), + 'require_children' => array('td'), + 'trim' => 'both', + 'block_level' => true, + 'disabled_before' => '', + 'disabled_after' => '', + ), + array( + 'tag' => 'td', + 'before' => '', + 'after' => '', + 'require_parents' => array('tr'), + 'trim' => 'outside', + 'block_level' => true, + 'disabled_before' => '', + 'disabled_after' => '', + ), + array( + 'tag' => 'url', + 'type' => 'unparsed_content', + 'content' => '$1', + 'validate' => function(&$tag, &$data, $disabled) { + $data = strtr($data, array('
    ' => '')); + if (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0 && strpos($data, 'bitcoin:') !== 0 && strpos($data, 'magnet:') !== 0) + $data = 'http://' . $data; + }, + ), + array( + 'tag' => 'url', + 'type' => 'unparsed_equals', + 'before' => '', + 'after' => '', + 'validate' => function(&$tag, &$data, $disabled) { + if (strpos($data, 'http://') !== 0 && strpos($data, 'https://') !== 0 && strpos($data, 'bitcoin:') !== 0 && strpos($data, 'magnet:') !== 0) + $data = 'http://' . $data; + }, + 'disallow_children' => array('email', 'ftp', 'url', 'iurl'), + 'disabled_after' => ' ($1)', + ), + array( + 'tag' => 'u', + 'before' => '', + 'after' => '', + ), + array( + 'tag' => 'white', + 'before' => '', + 'after' => '', + ), + ); + + // This is mainly for the bbc manager, so it's easy to add tags above. Custom BBC should be added above this line. + if ($message === false) + return $codes; + + // So the parser won't skip them. + $itemcodes = array( + '*' => '', + '@' => 'disc', + '+' => 'square', + 'x' => 'square', + '#' => 'square', + 'o' => 'circle', + 'O' => 'circle', + '0' => 'circle', + ); + if (!isset($disabled['li']) && !isset($disabled['list'])) + { + foreach ($itemcodes as $c => $dummy) + $bbc_codes[$c] = array(); + } + + // Inside these tags autolink is not recommendable. + $no_autolink_tags = array( + 'url', + 'iurl', + 'ftp', + 'email', + ); + + // Shhhh! + if (!isset($disabled['color'])) + { + $codes[] = array( + 'tag' => 'chrissy', + 'before' => '', + 'after' => ' :-*', + ); + $codes[] = array( + 'tag' => 'kissy', + 'before' => '', + 'after' => ' :-*', + ); + } + + foreach ($codes as $c) + $bbc_codes[substr($c['tag'], 0, 1)][] = $c; + $codes = null; + } + + // Shall we take the time to cache this? + if ($cache_id != '' && !empty($modSettings['cache_enable']) && (($modSettings['cache_enable'] >= 2 && strlen($message) > 1000) || strlen($message) > 2400)) + { + // It's likely this will change if the message is modified. + $cache_key = 'parse:' . $cache_id . '-' . md5(md5($message) . '-' . $smileys . (empty($disabled) ? '' : implode(',', array_keys($disabled))) . safe_serialize($context['browser']) . $txt['lang_locale'] . $user_info['time_offset'] . $user_info['time_format']); + + if (($temp = cache_get_data($cache_key, 600)) != null) + return $temp; + + $cache_t = microtime(); + } + + if ($smileys === 'print') + { + // [glow], [shadow], and [move] can't really be printed. + $disabled['glow'] = true; + $disabled['shadow'] = true; + $disabled['move'] = true; + + // Colors can't well be displayed... supposed to be black and white. + $disabled['color'] = true; + $disabled['black'] = true; + $disabled['blue'] = true; + $disabled['white'] = true; + $disabled['red'] = true; + $disabled['green'] = true; + $disabled['me'] = true; + + // Color coding doesn't make sense. + $disabled['php'] = true; + + // Links are useless on paper... just show the link. + $disabled['ftp'] = true; + $disabled['url'] = true; + $disabled['iurl'] = true; + $disabled['email'] = true; + $disabled['flash'] = true; + + // !!! Change maybe? + if (!isset($_GET['images'])) + $disabled['img'] = true; + + // !!! Interface/setting to add more? + } + + if($local_disable) + foreach($local_disable as $d) + $disabled[$d] = true; + + $open_tags = array(); + $message = strtr($message, array("\n" => '
    ')); + + // The non-breaking-space looks a bit different each time. + $non_breaking_space = $context['utf8'] ? ($context['server']['complex_preg_chars'] ? '\x{C2A0}' : chr(0xC2) . chr(0xA0)) : '\xA0'; + + $pos = -1; + while ($pos !== false) + { + // theymos - prevent various infinite loops + if($pos>90000) { + if(!isset($loopcount)) + $loopcount=0; + $loopcount++; + if($loopcount > 500) + return 'INVALID BBCODE: loop, probably unclosed tags'; + } + + $last_pos = isset($last_pos) ? max($pos, $last_pos) : $pos; + $pos = strpos($message, '[', $pos + 1); + + // Failsafe. + if ($pos === false || $last_pos > $pos) + $pos = strlen($message) + 1; + + // Can't have a one letter smiley, URL, or email! (sorry.) + if ($last_pos < $pos - 1) + { + // We want to eat one less, and one more, character (for smileys.) + $last_pos = max($last_pos - 1, 0); + $data = substr($message, $last_pos, $pos - $last_pos + 1); + + // Take care of some HTML! + if (!empty($modSettings['enablePostHTML']) && strpos($data, '<') !== false) + { + $data = preg_replace('~<a\s+href=((?:")?)((?:https?://|ftps?://|mailto:|bitcoin:)\S+?)\\1>~i', '[url=$2]', $data); + $data = preg_replace('~</a>~i', '[/url]', $data); + + //
    should be empty. + $empty_tags = array('br', 'hr'); + foreach ($empty_tags as $tag) + $data = str_replace(array('<' . $tag . '>', '<' . $tag . '/>', '<' . $tag . ' />'), '[' . $tag . ' /]', $data); + + // b, u, i, s, pre... basic tags. + $closable_tags = array('b', 'u', 'i', 's', 'em', 'ins', 'del', 'pre', 'blockquote'); + foreach ($closable_tags as $tag) + { + $diff = substr_count($data, '<' . $tag . '>') - substr_count($data, '</' . $tag . '>'); + $data = strtr($data, array('<' . $tag . '>' => '<' . $tag . '>', '</' . $tag . '>' => '')); + + if ($diff > 0) + $data .= str_repeat('', $diff); + } + + // Do - with security... action= -> action-. + preg_match_all('~<img\s+src=((?:")?)((?:https?://|ftps?://)\S+?)\\1(?:\s+alt=(".*?"|\S*?))?(?:\s?/)?>~i', $data, $matches, PREG_PATTERN_ORDER); + if (!empty($matches[0])) + { + $replaces = array(); + foreach ($matches[2] as $match => $imgtag) + { + $alt = empty($matches[3][$match]) ? '' : ' alt=' . preg_replace('~^"|"$~', '', $matches[3][$match]); + + // Remove action= from the URL - no funny business, now. + if (preg_match('~action(=|%3d)(?!dlattach)~i', $imgtag) != 0) + $imgtag = preg_replace('~action(=|%3d)(?!dlattach)~i', 'action-', $imgtag); + + // Check if the image is larger than allowed. + if (!empty($modSettings['max_image_width']) && !empty($modSettings['max_image_height'])) + { + list ($width, $height) = url_image_size($imgtag); + + if (!empty($modSettings['max_image_width']) && $width > $modSettings['max_image_width']) + { + $height = (int) (($modSettings['max_image_width'] * $height) / $width); + $width = $modSettings['max_image_width']; + } + + if (!empty($modSettings['max_image_height']) && $height > $modSettings['max_image_height']) + { + $width = (int) (($modSettings['max_image_height'] * $width) / $height); + $height = $modSettings['max_image_height']; + } + + // Set the new image tag. + $replaces[$matches[0][$match]] = '[img width=' . $width . ' height=' . $height . $alt . ']' . $imgtag . '[/img]'; + } + else + $replaces[$matches[0][$match]] = '[img' . $alt . ']' . $imgtag . '[/img]'; + } + + $data = strtr($data, $replaces); + } + } + + if (!empty($modSettings['autoLinkUrls'])) + { + // Are we inside tags that should be auto linked? + $no_autolink_area = false; + if (!empty($open_tags)) + { + foreach ($open_tags as $open_tag) + if (in_array($open_tag['tag'], $no_autolink_tags)) + $no_autolink_area = true; + } + + // Don't go backwards. + //!!! Don't think is the real solution.... + $lastAutoPos = isset($lastAutoPos) ? $lastAutoPos : 0; + if ($pos < $lastAutoPos) + $no_autolink_area = true; + $lastAutoPos = $pos; + + if (!$no_autolink_area) + { + // Parse any URLs.... have to get rid of the @ problems some things cause... stupid email addresses. + if (!isset($disabled['url']) && (strpos($data, '://') !== false || strpos($data, 'www.') !== false || strpos($data,'bitcoin:') !==false)) + { + // Switch out quotes really quick because they can cause problems. + $data = strtr($data, array(''' => '\'', ' ' => $context['utf8'] ? "\xC2\xA0" : "\xA0", '"' => '>">', '"' => '<"<', '<' => '\.(;\'"]|' . $nbsp . '|^)((?:http|https|ftp|ftps)://[\w\-_%@:|]+(?:\.[\w\-_%]+)*(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i', + '~(?<=[\s>(;\'<]|' . $nbsp . '|^)(www(?:\.[\w\-_]+)+(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i', + '~bitcoin:([-A-Za-z0-9._:/?#!%@$()*+,;=]{25,})~i' + ), array( + '[url]$1[/url]', + '[url=http://$1]$1[/url]', + '[url]bitcoin:$1[/url]' + ), $data))) + $data = $result; + + $data = strtr($data, array('\'' => ''', $context['utf8'] ? "\xC2\xA0" : "\xA0" => ' ', '>">' => '"', '<"<' => '"', ' '<')); + } + + // Next, emails... + if (!isset($disabled['email']) && strpos($data, '@') !== false) + { + $data = preg_replace('~(?<=[\?\s' . $non_breaking_space . '\[\]()*\\\;>]|^)([\w\-\.]{1,80}@[\w\-]+\.[\w\-\.]+[\w\-])(?=[?,\s' . $non_breaking_space . '\[\]()*\\\]|$|
    | |>|<|"|'|\.(?:\.|;| |\s|$|
    ))~' . ($context['utf8'] ? 'u' : ''), '[email]$1[/email]', $data); + $data = preg_replace('~(?<=
    )([\w\-\.]{1,80}@[\w\-]+\.[\w\-\.]+[\w\-])(?=[?\.,;\s' . $non_breaking_space . '\[\]()*\\\]|$|
    | |>|<|"|')~' . ($context['utf8'] ? 'u' : ''), '[email]$1[/email]', $data); + // theymos - infinite loop + if($pos > 1000 && strpos(substr($message, $pos-100), '[email][email][email][email]') !== false) + return 'INVALID BBCODE: loop, probably unclosed tags (2)'; + } + } + } + + $data = strtr($data, array("\t" => '   ')); + + if (!empty($modSettings['fixLongWords']) && $modSettings['fixLongWords'] > 5) + { + // This is SADLY and INCREDIBLY browser dependent. + if ($context['browser']['is_gecko'] || $context['browser']['is_konqueror']) + $breaker = ' '; + // Opera... + elseif ($context['browser']['is_opera']) + $breaker = ' '; + // Internet Explorer... + else + $breaker = ' '; + + // PCRE will not be happy if we don't give it a short. + $modSettings['fixLongWords'] = (int) min(65535, $modSettings['fixLongWords']); + + // The idea is, find words xx long, and then replace them with xx + space + more. + if (strlen($data) > $modSettings['fixLongWords']) + { + // This is done in a roundabout way because $breaker has "long words" :P. + $data = strtr($data, array($breaker => '< >', ' ' => $context['utf8'] ? "\xC2\xA0" : "\xA0")); + $data = preg_replace_callback( + '~(?<=[>;:!? ' . $non_breaking_space . '\]()]|^)([\w' . ($context['utf8'] ? '\pL' : '') . '\.]{' . $modSettings['fixLongWords'] . ',})~' . ($context['utf8'] ? 'u' : ''), + 'word_break__preg_callback', + $data); + $data = strtr($data, array('< >' => $breaker, $context['utf8'] ? "\xC2\xA0" : "\xA0" => ' ')); + } + } + + // Do any smileys! + if ($smileys === true) + parsesmileys($data); + + // If it wasn't changed, no copying or other boring stuff has to happen! + if ($data != substr($message, $last_pos, $pos - $last_pos + 1)) + { + $message = substr($message, 0, $last_pos) . $data . substr($message, $pos + 1); + + // Since we changed it, look again incase we added or removed a tag. But we don't want to skip any. + $old_pos = strlen($data) + $last_pos - 1; + $pos = strpos($message, '[', $last_pos); + $pos = $pos === false ? $old_pos : min($pos, $old_pos); + } + } + + // Are we there yet? Are we there yet? + if ($pos >= strlen($message) - 1) + break; + + $tags = strtolower(substr($message, $pos + 1, 1)); + + if ($tags == '/' && !empty($open_tags)) + { + $pos2 = strpos($message, ']', $pos + 1); + if ($pos2 == $pos + 2) + continue; + $look_for = strtolower(substr($message, $pos + 2, $pos2 - $pos - 2)); + + $to_close = array(); + $block_level = null; + do + { + $tag = array_pop($open_tags); + if (!$tag) + break; + + if (!empty($tag['block_level'])) + { + // Only find out if we need to. + if ($block_level === false) + { + array_push($open_tags, $tag); + break; + } + + // The idea is, if we are LOOKING for a block level tag, we can close them on the way. + if (strlen($look_for) > 0 && isset($bbc_codes[$look_for[0]])) + { + foreach ($bbc_codes[$look_for[0]] as $temp) + if ($temp['tag'] == $look_for) + { + $block_level = !empty($temp['block_level']); + break; + } + } + + if ($block_level !== true) + { + $block_level = false; + array_push($open_tags, $tag); + break; + } + } + + $to_close[] = $tag; + } + while ($tag['tag'] != $look_for); + + // Did we just eat through everything and not find it? + if ((empty($open_tags) && (empty($tag) || $tag['tag'] != $look_for))) + { + $tablewarn = false; + for($cc=count($to_close)-1;$cc>=0;$cc--) { + if($to_close[$cc]['tag'] == 'table') + $tablewarn = true; + if($tablewarn && (in_array($to_close[$cc]['tag'], array('td', 'tr')))) + return 'INVALID BBCODE: close of unopened tag in table (2)'; + } + unset($cc, $tablewarn); + $open_tags = $to_close; + continue; + } + elseif (!empty($to_close) && $tag['tag'] != $look_for) + { + if ($block_level === null && isset($look_for[0], $bbc_codes[$look_for[0]])) + { + foreach ($bbc_codes[$look_for[0]] as $temp) + if ($temp['tag'] == $look_for) + { + $block_level = !empty($temp['block_level']); + break; + } + } + + // We're not looking for a block level tag (or maybe even a tag that exists...) + if (!$block_level) + { + foreach ($to_close as $tag) + array_push($open_tags, $tag); + continue; + } + } + + foreach ($to_close as $tag) + { + $message = substr($message, 0, $pos) . $tag['after'] . substr($message, $pos2 + 1); + $pos += strlen($tag['after']); + $pos2 = $pos - 1; + + // See the comment at the end of the big loop - just eating whitespace ;). + if (!empty($tag['block_level']) && substr($message, $pos, 6) == '
    ') + $message = substr($message, 0, $pos) . substr($message, $pos + 6); + if (!empty($tag['trim']) && $tag['trim'] != 'inside' && preg_match('~(
    | |\s)*~', substr($message, $pos), $matches) != 0) + $message = substr($message, 0, $pos) . substr($message, $pos + strlen($matches[0])); + } + + if (!empty($to_close)) + { + $to_close = array(); + $pos--; + } + + continue; + } + + // No tags for this character, so just keep going (fastest possible course.) + if (!isset($bbc_codes[$tags])) + continue; + + $inside = empty($open_tags) ? null : $open_tags[count($open_tags) - 1]; + $tag = null; + foreach ($bbc_codes[$tags] as $possible) + { + // Not a match? + if (strtolower(substr($message, $pos + 1, strlen($possible['tag']))) != $possible['tag']) + continue; + + $next_c = substr($message, $pos + 1 + strlen($possible['tag']), 1); + + // A test validation? + if (isset($possible['test']) && preg_match('~^' . $possible['test'] . '~', substr($message, $pos + 1 + strlen($possible['tag']) + 1)) == 0) + continue; + // Do we want parameters? + elseif (!empty($possible['parameters'])) + { + if ($next_c != ' ') + continue; + } + elseif (isset($possible['type'])) + { + // Do we need an equal sign? + if (in_array($possible['type'], array('unparsed_equals', 'unparsed_commas', 'unparsed_commas_content', 'unparsed_equals_content', 'parsed_equals')) && $next_c != '=') + continue; + // Maybe we just want a /... + if ($possible['type'] == 'closed' && $next_c != ']' && substr($message, $pos + 1 + strlen($possible['tag']), 2) != '/]' && substr($message, $pos + 1 + strlen($possible['tag']), 3) != ' /]') + continue; + // An immediate ]? + if ($possible['type'] == 'unparsed_content' && $next_c != ']') + continue; + } + // No type means 'parsed_content', which demands an immediate ] without parameters! + elseif ($next_c != ']') + continue; + + // Check allowed tree? + if (isset($possible['require_parents']) && ($inside === null || !in_array($inside['tag'], $possible['require_parents']))) + continue; + elseif (isset($inside['require_children']) && !in_array($possible['tag'], $inside['require_children'])) + continue; + // If this is in the list of disallowed child tags, don't parse it. + elseif (isset($inside['disallow_children']) && in_array($possible['tag'], $inside['disallow_children'])) + continue; + + $pos1 = $pos + 1 + strlen($possible['tag']) + 1; + + // This is long, but it makes things much easier and cleaner. + if (!empty($possible['parameters'])) + { + $preg = array(); + foreach ($possible['parameters'] as $p => $info) + $preg[] = '(\s+' . $p . '=' . (empty($info['quoted']) ? '' : '"') . (isset($info['match']) ? $info['match'] : '(.+?)') . (empty($info['quoted']) ? '' : '"') . ')' . (empty($info['optional']) ? '' : '?'); + + // Okay, this may look ugly and it is, but it's not going to happen much and it is the best way of allowing any order of parameters but still parsing them right. + $match = false; + $orders = permute($preg); + foreach ($orders as $p) + if (preg_match('~^' . implode('', $p) . '\]~i', substr($message, $pos1 - 1), $matches) != 0) + { + $match = true; + break; + } + + // Didn't match our parameter list, try the next possible. + if (!$match) + continue; + + $params = array(); + for ($i = 1, $n = count($matches); $i < $n; $i += 2) + { + $key = strtok(ltrim($matches[$i]), '='); + if (isset($possible['parameters'][$key]['value'])) + $params['{' . $key . '}'] = strtr($possible['parameters'][$key]['value'], array('$1' => $matches[$i + 1])); + elseif (isset($possible['parameters'][$key]['validate'])) + $params['{' . $key . '}'] = $possible['parameters'][$key]['validate']($matches[$i + 1]); + else + $params['{' . $key . '}'] = $matches[$i + 1]; + + // Just to make sure: replace any $ or { so they can't interpolate wrongly. + $params['{' . $key . '}'] = strtr($params['{' . $key . '}'], array('$' => '$', '{' => '{')); + } + + foreach ($possible['parameters'] as $p => $info) + { + if (!isset($params['{' . $p . '}'])) + $params['{' . $p . '}'] = ''; + } + + $tag = $possible; + + // Put the parameters into the string. + if (isset($tag['before'])) + $tag['before'] = strtr($tag['before'], $params); + if (isset($tag['after'])) + $tag['after'] = strtr($tag['after'], $params); + if (isset($tag['content'])) + $tag['content'] = strtr($tag['content'], $params); + + $pos1 += strlen($matches[0]) - 1; + } + else + $tag = $possible; + break; + } + + // Item codes are complicated buggers... they are implicit [li]s and can make [list]s! + if ($smileys !== false && $tag === null && isset($itemcodes[substr($message, $pos + 1, 1)]) && substr($message, $pos + 2, 1) == ']' && !isset($disabled['list']) && !isset($disabled['li'])) + { + if (substr($message, $pos + 1, 1) == '0' && !in_array(substr($message, $pos - 1, 1), array(';', ' ', "\t", '>'))) + continue; + $tag = $itemcodes[substr($message, $pos + 1, 1)]; + + // First let's set up the tree: it needs to be in a list, or after an li. + if ($inside === null || ($inside['tag'] != 'list' && $inside['tag'] != 'li')) + { + $open_tags[] = array( + 'tag' => 'list', + 'after' => '', + 'block_level' => true, + 'require_children' => array('li'), + 'disallow_children' => isset($inside['disallow_children']) ? $inside['disallow_children'] : null, + ); + $code = '
      '; + } + // We're in a list item already: another itemcode? Close it first. + elseif ($inside['tag'] == 'li') + { + array_pop($open_tags); + $code = ''; + } + else + $code = ''; + + // Now we open a new tag. + $open_tags[] = array( + 'tag' => 'li', + 'after' => '', + 'trim' => 'outside', + 'block_level' => true, + 'disallow_children' => isset($inside['disallow_children']) ? $inside['disallow_children'] : null, + ); + + // First, open the tag... + $code .= ''; + $message = substr($message, 0, $pos) . $code . substr($message, $pos + 3); + $pos += strlen($code) - 1; + + // Next, find the next break (if any.) If there's more itemcode after it, keep it going - otherwise close! + $pos2 = strpos($message, '
      ', $pos); + $pos3 = strpos($message, '[/', $pos); + if ($pos2 !== false && ($pos2 <= $pos3 || $pos3 === false)) + { + preg_match('~^(
      | |\s|\[)+~', substr($message, $pos2 + 6), $matches); + $message = substr($message, 0, $pos2) . (!empty($matches[0]) && substr($matches[0], -1) == '[' ? '[/li]' : '[/li][/list]') . substr($message, $pos2); + + $open_tags[count($open_tags) - 2]['after'] = '
    '; + } + // Tell the [list] that it needs to close specially. + else + { + if(count($open_tags)<2) { + return 'INVALID BBCODE: messed-up itemcodes'; + } + // Move the li over, because we're not sure what we'll hit. + $open_tags[count($open_tags) - 1]['after'] = ''; + $open_tags[count($open_tags) - 2]['after'] = ''; + } + + continue; + } + + // Implicitly close lists and tables if something other than what's required is in them. This is needed for itemcode. + if ($tag === null && $inside !== null && !empty($inside['require_children'])) + { + array_pop($open_tags); + + $message = substr($message, 0, $pos) . $inside['after'] . substr($message, $pos); + $pos += strlen($inside['after']) - 1; + } + + // No tag? Keep looking, then. Silly people using brackets without actual tags. + if ($tag === null) + continue; + + // Propagate the list to the child (so wrapping the disallowed tag won't work either.) + if (isset($inside['disallow_children'])) + $tag['disallow_children'] = isset($tag['disallow_children']) ? array_unique(array_merge($tag['disallow_children'], $inside['disallow_children'])) : $inside['disallow_children']; + + // Is this tag disabled? + if (isset($disabled[$tag['tag']])) + { + if (!isset($tag['disabled_before']) && !isset($tag['disabled_after']) && !isset($tag['disabled_content'])) + { + $tag['before'] = !empty($tag['block_level']) ? '
    ' : ''; + $tag['after'] = !empty($tag['block_level']) ? '
    ' : ''; + $tag['content'] = isset($tag['type']) && $tag['type'] == 'closed' ? '' : (!empty($tag['block_level']) ? '
    $1
    ' : '$1'); + } + elseif (isset($tag['disabled_before']) || isset($tag['disabled_after'])) + { + $tag['before'] = isset($tag['disabled_before']) ? $tag['disabled_before'] : (!empty($tag['block_level']) ? '
    ' : ''); + $tag['after'] = isset($tag['disabled_after']) ? $tag['disabled_after'] : (!empty($tag['block_level']) ? '
    ' : ''); + } + else + $tag['content'] = $tag['disabled_content']; + } + + // The only special case is 'html', which doesn't need to close things. + if (!empty($tag['block_level']) && $tag['tag'] != 'html' && empty($inside['block_level'])) + { + $n = count($open_tags) - 1; + while (empty($open_tags[$n]['block_level']) && $n >= 0) + $n--; + + // Close all the non block level tags so this tag isn't surrounded by them. + for ($i = count($open_tags) - 1; $i > $n; $i--) + { + $message = substr($message, 0, $pos) . $open_tags[$i]['after'] . substr($message, $pos); + $pos += strlen($open_tags[$i]['after']); + $pos1 += strlen($open_tags[$i]['after']); + + // Trim or eat trailing stuff... see comment at the end of the big loop. + if (!empty($open_tags[$i]['block_level']) && substr($message, $pos, 6) == '
    ') + $message = substr($message, 0, $pos) . substr($message, $pos + 6); + if (!empty($open_tags[$i]['trim']) && $tag['trim'] != 'inside' && preg_match('~(
    | |\s)*~', substr($message, $pos), $matches) != 0) + $message = substr($message, 0, $pos) . substr($message, $pos + strlen($matches[0])); + + array_pop($open_tags); + } + } + + // No type means 'parsed_content'. + if (!isset($tag['type'])) + { + // !!! Check for end tag first, so people can say "I like that [i] tag"? + $open_tags[] = $tag; + $message = substr($message, 0, $pos) . $tag['before'] . substr($message, $pos1); + $pos += strlen($tag['before']) - 1; + } + // Don't parse the content, just skip it. + elseif ($tag['type'] == 'unparsed_content') + { + $pos2 = stripos($message, '[/' . substr($message, $pos + 1, strlen($tag['tag'])) . ']', $pos1); + if ($pos2 === false) + continue; + + $data = substr($message, $pos1, $pos2 - $pos1); + + if (!empty($tag['block_level']) && substr($data, 0, 6) == '
    ') + $data = substr($data, 6); + + if (isset($tag['validate'])) + $tag['validate']($tag, $data, $disabled); + + $code = strtr($tag['content'], array('$1' => $data)); + $message = substr($message, 0, $pos) . $code . substr($message, $pos2 + 3 + strlen($tag['tag'])); + $pos += strlen($code) - 1; + } + // Don't parse the content, just skip it. + elseif ($tag['type'] == 'unparsed_equals_content') + { + // The value may be quoted for some tags - check. + if (isset($tag['quoted'])) + { + $quoted = substr($message, $pos1, 6) == '"'; + if ($tag['quoted'] != 'optional' && !$quoted) + continue; + + if ($quoted) + $pos1 += 6; + } + else + $quoted = false; + + $pos2 = strpos($message, $quoted == false ? ']' : '"]', $pos1); + if ($pos2 === false) + continue; + $pos3 = stripos($message, '[/' . substr($message, $pos + 1, strlen($tag['tag'])) . ']', $pos2); + if ($pos3 === false) + continue; + + $data = array( + substr($message, $pos2 + ($quoted == false ? 1 : 7), $pos3 - ($pos2 + ($quoted == false ? 1 : 7))), + substr($message, $pos1, $pos2 - $pos1) + ); + + if (!empty($tag['block_level']) && substr($data[0], 0, 6) == '
    ') + $data[0] = substr($data[0], 6); + + // Validation for my parking, please! + if (isset($tag['validate'])) + $tag['validate']($tag, $data, $disabled); + + $code = strtr($tag['content'], array('$1' => $data[0], '$2' => $data[1])); + $message = substr($message, 0, $pos) . $code . substr($message, $pos3 + 3 + strlen($tag['tag'])); + $pos += strlen($code) - 1; + } + // A closed tag, with no content or value. + elseif ($tag['type'] == 'closed') + { + $pos2 = strpos($message, ']', $pos); + $message = substr($message, 0, $pos) . $tag['content'] . substr($message, $pos2 + 1); + $pos += strlen($tag['content']) - 1; + } + // This one is sorta ugly... :/. Unforunately, it's needed for flash. + elseif ($tag['type'] == 'unparsed_commas_content') + { + $pos2 = strpos($message, ']', $pos1); + if ($pos2 === false) + continue; + $pos3 = stripos($message, '[/' . substr($message, $pos + 1, strlen($tag['tag'])) . ']', $pos2); + if ($pos3 === false) + continue; + + // We want $1 to be the content, and the rest to be csv. + $data = explode(',', ',' . substr($message, $pos1, $pos2 - $pos1)); + $data[0] = substr($message, $pos2 + 1, $pos3 - $pos2 - 1); + + if (isset($tag['validate'])) + $tag['validate']($tag, $data, $disabled); + + $code = $tag['content']; + foreach ($data as $k => $d) + $code = strtr($code, array('$' . ($k + 1) => trim($d))); + $message = substr($message, 0, $pos) . $code . substr($message, $pos3 + 3 + strlen($tag['tag'])); + $pos += strlen($code) - 1; + } + // This has parsed content, and a csv value which is unparsed. + elseif ($tag['type'] == 'unparsed_commas') + { + $pos2 = strpos($message, ']', $pos1); + if ($pos2 === false) + continue; + + $data = explode(',', substr($message, $pos1, $pos2 - $pos1)); + + if (isset($tag['validate'])) + $tag['validate']($tag, $data, $disabled); + + // Fix after, for disabled code mainly. + foreach ($data as $k => $d) + $tag['after'] = strtr($tag['after'], array('$' . ($k + 1) => trim($d))); + + $open_tags[] = $tag; + + // Replace them out, $1, $2, $3, $4, etc. + $code = $tag['before']; + foreach ($data as $k => $d) + $code = strtr($code, array('$' . ($k + 1) => trim($d))); + $message = substr($message, 0, $pos) . $code . substr($message, $pos2 + 1); + $pos += strlen($code) - 1; + } + // A tag set to a value, parsed or not. + elseif ($tag['type'] == 'unparsed_equals' || $tag['type'] == 'parsed_equals') + { + // The value may be quoted for some tags - check. + if (isset($tag['quoted'])) + { + $quoted = substr($message, $pos1, 6) == '"'; + if ($tag['quoted'] != 'optional' && !$quoted) + continue; + + if ($quoted) + $pos1 += 6; + } + else + $quoted = false; + + $pos2 = strpos($message, $quoted == false ? ']' : '"]', $pos1); + if ($pos2 === false) + continue; + + $data = substr($message, $pos1, $pos2 - $pos1); + + // Validation for my parking, please! + if (isset($tag['validate'])) + $tag['validate']($tag, $data, $disabled); + + // For parsed content, we must recurse to avoid security problems. + if ($tag['type'] != 'unparsed_equals') + $data = parse_bbc($data); + + $tag['after'] = strtr($tag['after'], array('$1' => $data)); + + $open_tags[] = $tag; + + $code = strtr($tag['before'], array('$1' => $data)); + $message = substr($message, 0, $pos) . $code . substr($message, $pos2 + ($quoted == false ? 1 : 7)); + $pos += strlen($code) - 1; + } + + // If this is block level, eat any breaks after it. + if (!empty($tag['block_level']) && substr($message, $pos + 1, 6) == '
    ') + $message = substr($message, 0, $pos + 1) . substr($message, $pos + 7); + + // Are we trimming outside this tag? + if (!empty($tag['trim']) && $tag['trim'] != 'outside' && preg_match('~(
    | |\s)*~', substr($message, $pos + 1), $matches) != 0) + $message = substr($message, 0, $pos + 1) . substr($message, $pos + 1 + strlen($matches[0])); + } + + // Close any remaining tags. + while ($tag = array_pop($open_tags)) { + if(in_array($tag['tag'], array('table','td','tr','th'))) + return 'INVALID BBCODE: close of unopened tag in table (1)'; + $message .= $tag['after']; + } + + if (substr($message, 0, 1) == ' ') + $message = ' ' . substr($message, 1); + + // Cleanup whitespace. + $message = strtr($message, array(' ' => '  ', "\r" => '', "\n" => '
    ', '
    ' => '
     ', ' ' => "\n")); + + if(in_array('ugc', $local_disable)) + $message = str_replace(' 0.05) + cache_put_data($cache_key, $message, 600); + + return $message; +} + +// Parse smileys in the passed message. +function parsesmileys(&$message) +{ + global $modSettings, $db_prefix, $txt, $user_info, $context; + static $smileyfromcache = array(), $smileytocache = array(); + + // No smiley set at all?! + if ($user_info['smiley_set'] == 'none') + return; + + // If the smiley array hasn't been set, do it now. + if (empty($smileyfromcache)) + { + // Use the default smileys if it is disabled. (better for "portability" of smileys.) + if (empty($modSettings['smiley_enable'])) + { + $smileysfrom = array('>:D', ':D', '::)', '>:(', ':)', ';)', ';D', ':(', ':o', '8)', ':P', '???', ':-[', ':-X', ':-*', ':\'(', ':-\\', '^-^', 'O0', 'C:-)', '0:)'); + $smileysto = array('evil.gif', 'cheesy.gif', 'rolleyes.gif', 'angry.gif', 'smiley.gif', 'wink.gif', 'grin.gif', 'sad.gif', 'shocked.gif', 'cool.gif', 'tongue.gif', 'huh.gif', 'embarrassed.gif', 'lipsrsealed.gif', 'kiss.gif', 'cry.gif', 'undecided.gif', 'azn.gif', 'afro.gif', 'police.gif', 'angel.gif'); + $smileysdescs = array('', $txt[289], $txt[450], $txt[288], $txt[287], $txt[292], $txt[293], $txt[291], $txt[294], $txt[295], $txt[451], $txt[296], $txt[526], $txt[527], $txt[529], $txt[530], $txt[528], '', '', '', ''); + } + else + { + // Load the smileys in reverse order by length so they don't get parsed wrong. + if (($temp = cache_get_data('parsing_smileys', 480)) == null) + { + $result = db_query(" + SELECT code, filename, description + FROM {$db_prefix}smileys", __FILE__, __LINE__); + $smileysfrom = array(); + $smileysto = array(); + $smileysdescs = array(); + while ($row = mysql_fetch_assoc($result)) + { + $smileysfrom[] = $row['code']; + $smileysto[] = $row['filename']; + $smileysdescs[] = $row['description']; + } + mysql_free_result($result); + + cache_put_data('parsing_smileys', array($smileysfrom, $smileysto, $smileysdescs), 480); + } + else + list ($smileysfrom, $smileysto, $smileysdescs) = $temp; + } + + // The non-breaking-space is a complex thing... + $non_breaking_space = $context['utf8'] ? ($context['server']['complex_preg_chars'] ? '\x{A0}' : pack('C*', 0xC2, 0xA0)) : '\xA0'; + + // This smiley regex makes sure it doesn't parse smileys within code tags (so [url=mailto:David@bla.com] doesn't parse the :D smiley) + for ($i = 0, $n = count($smileysfrom); $i < $n; $i++) + { + $smileyfromcache[] = '/(?<=[>:\?\.\s' . $non_breaking_space . '[\]()*\\\;]|^)(' . preg_quote($smileysfrom[$i], '/') . '|' . preg_quote(htmlspecialchars($smileysfrom[$i], ENT_QUOTES), '/') . ')(?=[^[:alpha:]0-9]|$)/' . ($context['utf8'] ? 'u' : ''); + // Escape a bunch of smiley-related characters in the description so it doesn't get a double dose :P. + $smileytocache[] = '' . strtr(htmlspecialchars($smileysdescs[$i]), array(':' => ':', '(' => '(', ')' => ')', '$' => '$', '[' => '[')) . ''; + } + } + + // Replace away! + // !!! There must be a way to speed this up. + $message = preg_replace($smileyfromcache, $smileytocache, $message); +} + +// Parses some bbc before sending into the database... +function preparsecode(&$message, $previewing = false) +{ + global $user_info, $modSettings, $context; + + + // Clean up after nobbc ;). + $message = preg_replace_callback('~\[nobbc\](.+?)\[/nobbc\]~is', 'nobbc__preg_callback', $message); + + //$message = preg_replace('~\[([^\]=\s]+)[^\]]*\](?' . '>\s|(?R))*?\[/\1\]\s?~i', '', $message); + + // Remove \r's... they're evil! + $message = strtr($message, array("\r" => '')); + + // You won't believe this - but too many periods upsets apache it seems! + $message = preg_replace('~\.{100,}~', '...', $message); + + // Trim off trailing quotes - these often happen by accident. + while (substr($message, -7) == '[quote]') + $message = substr($message, 0, -7); + while (substr($message, 0, 8) == '[/quote]') + $message = substr($message, 8); + + // Check if all code tags are closed. + $codeopen = preg_match_all('~(\[code(?:=[^\]]+)?\])~is', $message, $dummy); + $codeclose = preg_match_all('~(\[/code\])~is', $message, $dummy); + + // Close/open all code tags... + if ($codeopen > $codeclose) + $message .= str_repeat('[/code]', $codeopen - $codeclose); + elseif ($codeclose > $codeopen) + $message = str_repeat('[code]', $codeclose - $codeopen) . $message; + + // Now that we've fixed all the code tags, let's fix the img and url tags... + $parts = preg_split('~(\[/code\]|\[code(?:=[^\]]+)?\])~i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); + + // The regular expression non breaking space has many versions. + $non_breaking_space = $context['utf8'] ? ($context['server']['complex_preg_chars'] ? '\x{A0}' : pack('C*', 0xC2, 0xA0)) : '\xA0'; + + // Only mess with stuff outside [code] tags. + for ($i = 0, $n = count($parts); $i < $n; $i++) + { + // It goes 0 = outside, 1 = begin tag, 2 = inside, 3 = close tag, repeat. + if ($i % 4 == 0) + { + fixTags($parts[$i]); + + // Replace /me.+?\n with [me=name]dsf[/me]\n. + if (strpos($user_info['name'], '[') !== false || strpos($user_info['name'], ']') !== false || strpos($user_info['name'], '\'') !== false || strpos($user_info['name'], '"') !== false) + $parts[$i] = preg_replace('~(?:\A|\n)/me(?: | )([^\n]*)(?:\z)?~i', '[me="' . $user_info['name'] . '"]$1[/me]', $parts[$i]); + else + $parts[$i] = preg_replace('~(?:\A|\n)/me(?: | )([^\n]*)(?:\z)?~i', '[me=' . $user_info['name'] . ']$1[/me]', $parts[$i]); + + if (!$previewing && strpos($parts[$i], '[html]') !== false) + { + //if (false && allowedTo('admin_forum')) + //$parts[$i] = preg_replace('~\[html\](.+?)\[/html\]~ise', '\'[html]\' . strtr(un_htmlspecialchars(\'$1\'), array("\n" => \' \', \' \' => \' \')) . \'[/html]\'', $parts[$i]); + // We should edit them out, or else if an admin edits the message they will get shown... + //else + //{ + while (strpos($parts[$i], '[html]') !== false) + $parts[$i] = preg_replace('~\[[/]?html\]~i', '', $parts[$i]); + //} + } + + // Let's look at the time tags... + $parts[$i] = preg_replace_callback('~\[time(?:=(absolute))*\](.+?)\[/time\]~i', 'time_fix__preg_callback', $parts[$i]); + + $list_open = substr_count($parts[$i], '[list]') + substr_count($parts[$i], '[list '); + $list_close = substr_count($parts[$i], '[/list]'); + if ($list_close - $list_open > 0) + $parts[$i] = str_repeat('[list]', $list_close - $list_open) . $parts[$i]; + if ($list_open - $list_close > 0) + $parts[$i] = $parts[$i] . str_repeat('[/list]', $list_open - $list_close); + + // Make sure all tags are lowercase. + $parts[$i] = preg_replace_callback('~\[([/]?)(list|li|table|tr|td)((\s[^\]]+)*)\]~i', 'lowercase_tags__preg_callback', $parts[$i]); + + $mistake_fixes = array( + // Find [table]s not followed by [tr]. + '~\[table\](?![\s' . $non_breaking_space . ']*\[tr\])~s' . ($context['utf8'] ? 'u' : '') => '[table][tr]', + // Find [tr]s not followed by [td]. + '~\[tr\](?![\s' . $non_breaking_space . ']*\[td\])~s' . ($context['utf8'] ? 'u' : '') => '[tr][td]', + // Find [/td]s not followed by something valid. + '~\[/td\](?![\s' . $non_breaking_space . ']*(?:\[td\]|\[/tr\]|\[/table\]))~s' . ($context['utf8'] ? 'u' : '') => '[/td][/tr]', + // Find [/tr]s not followed by something valid. + '~\[/tr\](?![\s' . $non_breaking_space . ']*(?:\[tr\]|\[/table\]))~s' . ($context['utf8'] ? 'u' : '') => '[/tr][/table]', + // Find [/td]s incorrectly followed by [/table]. + '~\[/td\][\s' . $non_breaking_space . ']*\[/table\]~s' . ($context['utf8'] ? 'u' : '') => '[/td][/tr][/table]', + // Find [table]s, [tr]s, and [/td]s (possibly correctly) followed by [td]. + '~\[(table|tr|/td)\]([\s' . $non_breaking_space . ']*)\[td\]~s' . ($context['utf8'] ? 'u' : '') => '[$1]$2[_td_]', + // Now, any [td]s left should have a [tr] before them. + '~\[td\]~s' => '[tr][td]', + // Look for [tr]s which are correctly placed. + '~\[(table|/tr)\]([\s' . $non_breaking_space . ']*)\[tr\]~s' . ($context['utf8'] ? 'u' : '') => '[$1]$2[_tr_]', + // Any remaining [tr]s should have a [table] before them. + '~\[tr\]~s' => '[table][tr]', + // Look for [/td]s followed by [/tr]. + '~\[/td\]([\s' . $non_breaking_space . ']*)\[/tr\]~s' . ($context['utf8'] ? 'u' : '') => '[/td]$1[_/tr_]', + // Any remaining [/tr]s should have a [/td]. + '~\[/tr\]~s' => '[/td][/tr]', + // Look for properly opened [li]s which aren't closed. + '~\[li\]([^\[\]]+?)\[li\]~s' => '[li]$1[_/li_][_li_]', + '~\[li\]([^\[\]]+?)$~s' => '[li]$1[/li]', + // Lists - find correctly closed items/lists. + '~\[/li\]([\s' . $non_breaking_space . ']*)\[/list\]~s' . ($context['utf8'] ? 'u' : '') => '[_/li_]$1[/list]', + // Find list items closed and then opened. + '~\[/li\]([\s' . $non_breaking_space . ']*)\[li\]~s' . ($context['utf8'] ? 'u' : '') => '[_/li_]$1[_li_]', + // Now, find any [list]s or [/li]s followed by [li]. + '~\[(list(?: [^\]]*?)?|/li)\]([\s' . $non_breaking_space . ']*)\[li\]~s' . ($context['utf8'] ? 'u' : '') => '[$1]$2[_li_]', + // Any remaining [li]s weren't inside a [list]. + '~\[li\]~' => '[list][li]', + // Any remaining [/li]s weren't before a [/list]. + '~\[/li\]~' => '[/li][/list]', + // Put the correct ones back how we found them. + '~\[_(li|/li|td|tr|/tr)_\]~' => '[$1]', + ); + + // Fix up some use of tables without [tr]s, etc. (it has to be done more than once to catch it all.) + for ($j = 0; $j < 3; $j++) + $parts[$i] = preg_replace(array_keys($mistake_fixes), $mistake_fixes, $parts[$i]); + } + } + + // Put it back together! + if (!$previewing) + $message = strtr(implode('', $parts), array(' ' => '  ', "\n" => '
    ', $context['utf8'] ? "\xC2\xA0" : "\xA0" => ' ')); + else + $message = strtr(implode('', $parts), array(' ' => '  ', $context['utf8'] ? "\xC2\xA0" : "\xA0" => ' ')); + + // Now let's quickly clean up things that will slow our parser (which are common in posted code.) + $message = strtr($message, array('[]' => '[]', '['' => '['')); +} + +// This is very simple, and just removes things done by preparsecode. +function un_preparsecode($message) +{ + $parts = preg_split('~(\[/code\]|\[code(?:=[^\]]+)?\])~i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); + + // We're going to unparse only the stuff outside [code]... + for ($i = 0, $n = count($parts); $i < $n; $i++) + { + // If $i is a multiple of four (0, 4, 8, ...) then it's not a code section... + if ($i % 4 == 0) + { + $parts[$i] = preg_replace_callback('~\[html\](.+?)\[/html\]~i', function($m){return '[html]' . strtr(htmlspecialchars(stripslashes($m[1]), ENT_QUOTES), array('&#13;' => '
    ', '&#32;' => ' ')) . '[/html]';}, $parts[$i]); + + // Attempt to un-parse the time to something less awful. + $parts[$i] = preg_replace_callback('~\[time\](\d{0,10})\[/time\]~i', 'time_format__preg_callback', $parts[$i]); + } + } + + // Change breaks back to \n's and &nsbp; back to spaces. + return preg_replace('~~', "\n", str_replace(' ', ' ', implode('', $parts))); +} + +function mime_convert__preg_callback($matches) +{ + $c = $matches[1]; + if (strlen($c) === 1 && ord($c[0]) <= 0x7F) + return $c; + elseif (strlen($c) === 2 && ord($c[0]) >= 0xC0 && ord($c[0]) <= 0xDF) + return '&#' . (((ord($c[0]) ^ 0xC0) << 6) + (ord($c[1]) ^ 0x80)) . ';'; + elseif (strlen($c) === 3 && ord($c[0]) >= 0xE0 && ord($c[0]) <= 0xEF) + return '&#' . (((ord($c[0]) ^ 0xE0) << 12) + ((ord($c[1]) ^ 0x80) << 6) + (ord($c[2]) ^ 0x80)) . ';'; + elseif (strlen($c) === 4 && ord($c[0]) >= 0xF0 && ord($c[0]) <= 0xF7) + return '&#' . (((ord($c[0]) ^ 0xF0) << 18) + ((ord($c[1]) ^ 0x80) << 12) + ((ord($c[2]) ^ 0x80) << 6) + (ord($c[3]) ^ 0x80)) . ';'; + else + return ''; +} + +function time_fix__preg_callback($matches) +{ + global $modSettings, $user_info; + return '[time]' . (is_numeric($matches[2]) || @strtotime($matches[2]) == 0 ? $matches[2] : strtotime($matches[2]) - ($matches[1] == 'absolute' ? 0 : (($modSettings['time_offset'] + $user_info['time_offset']) * 3600))) . '[/time]'; +} + +function nobbc__preg_callback($matches) +{ + return '[nobbc]' . strtr($matches[1], array('[' => '[', ']' => ']', ':' => ':', '@' => '@')) . '[/nobbc]'; +} + +function lowercase_tags__preg_callback($matches) +{ + return '[' . $matches[1] . strtolower($matches[2]) . $matches[3] . ']'; +} + +function htmlspecial_html__preg_callback($matches) +{ + global $modSettings, $txt; + static $charset = null; + if ($charset === null) + $charset = empty($modSettings['global_character_set']) ? $txt['lang_character_set'] : $modSettings['global_character_set']; + + return '[html]' . strtr(htmlspecialchars($matches[1], ENT_QUOTES, $charset), array('\\"' => '"', '&#13;' => '
    ', '&#32;' => ' ', '&#91;' => '[', '&#93;' => ']')) . '[/html]'; +} + +function time_format__preg_callback($matches) +{ + return '[time]' . timeformat($matches[1], false) . '[/time]'; +} +function word_break__preg_callback($matches) +{ + global $modSettings, $context; + return preg_replace('~(.{' . ($modSettings['fixLongWords'] - 1) . '})~' . ($context['utf8'] ? 'u' : ''), '$1< >', $matches[1]); +} + +//this would normally transform the URL into a proxied URL, but here it does nothing +function proxyurl($url) { + return $url; +} + +?> diff --git a/parsing_extra.php b/parsing_extra.php new file mode 100644 index 00000000..2491ab44 --- /dev/null +++ b/parsing_extra.php @@ -0,0 +1,656 @@ +array()); + $context['browser']['is_gecko'] = true; + $context['browser']['is_konqueror'] = false; + $context['browser']['is_opera'] = false; + $context['browser']['is_ie'] = false; + $context['browser']['is_ie4'] = false; + $context['browser']['is_ie5'] = false; + $context['browser']['is_ie5.5'] = false; + + // Fix for portuguese characters (test: http://localhost:8000/threads/5515847) + $context['utf8'] = true; + $context['server']['complex_preg_chars'] = true; + + $txt['lang_character_set'] = 'ISO-8859-1'; + $txt['smf238'] = 'Code'; + $txt['smf240'] = 'Quote'; + $txt['smf239'] = 'Quote from'; + $txt[176] = 'on'; + $txt['lang_locale'] = 'en_US'; + $txt[289] = 'Cheesy'; + $txt[450] = 'Roll Eyes'; + $txt[288] = 'Angry'; + $txt[287] = 'Smiley'; + $txt[292] = 'Wink'; + $txt[293] = 'Grin'; + $txt[291] = 'Sad'; + $txt[294] = 'Shocked'; + $txt[295] = 'Cool'; + $txt[451] = 'Tongue'; + $txt[296] = 'Huh'; + $txt[526] = 'Embarrassed'; + $txt[527] = 'Lips sealed'; + $txt[529] = 'Kiss'; + $txt[530] = 'Cry'; + $txt[528] = 'Undecided'; + $txt['smf10'] = 'Today at '; + $txt['smf10b'] = 'Yesterday at '; + $txt['days_short'] = array('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'); + $txt['days'] = array('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'); + $txt['months_short'] = array(1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'); + $txt['months'] = array(1 => 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'); + + $modSettings['enableBBC'] = 1; + $modSettings['cache_enable'] = 0; + $modSettings['enablePostHTML'] = 0; + $modSettings['max_image_width'] = 0; + $modSettings['max_image_height'] = 0; + $modSettings['autoLinkUrls'] = 1; + $modSettings['fixLongWords'] = 80; + $modSettings['smileys_url'] = 'https://bitcointalk.org/Smileys'; + $modSettings['time_offset'] = 0; + $modSettings['todayMod'] = 1; + + // Need these from theymos + $user_info['smiley_set'] = 'default'; + $user_info['time_offset'] = 0; + $user_info['time_format'] = '%I:%M:%S %p'; +} + +//the cache functions are highly implementation-dependant, so here they are just no-ops +function cache_put_data($key, $value, $ttl = 120) +{ + return; +} + +function cache_get_data($key, $ttl = 120) +{ + return; +} + +function highlight_php_code($code) +{ + global $context; + + // Remove special characters. + $code = un_htmlspecialchars(strtr($code, array('
    ' => "\n", "\t" => 'SMF_TAB();', '[' => '['))); + + $oldlevel = error_reporting(0); + + // It's easier in 4.2.x+. + if (@version_compare(PHP_VERSION, '4.2.0') == -1) + { + ob_start(); + @highlight_string($code); + $buffer = str_replace(array("\n", "\r"), '', ob_get_contents()); + ob_end_clean(); + } + else + $buffer = str_replace(array("\n", "\r"), '', @highlight_string($code, true)); + + error_reporting($oldlevel); + + // Yes, I know this is kludging it, but this is the best way to preserve tabs from PHP :P. + $buffer = preg_replace('~SMF_TAB(<(font color|span style)="[^"]*?">)?\(\);~', "
    \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 + */ +function strftime_updated(string $format, $timestamp = null, ?string $locale = null): string +{ + if (null === $timestamp) { + $timestamp = new \DateTime; + } + elseif (is_numeric($timestamp)) { + $timestamp = date_create('@' . $timestamp); + + if ($timestamp) { + $timestamp->setTimezone(new \DateTimezone(date_default_timezone_get())); + } + } + elseif (is_string($timestamp)) { + $timestamp = date_create($timestamp); + } + + if (!($timestamp instanceof \DateTimeInterface)) { + throw new \InvalidArgumentException('$timestamp argument is neither a valid UNIX timestamp, a valid date-time string or a DateTime object.'); + } + + $locale = substr((string) $locale, 0, 5); + + $intl_formats = [ + '%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat + '%A' => 'EEEE', // A full textual representation of the day Sunday through Saturday + '%b' => 'MMM', // Abbreviated month name, based on the locale Jan through Dec + '%B' => 'MMMM', // Full month name, based on the locale January through December + '%h' => 'MMM', // Abbreviated month name, based on the locale (an alias of %b) Jan through Dec + ]; + + $intl_formatter = function (\DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale) { + $tz = $timestamp->getTimezone(); + $date_type = \IntlDateFormatter::FULL; + $time_type = \IntlDateFormatter::FULL; + $pattern = ''; + + // %c = Preferred date and time stamp based on locale + // Example: Tue Feb 5 00:45:10 2009 for February 5, 2009 at 12:45:10 AM + if ($format == '%c') { + $date_type = \IntlDateFormatter::LONG; + $time_type = \IntlDateFormatter::SHORT; + } + // %x = Preferred date representation based on locale, without the time + // Example: 02/05/09 for February 5, 2009 + elseif ($format == '%x') { + $date_type = \IntlDateFormatter::SHORT; + $time_type = \IntlDateFormatter::NONE; + } + // Localized time format + elseif ($format == '%X') { + $date_type = \IntlDateFormatter::NONE; + $time_type = \IntlDateFormatter::MEDIUM; + } + else { + $pattern = $intl_formats[$format]; + } + + return (new \IntlDateFormatter($locale, $date_type, $time_type, $tz, null, $pattern))->format($timestamp); + }; + + // Same order as https://www.php.net/manual/en/function.strftime_updated.php + $translation_table = [ + // Day + '%a' => $intl_formatter, + '%A' => $intl_formatter, + '%d' => 'd', + '%e' => function ($timestamp) { + return sprintf('% 2u', $timestamp->format('j')); + }, + '%j' => function ($timestamp) { + // Day number in year, 001 to 366 + return sprintf('%03d', $timestamp->format('z')+1); + }, + '%u' => 'N', + '%w' => 'w', + + // Week + '%U' => function ($timestamp) { + // Number of weeks between date and first Sunday of year + $day = new \DateTime(sprintf('%d-01 Sunday', $timestamp->format('Y'))); + return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7); + }, + '%V' => 'W', + '%W' => function ($timestamp) { + // Number of weeks between date and first Monday of year + $day = new \DateTime(sprintf('%d-01 Monday', $timestamp->format('Y'))); + return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7); + }, + + // Month + '%b' => $intl_formatter, + '%B' => $intl_formatter, + '%h' => $intl_formatter, + '%m' => 'm', + + // Year + '%C' => function ($timestamp) { + // Century (-1): 19 for 20th century + return floor($timestamp->format('Y') / 100); + }, + '%g' => function ($timestamp) { + return substr($timestamp->format('o'), -2); + }, + '%G' => 'o', + '%y' => 'y', + '%Y' => 'Y', + + // Time + '%H' => 'H', + '%k' => function ($timestamp) { + return sprintf('% 2u', $timestamp->format('G')); + }, + '%I' => 'h', + '%l' => function ($timestamp) { + return sprintf('% 2u', $timestamp->format('g')); + }, + '%M' => 'i', + '%p' => 'A', // AM PM (this is reversed on purpose!) + '%P' => 'a', // am pm + '%r' => 'h:i:s A', // %I:%M:%S %p + '%R' => 'H:i', // %H:%M + '%S' => 's', + '%T' => 'H:i:s', // %H:%M:%S + '%X' => $intl_formatter, // Preferred time representation based on locale, without the date + + // Timezone + '%z' => 'O', + '%Z' => 'T', + + // Time and Date Stamps + '%c' => $intl_formatter, + '%D' => 'm/d/Y', + '%F' => 'Y-m-d', + '%s' => 'U', + '%x' => $intl_formatter, + ]; + + $out = preg_replace_callback('/(?format($replace); + } + else { + return $replace($timestamp, $match[1]); + } + }, $format); + + $out = str_replace('%%', '%', $out); + return $out; +} + + +?> diff --git a/priv/repo/migrations/20231110161916_add_sequences.exs b/priv/repo/migrations/20231110161916_add_sequences.exs new file mode 100644 index 00000000..b8e68b64 --- /dev/null +++ b/priv/repo/migrations/20231110161916_add_sequences.exs @@ -0,0 +1,10 @@ +defmodule EpochtalkServer.Repo.Migrations.AddSequences do + use Ecto.Migration + + def change do + execute "ALTER SEQUENCE categories_id_seq RESTART WITH 50;" + execute "ALTER SEQUENCE boards_id_seq RESTART WITH 500;" + execute "ALTER SEQUENCE threads_id_seq RESTART WITH 6000000;" + execute "ALTER SEQUENCE posts_id_seq RESTART WITH 60000000;" + end +end