Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 73 additions & 8 deletions lib/sanbase/insight/post.ex
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ defmodule Sanbase.Insight.Post do
|> Repo.insert()
|> case do
{:ok, post} ->
auto_link_images(post)
emit_event({:ok, post}, :create_insight, %{})
:ok = Sanbase.Insight.Search.update_document_tokens(post.id)
{:ok, post}
Expand Down Expand Up @@ -526,6 +527,8 @@ defmodule Sanbase.Insight.Post do

case Repo.update(update_changeset) do
{:ok, post} ->
auto_link_images(post)

# Update the embeddings only if the title or text changed and the post is published.
# On embed existing embeddings are deleted and the new one are created
published? = post.ready_state == @published
Expand Down Expand Up @@ -980,16 +983,35 @@ defmodule Sanbase.Insight.Post do

defp images_cast(changeset, _), do: changeset

defp extract_image_url_from_post(%Post{} = post) do
post
|> Repo.preload(:images)
|> Map.get(:images, [])
|> Enum.map(fn %{image_url: image_url} -> image_url end)
end
@doc """
Delete S3 files for a post's images, but only when:
1. The image was uploaded by the post's author (image.user_id == post.user_id)
2. The image URL is not used in any other post's text

The PostImage DB records are cascade-deleted when the post is deleted,
so this only controls S3 file cleanup.
"""
def delete_post_images(%Post{} = post) do
extract_image_url_from_post(post)
|> Enum.map(&Sanbase.FileStore.delete/1)
post = Repo.preload(post, :images)

Enum.each(post.images, fn image ->
owner_uploaded? = image.user_id == post.user_id
used_elsewhere? = image_used_in_other_posts?(image.image_url, post.id)

if owner_uploaded? and not used_elsewhere? do
Sanbase.FileStore.delete(image.image_url)
end
end)
end

defp image_used_in_other_posts?(image_url, post_id) do
pattern = "%#{image_url}%"

from(p in __MODULE__,
where: p.id != ^post_id and p.is_deleted != true,
where: like(p.text, ^pattern)
)
|> Repo.exists?()
end

defp maybe_drop_post_tags(post, %{tags: tags}) when is_list(tags),
Expand Down Expand Up @@ -1019,6 +1041,49 @@ defmodule Sanbase.Insight.Post do
end
end

@doc """
Scan the post text for image URLs matching existing unlinked PostImage records
uploaded by the same user, and link them to this post.
"""
def auto_link_images(%__MODULE__{id: post_id, user_id: user_id, text: text})
when is_binary(text) do
image_urls = extract_image_urls_from_text(text)

if image_urls != [] do
from(pi in PostImage,
where: pi.image_url in ^image_urls,
where: pi.user_id == ^user_id,
where: is_nil(pi.post_id) or pi.post_id == ^post_id
)
|> Repo.update_all(set: [post_id: post_id])
end

:ok
end

def auto_link_images(_post), do: :ok

case Application.compile_env(:sanbase, :env) do
:test ->
defp extract_image_urls_from_text(text) do
storage_dir = Application.get_env(:waffle, :storage_dir)

storage_dir =
if String.last(storage_dir) != "/", do: storage_dir <> "/", else: storage_dir

