이 사용 사례는 activepieces, data explorer 및 API를 활용한 인간이 개입하는 인라인(human in the loop) 자동화 워크플로우의 일환으로, 시간적으로 민감한 업로드 삭제를 수행하는 것입니다.
그 목표는 SSH 접근 권한 없이도 일반 모더레이터가 즉시 업로드를 완전히 삭제할 수 있는 간단한 방법을 제공하여, 업로드 참조가 깨지는 모든 알려진 경우를 포괄적으로 처리하고, 특정 URL(프록시된 아바타의 경우 사용자 이름 기반 URL 접두사 포함)에 대해 CDN을 자동으로 정리(purge)할 수 있도록 하는 것입니다.
테스트를 해본 결과, 업로드가 파괴(destroy)될 때 아바타, 프로필 배경, 카드 배경 참조가 자동으로 처리되는 것을 확인하여 안도했습니다.
주요 시나리오는 두 가지로, ‘화약고(Scorched earth)’ 방식과 ‘정밀 제거(Surgical removal)’ 방식입니다. 다음은 화약고 방식에 대한 진행 중인 작업 내용입니다:
업로드 해시 목록으로 변환하기:
아바타 URL (정규식으로 사용자 이름 추출) → data explorer 쿼리를 통해 아바타 해시 가져오기 (URL에는 해시가 포함되지 않음)
토픽/게시물 URL → data explorer를 사용하여 해당 게시물에서 사용된 모든 업로드 해시 수집
직접적인 원본/최적화 업로드 URL (프로필 배경 및 카드 배경 포함) → 정규식으로 해시 추출
그 다음 각 해시를 쿼리하여 모든 발생 위치를 찾습니다 (모든 경우를 포괄하는 하나의 data explorer 쿼리, 한 번에 하나의 해시):
아바타로 사용한 사용자 목록 (사용자 이름/ID)
프로필 배경으로 사용한 사용자 목록 (사용자 이름/ID)
카드 배경으로 사용한 사용자 목록 (사용자 이름/ID)
이 업로드를 사용하는 모든 게시물(원본) 목록
실행 작업:
해당 업로드를 아바타, 프로필 배경 또는 카드 배경으로 사용한 모든 사용자 정지(suspend)
대상 업로드를 참조하는 게시물이 있는 모든 사용자 정지 (단, 인용(quote) 안에 중첩된 참조는 제외)
모든 토픽 삭제
모든 게시물 삭제
업로드 파괴 (API 엔드포인트 없음)
모든 CDN URL 정리 (최적화/비최적화)
각 관련 사용자 이름에 대해 프록시된 아바타 URL의 표준 접두사 정리 (모든 크기 커버링)
정밀 제거 시나리오는 본질적으로 동일하지만, 관련 사용자를 정지하지 않으며, 게시물/토픽 URL에서 모든 업로드 해시를 수집하는 방식에 일부 변경이 필요합니다.
깨진 참조를 피하기 위해 게시물/토픽 자체를 삭제하는 것이 여전히 필요할 가능성이 높지만, 해당 특정 업로드의 마크다운만 제거하고(다른 업로드에는 영향을 주지 않고) 다른 업로드에는 손대지 않는 방식이 가능하다면 더 우수할 것입니다. 모든 마크다운 업로드 참조를 제거하지 않는다면, 이 자동화와 같은 방식이 될 것입니다.
이상적으로는 해시 자체를 차단할 수 있으면 좋겠습니다. 위에서 설명한 과정을 거친 후에도 누군가가 단순히 새 계정을 만들고 재업로드하지 못하도록 하기 위해서입니다.
현재 일반 모더레이터가 감시 단어(watched words) 등을 사용하여 이를 수행하는 것은 불가능하다고 생각합니다. 따라서 해시 목록에 대해 주기적으로 위와 같은 스캔을 수행하는 것이 이를 처리하는 방법이 될 수 있습니다.
네! 지난 달에 이 플러그인을 보고 거의 기쁨의 눈물을 흘릴 뻔했어요. ㅋㅋ 만들고 공유해 주셔서 정말 감사합니다.
이 플러그인으로 처리할 수 없었던 몇 가지 시나리오가 있습니다:
upload: 검색 접두어를 사용해도 프로필 배경이나 카드 배경을 찾지 못했습니다(다른 사람이 게시물에 해당 이미지를 직접 링크한 경우를 제외하고).
게시물과 연관성이 없는 이미지(해당 시나리오)를 영구 삭제하는 것.
아바타의 경우에도 유사합니다. 예를 들어 아바타의 해시를 가져오기(보통 URL에 포함되지 않음), 그리고 필요 시 해당 아바타를 사용했을 가능성이 있는 다른 계정을 찾아서 정지 조치하고, 게시물 연관성이 없는 업로드를 삭제하는 것.
있으면 좋은 기능들:
업로드 삭제 시 웹훅을 트리거하여, 업로드의 모든 변형에 대해 CDN 정화를 수행하는 것.
업로드가 삭제될 때 해당 업로드 참조가 있는 모든 게시물/주제를 일괄/자동으로 삭제하는 것.
Discourse(웹 UI)에서는 관리자/모드레이터나 사용자 본인이 사용자의 아바타를 삭제(연결 해제)할 수 없는 것 같습니다(대체 이미지를 업로드하는 경우를 제외하고). 프로필 배경과 배너는 가능합니다. 하지만 이 둘 모두 업로드를 즉시 파괴하지 않으며, 다른 사용자가 자신의 프로필(아바타, 프로필 배경, 프로필 카드)의 일부로 동일한 업로드를 사용 중인 경우 나중에 정화되지도 않습니다.
결국 대부분의 프로세스(또는 전체 프로세스)를 처리할 수 있는 자동화 워크플로우를 만들어 보는 것이 가치 있을 것 같다고 생각하게 되었습니다(인간의 검토/승인 및/또는 단순화된 인간 입력을 포함하여). 일관되게 적용하기 쉽고, 엣지 케이스를 커버하며, 이상적으로는 업로드 참조가 죽어 있는(존재하지 않는) 게시물이 남지 않도록 가능성을 최소화하기 위함입니다. 또한 필요 시 자동 정지(목표 업로드가 인용문 안에 있는 경우를 제외하고) 및 CDN 정화도 수행합니다.
현재까지 작성한 데이터 탐색기(Data Explorer) 쿼리는 다음과 같습니다(아직 ‘그들을 하나로 묶는’ 단계는 아님). 목표는 다음을 커버하는 것입니다:
주제 URL
게시물 URL
직접 이미지 URL (게시물, 프로필 배경, 카드 배경)
아바타 URL (프록시된)
업로드 해시 목록 준비
사용자명 (프록시된 아바타 URL에서) 에서 업로드 해시로
-- [params]
-- user_id :username
SELECT
up.sha1 AS upload_hash
FROM
users u
JOIN uploads up ON up.id = u.uploaded_avatar_id
WHERE
u.id = :username
게시물/주제 URL 에서 업로드 해시 목록으로
-- [params]
-- post_id :post_url
SELECT
COALESCE(json_agg(up.sha1), '[]'::json) AS upload_hashes
FROM
upload_references ur
JOIN uploads up ON up.id = ur.upload_id
WHERE
ur.target_id = :post_url
AND ur.target_type = 'Post'
제가 알고 있는 다른 업로드 URL 유형들은 URL 자체에 해시가 포함되어 있습니다(해시 가져오기 위해 데이터 탐색기를 사용할 필요가 없음).
-- [params]
-- string :upload_hash
-- string :upload_schemeless_prefix
-- string :cdn_url_prefix
-- string :app_hostname
WITH target_uploads AS (
SELECT
id,
url,
user_id
FROM
uploads
WHERE
sha1 = :upload_hash
),
all_urls AS (
SELECT
url
FROM
target_uploads
UNION
SELECT
url
FROM
optimized_images
WHERE
upload_id IN (
SELECT
id
FROM
target_uploads)
),
post_data AS (
SELECT
p.id AS post_id,
p.topic_id,
u.id AS user_id,
u.username AS author,
p.created_at,
CASE WHEN p.post_number = 1 THEN
TRUE
ELSE
FALSE
END AS is_topic_starter,
CASE WHEN t.archetype = 'private_message' THEN
TRUE
ELSE
FALSE
END AS is_dm,
p.raw
FROM
upload_references ur
JOIN posts p ON p.id = ur.target_id
AND ur.target_type = 'Post'
JOIN topics t ON t.id = p.topic_id
JOIN users u ON u.id = p.user_id
WHERE
ur.upload_id IN (
SELECT
id
FROM
target_uploads)
AND p.deleted_at IS NULL
)
SELECT
(
SELECT
COALESCE(json_agg(id), '[]'::json)
FROM
target_uploads) AS upload_ids,
(
SELECT
COALESCE(json_agg(url), '[]'::json)
FROM
all_urls) AS upload_s3_schemeless_urls,
(
SELECT
COALESCE(json_agg(
CASE WHEN url LIKE '//%' THEN
REPLACE(url, :upload_schemeless_prefix, :cdn_url_prefix)
ELSE
:cdn_url_prefix || url
END), '[]'::json)
FROM
all_urls) AS upload_cdn_urls,
(
SELECT
COALESCE(json_agg(json_build_object('user_id', id, 'username', username)), '[]'::json)
FROM
users
WHERE
uploaded_avatar_id IN (
SELECT
id
FROM
target_uploads)) AS avatar_users,
(
SELECT
COALESCE(json_agg('https://' || :app_hostname || '/user_avatar/' || :app_hostname || '/' || username || '/'), '[]'::json)
FROM
users
WHERE
uploaded_avatar_id IN (
SELECT
id
FROM
target_uploads)) AS avatar_proxied_url_prefixes,
(
SELECT
COALESCE(json_agg(json_build_object('user_id', u.id, 'username', u.username)), '[]'::json)
FROM
user_profiles up
JOIN users u ON u.id = up.user_id
WHERE
up.profile_background_upload_id IN (
SELECT
id
FROM
target_uploads)) AS profile_background_users,
(
SELECT
COALESCE(json_agg(json_build_object('user_id', u.id, 'username', u.username)), '[]'::json)
FROM
user_profiles up
JOIN users u ON u.id = up.user_id
WHERE
up.card_background_upload_id IN (
SELECT
id
FROM
target_uploads)) AS card_background_users,
(
SELECT
COALESCE(json_agg(pd.topic_id), '[]'::json)
FROM
post_data pd
WHERE
pd.is_topic_starter = TRUE) AS topic_ids,
(
SELECT
COALESCE(json_agg(pd.post_id), '[]'::json)
FROM
post_data pd
WHERE
pd.is_topic_starter = FALSE) AS post_ids,
(
SELECT
COALESCE(json_agg(json_build_object('post_id', pd.post_id, 'topic_id', pd.topic_id, 'user_id', pd.user_id, 'author', pd.author, 'created_at', pd.created_at, 'is_topic_starter', pd.is_topic_starter, 'is_dm', pd.is_dm, 'raw', pd.raw)), '[]'::json)
FROM
post_data pd) AS post_details