Skip to content

Commit 38858cc

Browse files
authored
Merge pull request #743 from Hubs-Foundation/icosa-cleanup-2
Replacing google poly integration with Icosa Gallery integration
2 parents 7486f2f + bfedbcd commit 38858cc

File tree

6 files changed

+192
-21
lines changed

6 files changed

+192
-21
lines changed

.github/workflows/lint-and-test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,5 @@ jobs:
193193
_build
194194
key: mix-${{ hashFiles('mix.lock') }}
195195

196+
- run: mix deps.get
196197
- run: mix format --check-formatted '{lib,priv,test,config}/**/*.{ex,exs}'

lib/ret/media_resolver.ex

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ defmodule Ret.MediaResolver do
2020

2121
@youtube_rate_limit %{scale: 8_000, limit: 1}
2222
@sketchfab_rate_limit %{scale: 60_000, limit: 15}
23+
@icosa_rate_limit %{scale: 60_000, limit: 1000}
2324
@max_await_for_rate_limit_s 120
2425

2526
@non_video_root_hosts [
2627
"sketchfab.com",
2728
"giphy.com",
28-
"tenor.com"
29+
"tenor.com",
30+
"icosa.gallery"
2931
]
3032

3133
@deviant_id_regex ~r/\"DeviantArt:\/\/deviation\/([^"]+)/
@@ -79,6 +81,12 @@ defmodule Ret.MediaResolver do
7981
|> resolved()}
8082
end
8183

84+
def resolve(%MediaResolverQuery{url: %URI{host: "api.icosa.gallery"}} = query, root_host) do
85+
rate_limited_resolve(query, root_host, @icosa_rate_limit, fn ->
86+
resolve_non_video(query, root_host)
87+
end)
88+
end
89+
8290
def resolve(%MediaResolverQuery{} = query, root_host) when root_host in @non_video_root_hosts do
8391
resolve_non_video(query, root_host)
8492
end
@@ -327,6 +335,46 @@ defmodule Ret.MediaResolver do
327335
{:commit, (resolved_url || uri) |> resolved(meta)}
328336
end
329337

