# frozen_string_literal: true

RSpec.describe StaticController do
  fab!(:upload)

  describe "#favicon" do
    fab!(:user)

    let(:filename) { "smallest.png" }
    let(:file) { file_from_fixtures(filename) }

    let(:upload) { UploadCreator.new(file, filename).create_for(Discourse.system_user.id) }

    after { Discourse.redis.scan_each(match: "memoize_*").each { |key| Discourse.redis.del(key) } }

    context "with local store" do
      it "returns the default favicon if favicon has not been configured" do
        get "/favicon/proxied"

        expect(response.status).to eq(200)
        expect(response.media_type).to eq("image/png")
        expect(response.body.bytesize).to eq(SiteIconManager.favicon.filesize)
      end

      it "does not include a username in the cacheable response" do
        sign_in(user)

        get "/favicon/proxied"

        expect(response.status).to eq(200)
        expect(response.headers["X-Discourse-Username"]).to be_nil
      end

      it "returns the configured favicon" do
        SiteSetting.favicon = upload

        get "/favicon/proxied"

        expect(response.status).to eq(200)
        expect(response.media_type).to eq("image/png")
        expect(response.body.bytesize).to eq(upload.filesize)
      end
    end

    context "with external store" do
      let(:upload) do
        Upload.create!(
          url: "//s3-upload-bucket.s3-us-east-1.amazonaws.com/somewhere/a.png",
          original_filename: filename,
          filesize: file.size,
          user_id: Discourse.system_user.id,
        )
      end

      before { setup_s3 }

      it "can proxy a favicon correctly" do
        SiteSetting.favicon = upload

        stub_request(:get, "https:/#{upload.url}").to_return(status: 200, body: file)

        get "/favicon/proxied"

        expect(response.status).to eq(200)
        expect(response.media_type).to eq("image/png")
        expect(response.body.bytesize).to eq(upload.filesize)
      end

      context "when favicon fails to load" do
        before { FileHelper.stubs(:download).raises(SocketError) }

        it "creates an admin notice" do
          expect { get "/favicon/proxied" }.to change { AdminNotice.problem.count }.by(1)
        end
      end
    end
  end

  describe "#cdn_asset" do
    let(:site) { RailsMultisite::ConnectionManagement.current_db }

    it "can serve assets" do
      assets_path = Rails.public_path.join("assets")

      FileUtils.mkdir_p(assets_path)

      file_path = assets_path.join("test.js.br")
      File.write(file_path, "fake brotli file")

      get "/cdn_asset/#{site}/test.js.br"

      expect(response.status).to eq(200)
      expect(response.headers["Cache-Control"]).to match(/public/)
    ensure
      File.delete(file_path)
    end

    it "does not serve files outside the assets directory via path traversal" do
      secret_dir = Rails.public_path.join("assets-secret")
      FileUtils.mkdir_p(secret_dir)
      secret_file = secret_dir.join("leak.txt")
      File.write(secret_file, "secret content")

      get "/cdn_asset/#{site}/../assets-secret/leak.txt"

      expect(response.status).to eq(404)
    ensure
      File.delete(secret_file) if secret_file && File.exist?(secret_file)
      FileUtils.rm_rf(secret_dir) if secret_dir && Dir.exist?(secret_dir)
    end

    context "with fallback_assets_path" do
      it "serves files from the fallback assets directory" do
        Dir.mktmpdir do |tmpdir|
          fallback_dir = File.join(tmpdir, "fallback_assets")
          FileUtils.mkdir_p(fallback_dir)

          File.write(File.join(fallback_dir, "test-asset.js"), "fallback js content")

          GlobalSetting.stubs(:fallback_assets_path).returns(fallback_dir)

          get "/cdn_asset/#{site}/test-asset.js"

          expect(response.status).to eq(200)
          expect(response.headers["Cache-Control"]).to match(/public/)
          expect(response.body).to eq("fallback js content")
        end
      end

      it "returns 404 for files not in primary or fallback" do
        Dir.mktmpdir do |tmpdir|
          fallback_dir = File.join(tmpdir, "fallback_assets")
          FileUtils.mkdir_p(fallback_dir)

          GlobalSetting.stubs(:fallback_assets_path).returns(fallback_dir)

          get "/cdn_asset/#{site}/nonexistent.js"

          expect(response.status).to eq(404)
        end
      end

      it "rejects fallback paths that traverse outside the fallback directory" do
        Dir.mktmpdir do |tmpdir|
          fallback_dir = File.join(tmpdir, "fallback_assets")
          FileUtils.mkdir_p(fallback_dir)

          File.write(File.join(fallback_dir, "test-asset.js"), "fallback js content")

          GlobalSetting.stubs(:fallback_assets_path).returns(fallback_dir)

          get "/cdn_asset/#{site}/../test-asset.js"

          expect(response.status).to eq(404)
          expect(response.body).not_to eq("fallback js content")
        end
      end
    end
  end

  describe "#show" do
    before do
      post = create_post
      SiteSetting.tos_topic_id = post.topic.id
      SiteSetting.guidelines_topic_id = post.topic.id
      SiteSetting.privacy_topic_id = post.topic.id
    end

    context "with a static file that's present" do
      it "returns the expected response for /faq" do
        get "/faq"

        expect(response).to redirect_to("/guidelines")

        get "/guidelines"

        expect(response.status).to eq(200)
        expect(response.body).to include(I18n.t("js.guidelines"))
        expect(response.body).to include("<title>Guidelines - Discourse</title>")
      end
    end

    [
      ["tos", :tos_url, I18n.t("js.tos")],
      ["privacy", :privacy_policy_url, I18n.t("js.privacy")],
    ].each do |id, setting_name, text|
      context "with #{id}" do
        context "when #{setting_name} site setting is NOT set" do
          it "renders the #{id} page" do
            get "/#{id}"

            expect(response.status).to eq(200)
            expect(response.body).to include(text)
          end
        end

        context "when #{setting_name} site setting is set" do
          before { SiteSetting.set(setting_name, "http://example.com/page") }

          it "redirects to the #{setting_name}" do
            get "/#{id}"

            expect(response).to redirect_to("http://example.com/page")
          end
        end
      end
    end

    it "renders the localized guidelines post" do
      SiteSetting.content_localization_enabled = true
      SiteSetting.set_locale_from_param = true
      post = create_post(raw: "Original guidelines body")
      post.update!(locale: "en")
      localization =
        Fabricate(
          :post_localization,
          post:,
          locale: "ja",
          raw: "翻訳されたガイドライン本文",
          cooked: "<p>翻訳されたガイドライン本文</p>",
        )
      SiteSetting.guidelines_topic_id = post.topic.id

      get "/guidelines", params: { tl: "ja" }

      aggregate_failures do
        expect(response.status).to eq(200)
        expect(response.body).to include(localization.cooked)
        expect(response.body).not_to include(post.cooked)
      end
    end

    it "renders the localized TOS post" do
      SiteSetting.content_localization_enabled = true
      SiteSetting.set_locale_from_param = true
      post = create_post(raw: "Original TOS body")
      post.update!(locale: "en")
      localization =
        Fabricate(
          :post_localization,
          post:,
          locale: "ja",
          raw: "翻訳された利用規約本文",
          cooked: "<p>翻訳された利用規約本文</p>",
        )
      SiteSetting.tos_topic_id = post.topic.id

      get "/tos", params: { tl: "ja" }

      aggregate_failures do
        expect(response.status).to eq(200)
        expect(response.body).to include(localization.cooked)
        expect(response.body).not_to include(post.cooked)
      end
    end

    context "with a missing file" do
      it "responds with 404" do
        get "/static/does-not-exist"
        expect(response.status).to eq(404)
      end

      context "with modal pages" do
        it "returns the expected response for /signup" do
          get "/signup"
          expect(response.status).to eq(200)
        end

        it "returns the expected response for /password-reset" do
          get "/password-reset"
          expect(response.status).to eq(200)
        end
      end
    end

    it "redirects a logged-in user from /login to /" do
      sign_in(Fabricate(:user))
      get "/login"
      expect(response).to redirect_to("/")
    end

    it "displays the login template when login is required" do
      SiteSetting.login_required = true

      get "/login"

      expect(response.status).to eq(200)

      expect(response.body).to include(
        PrettyText.cook(I18n.t("login_required.welcome_message", title: SiteSetting.title)),
      )
    end

    context "when login_required is enabled" do
      before { SiteSetting.login_required = true }

      %w[faq guidelines rules conduct].each do |page_name|
        it "#{page_name} page redirects to login page for anon" do
          get "/#{page_name}"
          expect(response).to redirect_to "/login"
        end
      end

      it "guidelines page loads for logged in user" do
        sign_in(Fabricate(:user))

        get "/guidelines"

        expect(response.status).to eq(200)
        expect(response.body).to include(I18n.t("js.guidelines"))
      end

      %w[faq rules conduct].each do |page_name|
        it "#{page_name} page redirects to guidelines for logged in user" do
          sign_in(Fabricate(:user))

          get "/#{page_name}"

          expect(response).to redirect_to("/guidelines")
        end
      end
    end

    context "with crawler view" do
      it "includes the correct title" do
        get "/guidelines", headers: { "HTTP_USER_AGENT" => "Googlebot" }
        expect(response.status).to eq(200)
        expect(response.body).to include("<title>Guidelines - Discourse</title>")
      end
    end

    context "with plugin api extensions" do
      after do
        Rails.application.reload_routes!
        StaticController::CUSTOM_PAGES.clear
      end

      it "adds new topic-backed pages" do
        routes = Proc.new { get "contact" => "static#show", :id => "contact" }
        Discourse::Application.routes.send(:eval_block, routes)

        topic_id = Fabricate(:post, cooked: "contact info").topic_id
        SiteSetting.test_some_topic_id = topic_id

        Plugin::Instance.new.add_topic_static_page("contact", topic_id: "test_some_topic_id")

        get "/contact"

        expect(response.status).to eq(200)
        expect(response.body).to include("contact info")
      end

      it "replaces existing topic-backed pages" do
        topic_id = Fabricate(:post, cooked: "Regular FAQ").topic_id
        SiteSetting.test_some_topic_id = topic_id

        polish_topic_id = Fabricate(:post, cooked: "Polish FAQ").topic_id
        SiteSetting.test_some_other_topic_id = polish_topic_id

        Plugin::Instance
          .new
          .add_topic_static_page("faq") do
            current_user&.locale == "pl" ? "test_some_other_topic_id" : "test_some_topic_id"
          end

        get "/guidelines"

        expect(response.status).to eq(200)
        expect(response.body).to include("Regular FAQ")

        sign_in(Fabricate(:user, locale: "pl"))
        get "/guidelines"

        expect(response.status).to eq(200)
        expect(response.body).to include("Polish FAQ")
      end
    end

    it "does not pollute SiteSetting.title (regression)" do
      SiteSetting.title = "test"
      SiteSetting.short_site_description = "something"

      expect do
        get "/login"
        get "/login"
      end.to_not change { SiteSetting.title }
    end

    context "without a subfolder" do
      it "redirects as requested when logged in and path is /login with valid redirect param" do
        sign_in(Fabricate(:user))
        get "/login", params: { redirect: "/foo" }
        expect(response).to redirect_to("/foo")
      end

      it "redirects to / when logged in and path is /login with invalid redirect param" do
        sign_in(Fabricate(:user))
        get "/login", params: { redirect: "//foo" }
        expect(response).to redirect_to("/")
        get "/login", params: { redirect: "foo" }
        expect(response).to redirect_to("/")
        get "/login", params: { redirect: "http://foo" }
        expect(response).to redirect_to("/")
        get "/login", params: { redirect: "www.foo.bar" }
        expect(response).to redirect_to("/")
      end

      context "when setting the destination_url cookie" do
        it "respects the redirect parameter in the cookie" do
          get "/login", params: { redirect: "/foo" }
          expect(response.cookies["destination_url"]).to eq("/foo")
        end

        it "respects the redirect parameter including the query params" do
          get "/login", params: { redirect: "/foo?filter=test" }
          expect(response.cookies["destination_url"]).to eq("/foo?filter=test")
        end
      end
    end

    context "with a subfolder" do
      before { set_subfolder "/sub_test" }

      it "redirects as requested when logged in and path is /login with valid redirect param" do
        sign_in(Fabricate(:user))
        get "/login", params: { redirect: "/foo" }
        expect(response).to redirect_to("/sub_test/foo")
      end

      it "redirects to / when logged in and path is /login with invalid redirect param" do
        sign_in(Fabricate(:user))
        get "/login", params: { redirect: "//foo" }
        expect(response).to redirect_to("/sub_test/")
        get "/login", params: { redirect: "foo" }
        expect(response).to redirect_to("/sub_test/")
        get "/login", params: { redirect: "http://foo" }
        expect(response).to redirect_to("/sub_test/")
        get "/login", params: { redirect: "www.foo.bar" }
        expect(response).to redirect_to("/sub_test/")
      end

      context "when sets the destination_url cookie" do
        it "respects the redirect parameter in the cookie" do
          get "/login", params: { redirect: "/foo" }
          expect(response.cookies["destination_url"]).to eq("/sub_test/foo")
        end

        it "respects the redirect parameter including the query params" do
          get "/login", params: { redirect: "/foo?filter=test" }
          expect(response.cookies["destination_url"]).to eq("/sub_test/foo?filter=test")
        end
      end
    end
  end

  describe "#enter" do
    context "without a redirect path" do
      it "redirects to the root url" do
        post "/login.json"
        expect(response).to redirect_to("/")
      end
    end

    context "with a redirect path" do
      it "redirects to the redirect path" do
        post "/login.json", params: { redirect: "/foo" }
        expect(response).to redirect_to("/foo")
      end
    end

    context "with a full url" do
      it "redirects to the correct path" do
        post "/login.json", params: { redirect: "#{Discourse.base_url}/foo" }
        expect(response).to redirect_to("/foo")
      end
    end

    context "with a redirect path with query params" do
      it "redirects to the redirect path and preserves query params" do
        post "/login.json", params: { redirect: "/foo?bar=1" }
        expect(response).to redirect_to("/foo?bar=1")
      end
    end

    context "with a period to force a new host" do
      it "redirects to the root path" do
        post "/login.json", params: { redirect: ".org/foo" }
        expect(response).to redirect_to("/")
      end
    end

    context "with a full url to an external host" do
      it "redirects to the root path" do
        post "/login.json", params: { redirect: "http://eviltrout.com/foo" }
        expect(response).to redirect_to("/")
      end
    end

    context "with an invalid URL" do
      it "redirects to the root" do
        post "/login.json", params: { redirect: "javascript:alert('trout')" }
        expect(response).to redirect_to("/")
      end
    end

    context "with an array" do
      it "redirects to the root" do
        post "/login.json", params: { redirect: ["/foo"] }
        expect(response).to redirect_to("/")
      end
    end

    context "when the redirect path is the login page" do
      it "redirects to the root url" do
        post "/login.json", params: { redirect: login_path }
        expect(response).to redirect_to("/")
      end
    end

    context "when the redirect path contains the '/login' string" do
      it "redirects to the requested path" do
        post "/login.json", params: { redirect: "/page/login/1" }
        expect(response).to redirect_to("/page/login/1")
      end
    end

    context "when the redirect path is invalid" do
      it "redirects to the root URL" do
        post "/login.json", params: { redirect: "test" }
        expect(response).to redirect_to("/")
      end
    end

    context "with a subfolder" do
      before { set_subfolder "/sub_test" }

      context "without a redirect path" do
        it "redirects to the subfolder root" do
          post "/login.json"
          expect(response).to redirect_to("/sub_test/")
        end
      end

      context "when the redirect path is the login page" do
        it "redirects to the subfolder root" do
          post "/login.json", params: { redirect: "#{Discourse.base_path}/login" }
          expect(response).to redirect_to("/sub_test/")
        end
      end

      context "when the redirect path is invalid" do
        it "redirects to the subfolder root" do
          post "/login.json", params: { redirect: "test" }
          expect(response).to redirect_to("/sub_test/")
        end
      end
    end

    context "with sso_destination_url cookie" do
      before { SiteSetting.enable_discourse_connect_provider = true }

      it "redirects to valid SSO destination URL when provider is configured" do
        SiteSetting.discourse_connect_provider_secrets = "allowed-site.com|secret123"
        cookies[:sso_destination_url] = "https://allowed-site.com/sso?token=abc"

        post "/login.json"

        expect(response).to redirect_to("https://allowed-site.com/sso?token=abc")
        expect(response.cookies["sso_destination_url"]).to be_nil
      end

      it "redirects to valid SSO destination URL with wildcard domain" do
        SiteSetting.discourse_connect_provider_secrets = "*.allowed-domain.com|secret123"
        cookies[:sso_destination_url] = "https://sub.allowed-domain.com/sso?token=abc"

        post "/login.json"

        expect(response).to redirect_to("https://sub.allowed-domain.com/sso?token=abc")
      end

      it "ignores SSO destination URL when domain is not in provider secrets" do
        SiteSetting.discourse_connect_provider_secrets = "allowed-site.com|secret123"
        cookies[:sso_destination_url] = "https://evil-site.com/phishing"

        post "/login.json"

        expect(response).to redirect_to("/")
        expect(response.cookies["sso_destination_url"]).to be_nil
      end

      it "ignores SSO destination URL when provider secrets is empty" do
        SiteSetting.discourse_connect_provider_secrets = ""
        cookies[:sso_destination_url] = "https://some-site.com/sso"

        post "/login.json"

        expect(response).to redirect_to("/")
      end

      it "ignores malformed SSO destination URL" do
        SiteSetting.discourse_connect_provider_secrets = "allowed-site.com|secret123"
        cookies[:sso_destination_url] = "not a valid url"

        post "/login.json"

        expect(response).to redirect_to("/")
      end

      it "ignores SSO destination URL when discourse_connect_provider is disabled" do
        SiteSetting.enable_discourse_connect_provider = false
        SiteSetting.discourse_connect_provider_secrets = "allowed-site.com|secret123"
        cookies[:sso_destination_url] = "https://allowed-site.com/sso"

        post "/login.json"

        expect(response).to redirect_to("/")
      end

      it "deletes sso_destination_url cookie regardless of validity" do
        SiteSetting.discourse_connect_provider_secrets = "allowed-site.com|secret123"
        cookies[:sso_destination_url] = "https://evil-site.com/phishing"

        post "/login.json"

        expect(response.cookies["sso_destination_url"]).to be_nil
      end
    end
  end

  describe "#service_worker_asset" do
    fab!(:user)

    it "renders the requested static page" do
      get "/service-worker.js"
      expect(response.status).to eq(200)
      expect(response.content_type).to start_with("text/javascript")
      expect(response.body).to include("addEventListener")
    end

    it "does not include a username in the cacheable response" do
      sign_in(user)

      get "/service-worker.js"

      expect(response.status).to eq(200)
      expect(response.headers["X-Discourse-Username"]).to be_nil
    end
  end

  describe "#llms_txt" do
    def create_llms_upload(content)
      file = Tempfile.new(%w[llms .txt])
      file.binmode
      file.write(content)
      file.rewind
      UploadCreator.new(file, "llms.txt").create_for(Discourse.system_user.id)
    ensure
      file&.close!
    end

    it "returns 404 when an admin opts out of the generated document" do
      SiteSetting.enable_generated_llms_txt = false

      get "/llms.txt"

      expect(response.status).to eq(404)
    end

    it "returns the generated document when the beta change is auto-promoted" do
      SiteSetting.promote_upcoming_changes_on_status = "beta"

      get "/llms.txt"

      expect(response.status).to eq(200)
      expect(response.content_type).to eq("text/plain; charset=utf-8")
      expect(response.body).to start_with("# #{SiteSetting.title}\n")
    end

    it "returns the same generated document for anonymous and signed-in requesters" do
      get "/llms.txt"
      anonymous_body = response.body

      sign_in(Fabricate(:user))
      get "/llms.txt"
      expect(response.body).to eq(anonymous_body)

      sign_in(Fabricate(:admin))
      get "/llms.txt"
      expect(response.body).to eq(anonymous_body)
    end

    it "omits the public search link when anonymous users cannot search" do
      SiteSetting.allow_anonymous_search = false

      get "/llms.txt"

      expect(response.status).to eq(200)
      expect(response.body).not_to include("#{Discourse.base_path}/search")
      expect(response.body).to include("#{Discourse.base_path}/latest")
    end

    context "with local store" do
      before { SiteSetting.authorized_extensions = "txt" }

      it "serves exact custom bytes regardless of the generated-document rollout" do
        custom_content = "# Custom LLMs Content\nCafé\n"
        SiteSetting.llms_txt = create_llms_upload(custom_content)

        get "/llms.txt"
        expect(response.status).to eq(200)
        expect(response.body.bytes).to eq(custom_content.bytes)

        SiteSetting.enable_generated_llms_txt = false
        get "/llms.txt"

        expect(response.status).to eq(200)
        expect(response.content_type).to start_with("text/plain")
        expect(response.body.bytes).to eq(custom_content.bytes)
      end

      it "returns 404 instead of generated content when the configured file is unavailable" do
        configured_upload = create_llms_upload("custom")
        SiteSetting.llms_txt = configured_upload
        File.delete(Discourse.store.path_for(configured_upload))

        get "/llms.txt"

        expect(response.status).to eq(404)
      end
    end

    context "with external store" do
      let(:external_content) { "external Café\n" }
      let(:external_upload) do
        Upload.create!(
          url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/llms.txt",
          original_filename: "llms.txt",
          filesize: external_content.bytesize,
          sha1: SecureRandom.hex(20),
          user_id: Discourse.system_user.id,
        )
      end

      before do
        SiteSetting.authorized_extensions = "txt"
        setup_s3
        SiteSetting.llms_txt = external_upload
      end

      it "downloads and serves exact custom bytes" do
        file = Tempfile.new(%w[external-llms .txt])
        file.binmode
        file.write(external_content)
        file.rewind
        FileHelper.stubs(:download).returns(file)

        get "/llms.txt"

        expect(response.status).to eq(200)
        expect(response.body.bytes).to eq(external_content.bytes)
      ensure
        file&.close!
      end

      it "returns 404 instead of generated content when the download is missing" do
        FileHelper.stubs(:download).returns(nil)

        get "/llms.txt"

        expect(response.status).to eq(404)
      end
    end

    it "does not redirect on login-required sites and omits public discovery links" do
      SiteSetting.login_required = true

      get "/llms.txt"

      expect(response.status).to eq(200)
      expect(response.body).to include(I18n.t("llms_txt.account_required"))
      expect(response.body).not_to include(
        "## Public web access",
        "/search",
        "/filter",
        "/latest",
        "/categories",
        "/sitemap.xml",
        "/about",
        "/guidelines",
      )
    end
  end
end