regex = Regex.compile!(~s{#{storage_dir}[^\s"<>]+(?:\\.jpg|\\.png|\\.gif|\\.jpeg)})
Regex.scan(regex, text) |> Enum.map(fn [url] -> url end)
end

_ ->
defp extract_image_urls_from_text(text) do
regex =
~r{https://[a-zA-Z0-9\-\.]*sanbase-images.s3\.amazonaws\.com/[^\s"<>]+(?:\.jpg|\.png|\.gif|\.jpeg)}

Regex.scan(regex, text) |> Enum.map(fn [url] -> url end)
end
end

defp async_embed_post(%__MODULE__{} = post) do
if Application.get_env(:sanbase, :env) != :test do
Task.Supervisor.async_nolink(Sanbase.TaskSupervisor, fn ->
Expand Down
6 changes: 4 additions & 2 deletions lib/sanbase/insight/post_image.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ defmodule Sanbase.Insight.PostImage do
use Ecto.Schema
import Ecto.Changeset

alias Sanbase.Insight.Post
alias __MODULE__
alias Sanbase.Insight.Post
alias Sanbase.Accounts.User

schema "post_images" do
belongs_to(:post, Post)
belongs_to(:user, User)

field(:file_name, :string)
field(:image_url, :string)
Expand All @@ -16,7 +18,7 @@ defmodule Sanbase.Insight.PostImage do

def changeset(%PostImage{} = post_image, attrs \\ %{}) do
post_image
|> cast(attrs, [:post_id, :file_name, :image_url, :content_hash, :hash_algorithm])
|> cast(attrs, [:post_id, :user_id, :file_name, :image_url, :content_hash, :hash_algorithm])
|> validate_required([:image_url, :content_hash, :hash_algorithm])
|> update_change(:image_url, &String.downcase/1)
|> unique_constraint(:image_url, name: :image_url_index)
Expand Down
9 changes: 5 additions & 4 deletions lib/sanbase_web/graphql/resolvers/file_resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do
The files are first uploaded to an AWS S3 bucket and then the image url,
the content hash and used hash algorithm are stored in postgres.
"""
def upload_image(_root, %{images: images}, _resolution) do
def upload_image(_root, %{images: images}, %{context: %{auth: %{current_user: current_user}}}) do
# In S3 there are no folders so the file name just contains some random text
# and a slash in it. Locally (in test and dev mode) the files are treated as if
# they are located in a folder called `scope`
Expand All @@ -19,7 +19,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do
# Prepend the timestamp in milliseconds to the name to avoid name collision
# when uploading images with the same hash and name
arg = %{arg | filename: milliseconds_str() <> "_" <> file_name}
save_image_content(arg)
save_image_content(arg, current_user.id)
end)

:ok = save_image_meta_data(image_data)
Expand All @@ -29,7 +29,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do

# Helper functions

defp save_image_content(%Plug.Upload{filename: file_name} = arg) do
defp save_image_content(%Plug.Upload{filename: file_name} = arg, user_id) do
with {:ok, content_hash} <- FileHash.calculate(arg.path),
{:ok, file_name} <- FileStore.store({arg, content_hash}) do
image_url = FileStore.url({file_name, content_hash})
Expand All @@ -38,7 +38,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do
file_name: file_name,
image_url: image_url,
content_hash: content_hash,
hash_algorithm: FileHash.algorithm() |> Atom.to_string()
hash_algorithm: FileHash.algorithm() |> Atom.to_string(),
user_id: user_id
}
else
{:error, error} ->
Expand Down
26 changes: 21 additions & 5 deletions lib/sanbase_web/graphql/resolvers/insight_resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,28 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do
~r{https://[a-zA-Z0-9\-\.]*sanbase-images.s3\.amazonaws\.com/[^\s"<>]+(?:\.jpg|\.png|\.gif|\.jpeg)}
end

def extract_images_from_text(%Post{text: text}, _args, _resolution) do
image_urls =
Regex.scan(image_url_regex(), text)
|> Enum.map(fn [url] -> url end)
def extract_images_from_text(%Post{text: text, images: images}, _args, _resolution) do
# Images from DB (PostImage records linked to this post)
db_images =
case images do
images when is_list(images) ->
Enum.map(images, fn %{image_url: image_url} -> %{image_url: image_url} end)

_ ->
[]
end

# Images extracted from the post text via regex (for old insights without DB records)
regex_images =
Regex.scan(image_url_regex(), text || "")
|> Enum.map(fn [url] -> %{image_url: url} end)

# Union of both sources, deduplicated by image_url
all_images =
(db_images ++ regex_images)
|> Enum.uniq_by(fn %{image_url: url} -> url end)

{:ok, Enum.map(image_urls, fn image_url -> %{image_url: image_url} end)}
{:ok, all_images}
end

def insights_count(%User{id: id}, _args, %{context: %{loader: loader}}) do
Expand Down
9 changes: 9 additions & 0 deletions priv/repo/migrations/20250630112632_add_post_image_owner.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Sanbase.Repo.Migrations.AddPostImageOwner do
use Ecto.Migration

def change do
alter table(:post_images) do
add(:user_id, references(:users, on_delete: :nothing), null: true)
end
end
end
29 changes: 29 additions & 0 deletions priv/repo/migrations/20260303194227_add_user_id_to_post_images.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Sanbase.Repo.Migrations.AddUserIdToPostImages do
use Ecto.Migration

def up do
unless column_exists?(:post_images, :user_id) do
alter table(:post_images) do
add(:user_id, references(:users), null: true)
end
end
end

def down do
alter table(:post_images) do
remove(:user_id)
end
end

defp column_exists?(table, column) do
query = """
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '#{table}' AND column_name = '#{column}'
)
"""

%{rows: [[exists]]} = repo().query!(query)
exists
end
end
24 changes: 14 additions & 10 deletions priv/repo/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-- PostgreSQL database dump
--

\restrict lPrBCVGzfDAQrezJuUuvsvvIxIMIDCrQVbcCW2A0GafidncDVtkqtUHNwYPqA5k
\restrict D6tIxYMizWBarY7UY5Vw2vV7lR6dxVzT8Y0sbdLlB5d7PcuhASx5fPAf54gNRco

-- Dumped from database version 15.16 (Homebrew)
-- Dumped by pg_dump version 15.16 (Homebrew)
Expand Down Expand Up @@ -3028,7 +3028,8 @@ CREATE TABLE public.post_images (
image_url text NOT NULL,
content_hash text NOT NULL,
hash_algorithm text NOT NULL,
post_id bigint
post_id bigint,
user_id bigint
);


Expand Down Expand Up @@ -8743,13 +8744,6 @@ CREATE UNIQUE INDEX metrics_name_index ON public.metrics USING btree (name);
CREATE UNIQUE INDEX monitored_twitter_handles_handle_index ON public.monitored_twitter_handles USING btree (handle);


--
-- Name: notification_muted_users_muted_user_id_index; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX notification_muted_users_muted_user_id_index ON public.notification_muted_users USING btree (muted_user_id);


--
-- Name: notification_templates_action_step_channel_mime_type_index; Type: INDEX; Schema: public; Owner: -
--
Expand Down Expand Up @@ -10269,6 +10263,14 @@ ALTER TABLE ONLY public.post_images
ADD CONSTRAINT post_images_post_id_fkey FOREIGN KEY (post_id) REFERENCES public.posts(id) ON DELETE CASCADE;


--
-- Name: post_images post_images_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY public.post_images
ADD CONSTRAINT post_images_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);


--
-- Name: posts posts_chart_configuration_for_event_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
Expand Down Expand Up @@ -11057,7 +11059,7 @@ ALTER TABLE ONLY public.webinar_registrations
-- PostgreSQL database dump complete
--

\unrestrict lPrBCVGzfDAQrezJuUuvsvvIxIMIDCrQVbcCW2A0GafidncDVtkqtUHNwYPqA5k
\unrestrict D6tIxYMizWBarY7UY5Vw2vV7lR6dxVzT8Y0sbdLlB5d7PcuhASx5fPAf54gNRco

INSERT INTO public."schema_migrations" (version) VALUES (20171008200815);
INSERT INTO public."schema_migrations" (version) VALUES (20171008203355);
Expand Down Expand Up @@ -11555,6 +11557,7 @@ INSERT INTO public."schema_migrations" (version) VALUES (20250611104342);
INSERT INTO public."schema_migrations" (version) VALUES (20250612090655);
INSERT INTO public."schema_migrations" (version) VALUES (20250612131900);
INSERT INTO public."schema_migrations" (version) VALUES (20250612133320);
INSERT INTO public."schema_migrations" (version) VALUES (20250630112632);
INSERT INTO public."schema_migrations" (version) VALUES (20250703133723);
INSERT INTO public."schema_migrations" (version) VALUES (20250703144448);
INSERT INTO public."schema_migrations" (version) VALUES (20250709132930);
Expand Down Expand Up @@ -11599,3 +11602,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260116093636);
INSERT INTO public."schema_migrations" (version) VALUES (20260216103643);
INSERT INTO public."schema_migrations" (version) VALUES (20260224120000);
INSERT INTO public."schema_migrations" (version) VALUES (20260225120000);
INSERT INTO public."schema_migrations" (version) VALUES (20260303194227);
Loading