338+
defp resolve_non_video(
339+
%MediaResolverQuery{
340+
url: %URI{host: "api.icosa.gallery", path: "/v1/assets/" <> asset_id}
341+
},
342+
"icosa.gallery"
343+
) do
344+
# Increment stat for the request
345+
Statix.increment("ret.media_resolver.icosa.requests")
346+
347+
# Make the API call to get the asset data
348+
payload =
349+
"https://api.icosa.gallery/v1/assets/#{asset_id}"
350+
# Assuming this function sends the request and handles retries
351+
|> retry_get_until_success()
352+
|> Map.get(:body)
353+
|> Poison.decode!()
354+
355+
# Create the meta information based on the payload
356+
meta =
357+
%{
358+
expected_content_type: "model/gltf",
359+
name: payload["displayName"],
360+
author: payload["authorName"],
361+
license: payload["license"]
362+
}
363+
364+
# Extract the GLTF2 format URL from the payload
365+
uri =
366+
payload["formats"]
367+
|> Enum.find(&(&1["formatType"] == "GLTF2"))
368+
|> Kernel.get_in(["root", "url"])
369+
|> URI.parse()
370+
371+
# Increment stat for successful resolution
372+
Statix.increment("ret.media_resolver.icosa.ok")
373+
374+
# Return the URI and meta data for further processing
375+
{:commit, resolved(uri, meta)}
376+
end
377+
330378
defp resolve_non_video(
331379
%MediaResolverQuery{url: %URI{path: "/models/" <> model_id}} = query,
332380
"sketchfab.com" = root_host

lib/ret/media_search.ex

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,47 @@ defmodule Ret.MediaSearch do
249249
sketchfab_search(query)
250250
end
251251

252+
def search(%Ret.MediaSearchQuery{source: "icosa", cursor: cursor, filter: filter, q: q}) do
253+
query =
254+
URI.encode_query(
255+
pageSize: @page_size,
256+
maxComplexity: :MEDIUM,
257+
format: :GLTF2,
258+
pageToken: cursor,
259+
category: filter,
260+
licence: :REMIXABLE,
261+
orderBy: :BEST,
262+
keywords: q
263+
)
264+
265+
url = "https://api.icosa.gallery/v1/assets?#{query}"
266+
# Remove empty category queries as the Icosa API returns validation errors instead of "any"
267+
url = String.replace(url, "category=&", "")
268+
269+
res =
270+
url
271+
|> retry_get_until_success()
272+
273+
case res do
274+
:error ->
275+
:error
276+
277+
res ->
278+
decoded_res = res |> Map.get(:body) |> Poison.decode!()
279+
entries = decoded_res |> Map.get("assets") |> Enum.map(&icosa_api_result_to_entry/1)
280+
next_cursor = decoded_res |> Map.get("nextPageToken")
281+
282+
{:commit,
283+
%Ret.MediaSearchResult{
284+
meta: %Ret.MediaSearchResultMeta{
285+
next_cursor: next_cursor,
286+
source: :icosa
287+
},
288+
entries: entries
289+
}}
290+
end
291+
end
292+
252293
def search(%Ret.MediaSearchQuery{
253294
source: "youtube_videos",
254295
cursor: cursor,
@@ -382,6 +423,8 @@ defmodule Ret.MediaSearch do
382423
end
383424
end
384425

426+
# Icosa does not currently require an API key
427+
def available?(:icosa), do: true
385428
def available?(:bing_images), do: has_resolver_config?(:bing_search_api_key)
386429
def available?(:bing_videos), do: has_resolver_config?(:bing_search_api_key)
387430
def available?(:youtube_videos), do: has_resolver_config?(:youtube_api_key)
@@ -963,6 +1006,18 @@ defmodule Ret.MediaSearch do
9631006
}
9641007
end
9651008

1009+
defp icosa_api_result_to_entry(result) do
1010+
%{
1011+
id: result["assetId"],
1012+
type: "icosa_model",
1013+
name: result["displayName"],
1014+
attributions: %{creator: %{name: result["authorName"]}},
1015+
url: "http://api.icosa.gallery/v1/assets/" <> result["assetId"],
1016+
# url: result["url"],
1017+
images: %{preview: %{url: result["thumbnail"]["url"]}}
1018+
}
1019+
end
1020+
9661021
defp youtube_api_result_to_entry(result) do
9671022
%{
9681023
id: result["id"]["videoId"],

lib/ret/meta.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ defmodule Ret.Meta do
4444
def available_integrations_meta do
4545
%{
4646
twitter: Ret.TwitterClient.available?(),
47+
icosa: Ret.MediaSearch.available?(:icosa),
4748
bing_images: Ret.MediaSearch.available?(:bing_images),
4849
bing_videos: Ret.MediaSearch.available?(:bing_videos),
4950
youtube_videos: Ret.MediaSearch.available?(:youtube_videos),

lib/ret_web/controllers/api/v1/media_search_controller.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ defmodule RetWeb.Api.V1.MediaSearchController do
9898
def index(conn, %{"source" => source} = params)
9999
when source in [
100100
"sketchfab",
101+
"icosa",
101102
"tenor",
102103
"youtube_videos",
103104
"bing_videos",

lib/ret_web/controllers/page_controller.ex

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ defmodule RetWeb.PageController do
1818
alias Plug.Conn
1919
import Ret.ConnUtils
2020
import Ret.HttpUtils
21+
require Logger
2122

2223
##
2324
# NOTE: In addition to adding a route, you must add static html pages to the page_origin_warmer.ex
@@ -745,17 +746,23 @@ defmodule RetWeb.PageController do
745746
do: cors_proxy(conn, "#{url}?#{qs}")
746747

747748
defp cors_proxy(conn, url) do
749+
cors_proxy_with_redirects(conn, url, 0)
750+
end
751+
752+
defp cors_proxy_with_redirects(conn, url, redirect_count) when redirect_count > 5 do
753+
Logger.error("CORS Proxy: Too many redirects (#{redirect_count}) for URL: #{url}")
754+
conn |> send_resp(400, "Too many redirects")
755+
end
756+
757+
defp cors_proxy_with_redirects(conn, url, redirect_count) do
748758
%URI{authority: authority, host: host} = uri = URI.parse(url)
749759

750760
resolved_ip = HttpUtils.resolve_ip(host)
751761

752762
if HttpUtils.internal_ip?(resolved_ip) do
763+
Logger.warning("CORS Proxy: Blocking internal IP #{inspect(resolved_ip)} for host #{host}")
753764
conn |> send_resp(401, "Bad request.")
754765
else
755-
# We want to ensure that the URL we request hits the same IP that we verified above,
756-
# so we replace the host with the IP address here and use this url to make the proxy request.
757-
ip_url = URI.to_string(HttpUtils.replace_host(uri, resolved_ip))
758-
759766
# Disallow CORS proxying unless request was made to the cors proxy url
760767
cors_proxy_url = Application.get_env(:ret, RetWeb.Endpoint)[:cors_proxy_url]
761768

@@ -780,7 +787,7 @@ defmodule RetWeb.PageController do
780787
upstream: url,
781788
allowed_origins: allowed_origins,
782789
proxy_url: "#{cors_scheme}://#{cors_host}:#{cors_port}",
783-
# Since we replaced the host with the IP address in ip_url above, we need to force the host
790+
# We need to force the host
784791
# used for ssl verification here so that the connection isn't rejected.
785792
# Note that we have to convert the authority to a charlist, since this uses Erlang's `ssl` module
786793
# internally, which expects a charlist.
@@ -789,33 +796,91 @@ defmodule RetWeb.PageController do
789796
{:server_name_indication, to_charlist(authority)},
790797
{:versions, [:"tlsv1.2", :"tlsv1.3"]}
791798
]
792-
]
793-
# preserve_host_header: true
799+
],
800+
error_callback: fn error -> Logger.error("CORS-proxy error: #{inspect(error)}") end
794801
)
795802

796803
body = ReverseProxyPlug.read_body(conn)
797804
is_head = conn |> Conn.get_req_header("x-original-method") == ["HEAD"]
798805

799-
%Conn{}
800-
|> Map.merge(conn)
801-
|> Map.put(
802-
:method,
803-
if is_head do
804-
"HEAD"
805-
else
806-
conn.method
806+
try do
807+
# First, make a HEAD request to check for redirects using HTTPoison
808+
case HTTPoison.head(url, [],
809+
follow_redirect: false,
810+
ssl: [
811+
{:server_name_indication, to_charlist(authority)},
812+
{:versions, [:"tlsv1.2", :"tlsv1.3"]}
813+
],
814+
timeout: 15_000,
815+
recv_timeout: 15_000
816+
) do
817+
{:ok, %HTTPoison.Response{status_code: status_code, headers: headers}}
818+
when status_code in [301, 302, 303, 307, 308] ->
819+
# Found a redirect
820+
location_header =
821+
headers
822+
|> Enum.find(fn {k, _v} -> String.downcase(k) == "location" end)
823+
|> elem(1)
824+
825+
if location_header do
826+
# Resolve relative URLs against the current URL
827+
redirect_url = URI.merge(uri, location_header) |> URI.to_string()
828+
cors_proxy_with_redirects(conn, redirect_url, redirect_count + 1)
829+
else
830+
Logger.warning("CORS Proxy: Redirect response missing location header")
831+
# Fall back to ReverseProxyPlug for this response
832+
make_reverse_proxy_request(conn, url, body, is_head, opts)
833+
end
834+
835+
{:ok, %HTTPoison.Response{}} ->
836+
# Not a redirect, use ReverseProxyPlug for the actual request
837+
make_reverse_proxy_request(conn, url, body, is_head, opts)
838+
839+
{:error, reason} ->
840+
Logger.error("CORS Proxy: HEAD request failed: #{inspect(reason)}")
841+
# Fall back to ReverseProxyPlug anyway
842+
make_reverse_proxy_request(conn, url, body, is_head, opts)
807843
end
808-
)
809-
# Need to strip path_info since proxy plug reads it
810-
|> Map.put(:path_info, [])
811-
|> ReverseProxyPlug.request(body, opts)
812-
|> ReverseProxyPlug.response(conn, opts)
844+
rescue
845+
error ->
846+
Logger.error("CORS Proxy: Request failed with exception: #{inspect(error)}")
847+
conn |> send_resp(500, "Proxy request failed: #{inspect(error)}")
848+
catch
849+
:exit, reason ->
850+
Logger.error("CORS Proxy: Request exited with reason: #{inspect(reason)}")
851+
conn |> send_resp(500, "Proxy request timed out or failed")
852+
853+
kind, reason ->
854+
Logger.error("CORS Proxy: Request failed with #{kind}: #{inspect(reason)}")
855+
conn |> send_resp(500, "Proxy request failed")
856+
end
813857
else
858+
Logger.warning("CORS Proxy: Request rejected - invalid host or scheme")
814859
conn |> send_resp(401, "Bad request.")
815860
end
816861
end
817862
end
818863

864+
defp make_reverse_proxy_request(conn, _url, body, is_head, opts) do
865+
proxy_conn =
866+
%Conn{}
867+
|> Map.merge(conn)
868+
|> Map.put(
869+
:method,
870+
if is_head do
871+
"HEAD"
872+
else
873+
conn.method
874+
end
875+
)
876+
# Need to strip path_info since proxy plug reads it
877+
|> Map.put(:path_info, [])
878+
|> ReverseProxyPlug.request(body, opts)
879+
|> ReverseProxyPlug.response(conn, opts)
880+
881+
proxy_conn
882+
end
883+
819884
defp render_static_asset(conn) do
820885
static_options = Plug.Static.init(at: "/", from: :ret, gzip: true, brotli: true)
821886
Plug.Static.call(conn, static_options)

0 commit comments

Comments
 (0)