feat(privacy): harden logs, admin access, federation, and backups
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 25m2s
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 25m2s
Redact admin bodies, limit moderation to open reports, reject inbound ActivityPub DMs, federate admin deletes, shorten media cache, Tor-null and retain audit logs, scrub JSON logs, and add backup/Haraka ops scripts.
This commit is contained in:
parent
2198031b95
commit
01217824bb
26 changed files with 1065 additions and 145 deletions
|
|
@ -56,7 +56,8 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
|
|||
|> offset(^offset)
|
||||
|> preload([_m, c, s], conversation: c, sender: s)
|
||||
|> Repo.all()
|
||||
|> Messaging.ChatMessage.decrypt_messages()
|
||||
# Never decrypt chat bodies for admin — metadata only.
|
||||
|> ContentAccess.redact_chat_messages()
|
||||
|> Enum.map(&Map.put(&1, :protocol_kind, protocol_kind(&1)))
|
||||
|
||||
stats = %{
|
||||
|
|
@ -172,17 +173,29 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
|
|||
defp maybe_search(query, ""), do: query
|
||||
|
||||
defp maybe_search(query, search_query) do
|
||||
# Metadata only — never search plaintext body content.
|
||||
search_term = "%#{search_query}%"
|
||||
|
||||
from([m, c, s] in query,
|
||||
where:
|
||||
ilike(fragment("COALESCE(?, '')", m.content), ^search_term) or
|
||||
ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or
|
||||
metadata_filter =
|
||||
dynamic(
|
||||
[m, c, s],
|
||||
ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or
|
||||
ilike(fragment("COALESCE(?, '')", s.handle), ^search_term) or
|
||||
ilike(fragment("COALESCE(?, '')", c.name), ^search_term) or
|
||||
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_term) or
|
||||
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_term)
|
||||
)
|
||||
)
|
||||
|
||||
filter =
|
||||
case Integer.parse(String.trim(search_query)) do
|
||||
{id, ""} ->
|
||||
dynamic([m, _c, _s], m.id == ^id or ^metadata_filter)
|
||||
|
||||
_ ->
|
||||
metadata_filter
|
||||
end
|
||||
|
||||
from(q in query, where: ^filter)
|
||||
end
|
||||
|
||||
defp maybe_filter_conversation_type(query, "dm"), do: where(query, [_m, c, _s], c.type == "dm")
|
||||
|
|
@ -303,7 +316,7 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
|
|||
end
|
||||
|
||||
defp prepare_admin_chat_message(message) do
|
||||
%{message | content: ContentAccess.chat_redacted_notice(), encrypted_content: nil}
|
||||
ContentAccess.redact_chat_message(message)
|
||||
end
|
||||
|
||||
defp get_remote_ip(conn) do
|
||||
|
|
|
|||
|
|
@ -84,8 +84,10 @@ defmodule Elektrine.ActivityPub.MRF do
|
|||
def get_policies do
|
||||
configured = Application.get_env(:elektrine, :mrf, [])[:policies] || []
|
||||
|
||||
# Always include these core policies at the end
|
||||
# Always include these core policies at the end.
|
||||
# RejectNonPublicPolicy blocks inbound ActivityPub DMs (direct) by default.
|
||||
core_policies = [
|
||||
Elektrine.ActivityPub.MRF.RejectNonPublicPolicy,
|
||||
Elektrine.ActivityPub.MRF.AntiFollowbotPolicy,
|
||||
Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy,
|
||||
Elektrine.ActivityPub.MRF.NoEmptyPolicy,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,80 @@
|
|||
defmodule Elektrine.Admin.ContentAccess do
|
||||
@moduledoc """
|
||||
Admin UIs show metadata only. Message and chat bodies are never decrypted
|
||||
for operators.
|
||||
Admin UIs show metadata only. Message, chat, and social bodies are never
|
||||
decrypted or exposed for operators.
|
||||
"""
|
||||
|
||||
@email_redacted "[Message body is not available in admin.]"
|
||||
@chat_redacted "[Chat message body is not available in admin.]"
|
||||
@social_redacted "[Post content is not available in admin.]"
|
||||
|
||||
def email_redacted_notice, do: @email_redacted
|
||||
def chat_redacted_notice, do: @chat_redacted
|
||||
def social_redacted_notice, do: @social_redacted
|
||||
|
||||
@doc """
|
||||
Strip body and encrypted payload fields from an email message for admin UI.
|
||||
Keeps headers/metadata (subject, from, to, status, dates).
|
||||
"""
|
||||
def redact_email_message(message) when is_map(message) do
|
||||
message
|
||||
|> Map.put(:text_body, @email_redacted)
|
||||
|> Map.put(:html_body, nil)
|
||||
|> Map.put(:encrypted_text_body, nil)
|
||||
|> Map.put(:encrypted_html_body, nil)
|
||||
|> Map.put(:encrypted_raw_source, nil)
|
||||
|> Map.put(:raw_source, nil)
|
||||
|> Map.put(:client_encrypted_payload, nil)
|
||||
end
|
||||
|
||||
def redact_email_message(message), do: message
|
||||
|
||||
@doc """
|
||||
Strip body, media, and encrypted payload fields from a chat message for admin UI.
|
||||
Keeps conversation/sender/protocol metadata.
|
||||
"""
|
||||
def redact_chat_message(message) when is_map(message) do
|
||||
message
|
||||
|> Map.put(:content, @chat_redacted)
|
||||
|> Map.put(:encrypted_content, nil)
|
||||
|> Map.put(:client_encrypted_payload, nil)
|
||||
|> Map.put(:media_urls, [])
|
||||
|> Map.put(:media_metadata, nil)
|
||||
|> Map.put(:search_index, [])
|
||||
end
|
||||
|
||||
def redact_chat_message(message), do: message
|
||||
|
||||
@doc """
|
||||
Redact a list of chat messages for admin list views.
|
||||
"""
|
||||
def redact_chat_messages(messages) when is_list(messages) do
|
||||
Enum.map(messages, &redact_chat_message/1)
|
||||
end
|
||||
|
||||
def redact_chat_messages(messages), do: messages
|
||||
|
||||
@doc """
|
||||
Strip body, media, and encrypted payload fields from a social/timeline/community
|
||||
post for admin UI. Keeps title, author, vote counts, and timestamps.
|
||||
"""
|
||||
def redact_social_message(message) when is_map(message) do
|
||||
message
|
||||
|> Map.put(:content, @social_redacted)
|
||||
|> Map.put(:encrypted_content, nil)
|
||||
|> Map.put(:media_urls, [])
|
||||
|> Map.put(:media_metadata, %{})
|
||||
|> Map.put(:search_index, [])
|
||||
end
|
||||
|
||||
def redact_social_message(message), do: message
|
||||
|
||||
@doc """
|
||||
Redact a list of social messages for admin list views.
|
||||
"""
|
||||
def redact_social_messages(messages) when is_list(messages) do
|
||||
Enum.map(messages, &redact_social_message/1)
|
||||
end
|
||||
|
||||
def redact_social_messages(messages), do: messages
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
defmodule Elektrine.AuditLog do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Append-only admin audit log.
|
||||
|
||||
IP and user-agent are Tor-nulled via `Elektrine.Privacy.Network` when
|
||||
`via_tor: true` is passed. IPs are also nullified after the IP retention
|
||||
window; entire rows older than audit retention are purged.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
alias Elektrine.Accounts.User
|
||||
alias Elektrine.Privacy.Network
|
||||
alias Elektrine.Repo
|
||||
|
||||
@default_retention_days 90
|
||||
|
||||
schema "audit_logs" do
|
||||
field :action, :string
|
||||
field :resource_type, :string
|
||||
|
|
@ -23,17 +32,24 @@ defmodule Elektrine.AuditLog do
|
|||
|
||||
@doc """
|
||||
Creates an audit log entry.
|
||||
|
||||
Options:
|
||||
- `:target_user_id`, `:resource_id`, `:details`
|
||||
- `:ip_address`, `:user_agent` — scrubbed with `via_tor:`
|
||||
- `:via_tor` — when true, IP and UA are not stored
|
||||
"""
|
||||
def log(admin_id, action, resource_type, opts \\ []) do
|
||||
via_tor = Keyword.get(opts, :via_tor, false)
|
||||
|
||||
attrs = %{
|
||||
admin_id: admin_id,
|
||||
action: action,
|
||||
resource_type: resource_type,
|
||||
target_user_id: opts[:target_user_id],
|
||||
resource_id: opts[:resource_id],
|
||||
details: opts[:details] || %{},
|
||||
ip_address: opts[:ip_address],
|
||||
user_agent: opts[:user_agent]
|
||||
details: sanitize_details(opts[:details] || %{}),
|
||||
ip_address: Network.login_ip(opts[:ip_address], via_tor: via_tor),
|
||||
user_agent: Network.analytics_user_agent(opts[:user_agent], via_tor: via_tor)
|
||||
}
|
||||
|
||||
%__MODULE__{}
|
||||
|
|
@ -41,6 +57,45 @@ defmodule Elektrine.AuditLog do
|
|||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Configured retention for full audit rows (default 90 days).
|
||||
"""
|
||||
def retention_days do
|
||||
Application.get_env(:elektrine, :audit_log, [])
|
||||
|> Keyword.get(:retention_days, @default_retention_days)
|
||||
|> max(1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes audit log rows older than the retention window.
|
||||
"""
|
||||
def purge_older_than(opts \\ []) do
|
||||
days = Keyword.get(opts, :retention_days, retention_days())
|
||||
now = Keyword.get(opts, :now, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||
cutoff = DateTime.add(now, -days, :day)
|
||||
batch_size = Keyword.get(opts, :batch_size, 5_000) |> max(1)
|
||||
max_batches = Keyword.get(opts, :max_batches, 100) |> max(1)
|
||||
|
||||
Enum.reduce_while(1..max_batches, 0, fn _batch, total ->
|
||||
ids =
|
||||
from(a in __MODULE__,
|
||||
where: a.inserted_at < ^cutoff,
|
||||
select: a.id,
|
||||
limit: ^batch_size
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
case ids do
|
||||
[] ->
|
||||
{:halt, total}
|
||||
|
||||
ids ->
|
||||
{count, _} = from(a in __MODULE__, where: a.id in ^ids) |> Repo.delete_all()
|
||||
{:cont, total + count}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets audit logs with pagination and filtering.
|
||||
"""
|
||||
|
|
@ -88,4 +143,35 @@ defmodule Elektrine.AuditLog do
|
|||
|> validate_length(:action, max: 100)
|
||||
|> validate_length(:resource_type, max: 100)
|
||||
end
|
||||
|
||||
defp sanitize_details(details) when is_map(details) do
|
||||
Map.new(details, fn {key, value} ->
|
||||
key_s = to_string(key)
|
||||
|
||||
cond do
|
||||
String.contains?(String.downcase(key_s), "email") and is_binary(value) ->
|
||||
{key, redact_email(value)}
|
||||
|
||||
String.contains?(String.downcase(key_s), "password") ->
|
||||
{key, "[redacted]"}
|
||||
|
||||
true ->
|
||||
{key, value}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp sanitize_details(details), do: details
|
||||
|
||||
defp redact_email(email) when is_binary(email) do
|
||||
case String.split(email, "@") do
|
||||
[local, domain] when local != "" and domain != "" ->
|
||||
"#{String.slice(local, 0, 1)}…@#{domain}"
|
||||
|
||||
_ ->
|
||||
"[redacted]"
|
||||
end
|
||||
end
|
||||
|
||||
defp redact_email(_), do: "[redacted]"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Elektrine.Jobs.AuditLogRetentionWorker do
|
||||
@moduledoc """
|
||||
Purges audit log rows older than the configured retention window.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :default, max_attempts: 3
|
||||
|
||||
alias Elektrine.AuditLog
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
deleted = AuditLog.purge_older_than()
|
||||
{:ok, %{deleted: deleted}}
|
||||
end
|
||||
end
|
||||
|
|
@ -539,17 +539,83 @@ defmodule Elektrine.Reports do
|
|||
end
|
||||
|
||||
def build_metadata("message", message_id) do
|
||||
# Add logic to fetch message details when needed
|
||||
%{"message_id" => message_id}
|
||||
# Metadata + content hash only — never store plaintext body for operators.
|
||||
snapshot_message_metadata(message_id)
|
||||
end
|
||||
|
||||
def build_metadata("chat_message", message_id) do
|
||||
snapshot_chat_message_metadata(message_id)
|
||||
end
|
||||
|
||||
def build_metadata("conversation", conversation_id) do
|
||||
# Add logic to fetch conversation details when needed
|
||||
%{"conversation_id" => conversation_id}
|
||||
%{"conversation_id" => conversation_id, "snapshot_at" => snapshot_timestamp()}
|
||||
end
|
||||
|
||||
def build_metadata(_, _), do: %{}
|
||||
|
||||
defp snapshot_message_metadata(message_id) do
|
||||
case Repo.get(Elektrine.Social.Message, message_id) do
|
||||
nil ->
|
||||
%{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
|
||||
|
||||
message ->
|
||||
%{
|
||||
"message_id" => message.id,
|
||||
"conversation_id" => message.conversation_id,
|
||||
"sender_id" => message.sender_id,
|
||||
"message_type" => message.message_type,
|
||||
"post_type" => message.post_type,
|
||||
"visibility" => message.visibility,
|
||||
"title" => message.title,
|
||||
"content_sha256" => content_fingerprint(message),
|
||||
"media_count" => length(message.media_urls || []),
|
||||
"inserted_at" => datetime_iso(message.inserted_at),
|
||||
"snapshot_at" => snapshot_timestamp()
|
||||
}
|
||||
end
|
||||
rescue
|
||||
_ -> %{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
|
||||
end
|
||||
|
||||
defp snapshot_chat_message_metadata(message_id) do
|
||||
case Repo.get(Elektrine.Messaging.ChatMessage, message_id) do
|
||||
nil ->
|
||||
%{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
|
||||
|
||||
message ->
|
||||
%{
|
||||
"message_id" => message.id,
|
||||
"conversation_id" => message.conversation_id,
|
||||
"sender_id" => message.sender_id,
|
||||
"message_type" => message.message_type,
|
||||
"content_sha256" => content_fingerprint(message),
|
||||
"media_count" => length(message.media_urls || []),
|
||||
"inserted_at" => datetime_iso(message.inserted_at),
|
||||
"snapshot_at" => snapshot_timestamp()
|
||||
}
|
||||
end
|
||||
rescue
|
||||
_ -> %{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
|
||||
end
|
||||
|
||||
defp content_fingerprint(%{encrypted_content: enc}) when is_map(enc) and map_size(enc) > 0 do
|
||||
:crypto.hash(:sha256, :erlang.term_to_binary(enc)) |> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
defp content_fingerprint(%{content: content}) when is_binary(content) and content != "" do
|
||||
:crypto.hash(:sha256, content) |> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
defp content_fingerprint(_), do: nil
|
||||
|
||||
defp snapshot_timestamp do
|
||||
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
end
|
||||
|
||||
defp datetime_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
defp datetime_iso(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp datetime_iso(_), do: nil
|
||||
|
||||
defp maybe_record_report_creation(%Report{} = report) do
|
||||
if local_reporter?(report) do
|
||||
TrustLevel.increment_stat(report.reporter_id, :flags_given)
|
||||
|
|
|
|||
|
|
@ -456,12 +456,16 @@ defmodule Elektrine.Social.Messages do
|
|||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, deleted_message} ->
|
||||
media_urls = deleted_message.media_urls || []
|
||||
|
||||
_ =
|
||||
Elektrine.Social.AttachmentCleanupWorker.enqueue(
|
||||
deleted_message.id,
|
||||
deleted_message.media_urls || []
|
||||
media_urls
|
||||
)
|
||||
|
||||
_ = Elektrine.MediaProxy.purge(media_urls)
|
||||
|
||||
maybe_record_moderated_delete(deleted_message, user_id, is_admin, is_mod)
|
||||
broadcast_message_delete(deleted_message)
|
||||
notify_home_feed_message_deleted(deleted_message)
|
||||
|
|
@ -502,14 +506,28 @@ defmodule Elektrine.Social.Messages do
|
|||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, deleted_message} ->
|
||||
media_urls = deleted_message.media_urls || []
|
||||
|
||||
_ =
|
||||
Elektrine.Social.AttachmentCleanupWorker.enqueue(
|
||||
deleted_message.id,
|
||||
deleted_message.media_urls || []
|
||||
media_urls
|
||||
)
|
||||
|
||||
# Invalidate media-proxy state for remote attachments.
|
||||
_ = Elektrine.MediaProxy.purge(media_urls)
|
||||
|
||||
maybe_record_moderated_delete(deleted_message, admin_user.id, true, true)
|
||||
broadcast_message_delete(deleted_message)
|
||||
notify_home_feed_message_deleted(deleted_message)
|
||||
|
||||
# Same federation path as user deletes so remote copies get Delete/Tombstone.
|
||||
Elektrine.Async.start(fn ->
|
||||
preloaded = Repo.preload(deleted_message, :sender)
|
||||
Elektrine.ActivityPub.Outbox.federate_delete(preloaded)
|
||||
_ = Elektrine.Bluesky.OutboundWorker.enqueue_post_delete(deleted_message.id)
|
||||
end)
|
||||
|
||||
{:ok, deleted_message}
|
||||
|
||||
error ->
|
||||
|
|
|
|||
|
|
@ -3,10 +3,113 @@ defmodule Elektrine.Admin.ContentAccessTest do
|
|||
|
||||
alias Elektrine.Admin.ContentAccess
|
||||
|
||||
test "redaction notices are stable non-empty strings" do
|
||||
assert is_binary(ContentAccess.email_redacted_notice())
|
||||
assert ContentAccess.email_redacted_notice() != ""
|
||||
assert is_binary(ContentAccess.chat_redacted_notice())
|
||||
assert ContentAccess.chat_redacted_notice() != ""
|
||||
describe "notices" do
|
||||
test "email, chat, and social notices are non-empty binaries" do
|
||||
assert is_binary(ContentAccess.email_redacted_notice())
|
||||
assert ContentAccess.email_redacted_notice() != ""
|
||||
assert is_binary(ContentAccess.chat_redacted_notice())
|
||||
assert ContentAccess.chat_redacted_notice() != ""
|
||||
assert is_binary(ContentAccess.social_redacted_notice())
|
||||
assert ContentAccess.social_redacted_notice() != ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "redact_email_message/1" do
|
||||
test "replaces body fields and clears encrypted payloads" do
|
||||
message = %{
|
||||
subject: "Hello",
|
||||
from: "a@example.com",
|
||||
text_body: "SECRET BODY",
|
||||
html_body: "<p>SECRET</p>",
|
||||
encrypted_text_body: %{"c" => "x"},
|
||||
encrypted_html_body: %{"c" => "y"},
|
||||
encrypted_raw_source: %{"c" => "z"},
|
||||
raw_source: "raw",
|
||||
client_encrypted_payload: %{"p" => 1}
|
||||
}
|
||||
|
||||
redacted = ContentAccess.redact_email_message(message)
|
||||
|
||||
assert redacted.subject == "Hello"
|
||||
assert redacted.from == "a@example.com"
|
||||
assert redacted.text_body == ContentAccess.email_redacted_notice()
|
||||
refute redacted.text_body =~ "SECRET"
|
||||
assert is_nil(redacted.html_body)
|
||||
assert is_nil(redacted.encrypted_text_body)
|
||||
assert is_nil(redacted.encrypted_html_body)
|
||||
assert is_nil(redacted.encrypted_raw_source)
|
||||
assert is_nil(redacted.raw_source)
|
||||
assert is_nil(redacted.client_encrypted_payload)
|
||||
end
|
||||
end
|
||||
|
||||
describe "redact_chat_message/1" do
|
||||
test "replaces content and clears media and encrypted payloads" do
|
||||
message = %{
|
||||
id: 1,
|
||||
content: "SECRET CHAT",
|
||||
encrypted_content: %{"c" => "x"},
|
||||
client_encrypted_payload: %{"p" => 1},
|
||||
media_urls: ["https://example.com/secret.jpg"],
|
||||
media_metadata: %{w: 1},
|
||||
search_index: ["secret"],
|
||||
sender_id: 9
|
||||
}
|
||||
|
||||
redacted = ContentAccess.redact_chat_message(message)
|
||||
|
||||
assert redacted.id == 1
|
||||
assert redacted.sender_id == 9
|
||||
assert redacted.content == ContentAccess.chat_redacted_notice()
|
||||
refute redacted.content =~ "SECRET"
|
||||
assert is_nil(redacted.encrypted_content)
|
||||
assert is_nil(redacted.client_encrypted_payload)
|
||||
assert redacted.media_urls == []
|
||||
assert is_nil(redacted.media_metadata)
|
||||
assert redacted.search_index == []
|
||||
end
|
||||
|
||||
test "redacts a list of messages" do
|
||||
messages = [%{content: "a", media_urls: ["u"]}, %{content: "b", media_urls: ["v"]}]
|
||||
|
||||
assert Enum.all?(ContentAccess.redact_chat_messages(messages), fn m ->
|
||||
m.content == ContentAccess.chat_redacted_notice() and m.media_urls == []
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "redact_social_message/1" do
|
||||
test "replaces content and clears media and encrypted payloads" do
|
||||
message = %{
|
||||
id: 2,
|
||||
title: "Public title",
|
||||
content: "SECRET POST",
|
||||
encrypted_content: %{"c" => "x"},
|
||||
media_urls: ["https://example.com/post.jpg"],
|
||||
media_metadata: %{w: 1},
|
||||
search_index: ["secret"],
|
||||
sender_id: 3
|
||||
}
|
||||
|
||||
redacted = ContentAccess.redact_social_message(message)
|
||||
|
||||
assert redacted.id == 2
|
||||
assert redacted.title == "Public title"
|
||||
assert redacted.sender_id == 3
|
||||
assert redacted.content == ContentAccess.social_redacted_notice()
|
||||
refute redacted.content =~ "SECRET"
|
||||
assert is_nil(redacted.encrypted_content)
|
||||
assert redacted.media_urls == []
|
||||
assert redacted.media_metadata == %{}
|
||||
assert redacted.search_index == []
|
||||
end
|
||||
|
||||
test "redacts a list of social messages" do
|
||||
messages = [%{content: "a", media_urls: ["u"]}, %{content: "b", media_urls: ["v"]}]
|
||||
|
||||
assert Enum.all?(ContentAccess.redact_social_messages(messages), fn m ->
|
||||
m.content == ContentAccess.social_redacted_notice() and m.media_urls == []
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
54
apps/elektrine/test/elektrine/audit_log_test.exs
Normal file
54
apps/elektrine/test/elektrine/audit_log_test.exs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
defmodule Elektrine.AuditLogTest do
|
||||
use Elektrine.DataCase, async: true
|
||||
|
||||
alias Elektrine.AccountsFixtures
|
||||
alias Elektrine.AuditLog
|
||||
alias Elektrine.Repo
|
||||
|
||||
test "nulls IP and UA when via_tor is true" do
|
||||
admin = AccountsFixtures.user_fixture() |> make_admin()
|
||||
|
||||
{:ok, log} =
|
||||
AuditLog.log(admin.id, "elevate", "admin_session",
|
||||
ip_address: "1.2.3.4",
|
||||
user_agent: "TorBrowser",
|
||||
via_tor: true
|
||||
)
|
||||
|
||||
assert is_nil(log.ip_address)
|
||||
assert is_nil(log.user_agent)
|
||||
end
|
||||
|
||||
test "redacts email fields in details" do
|
||||
admin = AccountsFixtures.user_fixture() |> make_admin()
|
||||
|
||||
{:ok, log} =
|
||||
AuditLog.log(admin.id, "delete", "alias",
|
||||
details: %{alias_email: "secret@example.com", username: "bob"}
|
||||
)
|
||||
|
||||
assert log.details["username"] == "bob" or log.details[:username] == "bob"
|
||||
email = log.details["alias_email"] || log.details[:alias_email]
|
||||
refute email == "secret@example.com"
|
||||
assert is_binary(email)
|
||||
end
|
||||
|
||||
test "purge_older_than removes aged rows" do
|
||||
admin = AccountsFixtures.user_fixture() |> make_admin()
|
||||
|
||||
{:ok, old} = AuditLog.log(admin.id, "old_action", "user")
|
||||
past = DateTime.add(DateTime.utc_now(), -100, :day) |> DateTime.truncate(:second)
|
||||
|
||||
from_q = from(a in AuditLog, where: a.id == ^old.id)
|
||||
Repo.update_all(from_q, set: [inserted_at: past])
|
||||
|
||||
deleted = AuditLog.purge_older_than(retention_days: 90)
|
||||
assert deleted >= 1
|
||||
assert is_nil(Repo.get(AuditLog, old.id))
|
||||
end
|
||||
|
||||
defp make_admin(user) do
|
||||
{:ok, admin} = Elektrine.Accounts.admin_update_user(user, %{is_admin: true})
|
||||
admin
|
||||
end
|
||||
end
|
||||
|
|
@ -226,11 +226,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
|
|||
# Private helper functions
|
||||
|
||||
defp prepare_admin_email(message) do
|
||||
%{
|
||||
message
|
||||
| text_body: ContentAccess.email_redacted_notice(),
|
||||
html_body: nil
|
||||
}
|
||||
ContentAccess.redact_email_message(message)
|
||||
end
|
||||
|
||||
defp require_message_read_grant(conn, _opts) do
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ defmodule ElektrineSocialWeb.MediaProxyController do
|
|||
@max_redirects 5
|
||||
@keep_req_headers ~w(range if-range if-modified-since if-none-match accept accept-encoding)
|
||||
@keep_resp_headers ~w(content-type content-length content-range accept-ranges etag last-modified date content-encoding vary)
|
||||
@cache_control "public, max-age=1209600, immutable"
|
||||
# Short browser cache (1 hour). Avoid multi-week retention of remote media.
|
||||
@cache_control "public, max-age=3600"
|
||||
@valid_status [200, 206, 304]
|
||||
@conn_key :media_proxy_conn
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule ElektrineWeb.Admin.CommunitiesController do
|
|||
use ElektrineWeb, :controller
|
||||
|
||||
alias Elektrine.{Accounts, Repo}
|
||||
alias Elektrine.Admin.ContentAccess
|
||||
import Ecto.Query
|
||||
|
||||
plug :put_layout, html: {ElektrineWeb.Layouts, :admin}
|
||||
|
|
@ -237,7 +238,8 @@ defmodule ElektrineWeb.Admin.CommunitiesController do
|
|||
|> offset(^((posts_page - 1) * posts_per_page))
|
||||
|> preload(:sender)
|
||||
|> Repo.all()
|
||||
|> Elektrine.Social.Message.decrypt_messages()
|
||||
# Never decrypt post bodies for admin.
|
||||
|> ContentAccess.redact_social_messages()
|
||||
|> Enum.map(fn m ->
|
||||
%{
|
||||
id: m.id,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
defmodule ElektrineWeb.Admin.ModerationController do
|
||||
@moduledoc """
|
||||
Report-only content moderation.
|
||||
|
||||
Lists only content that has an open (pending/reviewing) report — not a
|
||||
full-history browser. Bodies are always redacted via ContentAccess.
|
||||
"""
|
||||
|
||||
use ElektrineWeb, :controller
|
||||
|
||||
alias Elektrine.{Accounts, Repo}
|
||||
alias Elektrine.Admin.ContentAccess
|
||||
alias Elektrine.Reports.Report
|
||||
alias Elektrine.Social.Messages
|
||||
alias Elektrine.Utils.SafeConvert
|
||||
alias ElektrineWeb.Platform.Integrations
|
||||
|
|
@ -16,7 +25,8 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
offset = (page - 1) * per_page
|
||||
search_query = params["search"] || ""
|
||||
|
||||
# Build query based on content type
|
||||
reported_ids = open_reported_ids(content_type)
|
||||
|
||||
base_query =
|
||||
case content_type do
|
||||
"timeline" ->
|
||||
|
|
@ -24,61 +34,42 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
join: c in Elektrine.Social.Conversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type == "timeline",
|
||||
where: is_nil(m.deleted_at)
|
||||
where: is_nil(m.deleted_at),
|
||||
where: m.id in ^reported_ids
|
||||
|
||||
"discussions" ->
|
||||
from m in Elektrine.Social.Message,
|
||||
join: c in Elektrine.Social.Conversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type == "community",
|
||||
where: is_nil(m.deleted_at)
|
||||
where: is_nil(m.deleted_at),
|
||||
where: m.id in ^reported_ids
|
||||
|
||||
"chat" ->
|
||||
from m in Elektrine.Messaging.ChatMessage,
|
||||
join: c in Elektrine.Messaging.ChatConversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type in ["group", "dm", "channel"],
|
||||
where: is_nil(m.deleted_at)
|
||||
where: is_nil(m.deleted_at),
|
||||
where: m.id in ^reported_ids
|
||||
|
||||
_ ->
|
||||
from m in Elektrine.Social.Message,
|
||||
join: c in Elektrine.Social.Conversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type == "timeline",
|
||||
where: is_nil(m.deleted_at)
|
||||
where: is_nil(m.deleted_at),
|
||||
where: m.id in ^reported_ids
|
||||
end
|
||||
|
||||
# Add search filter if provided
|
||||
# Empty reported set → empty list (Ecto `in []` can be awkward).
|
||||
query =
|
||||
if search_query != "" do
|
||||
search_pattern = "%#{search_query}%"
|
||||
|
||||
if content_type == "chat" do
|
||||
from [m, c] in base_query,
|
||||
left_join: u in Accounts.User,
|
||||
on: m.sender_id == u.id,
|
||||
where:
|
||||
ilike(fragment("COALESCE(?, '')", m.content), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", u.username), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", u.handle), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_pattern)
|
||||
else
|
||||
from [m, c] in base_query,
|
||||
join: u in Accounts.User,
|
||||
on: m.sender_id == u.id,
|
||||
where:
|
||||
ilike(m.content, ^search_pattern) or
|
||||
ilike(u.username, ^search_pattern) or
|
||||
ilike(u.handle, ^search_pattern) or
|
||||
ilike(c.name, ^search_pattern)
|
||||
end
|
||||
if reported_ids == [] do
|
||||
from(m in base_query, where: false)
|
||||
else
|
||||
base_query
|
||||
maybe_filter_search(base_query, content_type, search_query)
|
||||
end
|
||||
|
||||
# Get content
|
||||
content =
|
||||
query
|
||||
|> order_by([m], desc: m.inserted_at)
|
||||
|
|
@ -86,40 +77,15 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
|> offset(^offset)
|
||||
|> preload([:sender, conversation: []])
|
||||
|> Repo.all()
|
||||
|> then(fn messages ->
|
||||
if content_type == "chat" do
|
||||
Elektrine.Messaging.ChatMessage.decrypt_messages(messages)
|
||||
else
|
||||
Elektrine.Social.Message.decrypt_messages(messages)
|
||||
end
|
||||
end)
|
||||
|> redact_admin_messages(content_type)
|
||||
|
||||
# Get counts for all types
|
||||
counts = %{
|
||||
timeline:
|
||||
from(m in Elektrine.Social.Message,
|
||||
join: c in Elektrine.Social.Conversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type == "timeline" and is_nil(m.deleted_at)
|
||||
)
|
||||
|> Repo.aggregate(:count),
|
||||
discussions:
|
||||
from(m in Elektrine.Social.Message,
|
||||
join: c in Elektrine.Social.Conversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type == "community" and is_nil(m.deleted_at)
|
||||
)
|
||||
|> Repo.aggregate(:count),
|
||||
chat:
|
||||
from(m in Elektrine.Messaging.ChatMessage,
|
||||
join: c in Elektrine.Messaging.ChatConversation,
|
||||
on: m.conversation_id == c.id,
|
||||
where: c.type in ["group", "dm", "channel"] and is_nil(m.deleted_at)
|
||||
)
|
||||
|> Repo.aggregate(:count)
|
||||
timeline: count_open_reports(["message", "social_message"]),
|
||||
discussions: count_open_reports(["message", "social_message"]),
|
||||
chat: count_open_reports(["chat_message", "message"])
|
||||
}
|
||||
|
||||
total_count = query |> Repo.aggregate(:count)
|
||||
total_count = if reported_ids == [], do: 0, else: query |> Repo.aggregate(:count)
|
||||
total_pages = ceil(total_count / per_page)
|
||||
page_range = pagination_range(page, total_pages)
|
||||
|
||||
|
|
@ -137,7 +103,8 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
total_count: total_count,
|
||||
page_range: page_range,
|
||||
timezone: timezone,
|
||||
time_format: time_format
|
||||
time_format: time_format,
|
||||
report_only: true
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -151,12 +118,20 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
end
|
||||
|
||||
with {:ok, content_id} <- SafeConvert.parse_id(content_id),
|
||||
true <- reported_open?(content_type, content_id),
|
||||
{:ok, _message} <-
|
||||
Messages.admin_delete_message(content_id, conn.assigns.current_user) do
|
||||
conn
|
||||
|> put_flash(:info, "#{content_name} deleted successfully.")
|
||||
|> redirect(to: ~p"/pripyat/content-moderation?type=#{content_type}")
|
||||
else
|
||||
false ->
|
||||
redirect_delete_error(
|
||||
conn,
|
||||
"Only reported content can be moderated here.",
|
||||
content_type
|
||||
)
|
||||
|
||||
{:error, :already_deleted} ->
|
||||
redirect_delete_error(conn, "#{content_name} was already deleted.", content_type)
|
||||
|
||||
|
|
@ -181,6 +156,95 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
|> redirect(to: ~p"/pripyat/content-moderation?type=#{content_type}")
|
||||
end
|
||||
|
||||
defp open_reported_ids(content_type) do
|
||||
types = reportable_types(content_type)
|
||||
|
||||
from(r in Report,
|
||||
where: r.reportable_type in ^types and r.status in ["pending", "reviewing"],
|
||||
select: r.reportable_id,
|
||||
distinct: true
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp reported_open?(content_type, content_id) do
|
||||
types = reportable_types(content_type)
|
||||
|
||||
from(r in Report,
|
||||
where:
|
||||
r.reportable_type in ^types and r.reportable_id == ^content_id and
|
||||
r.status in ["pending", "reviewing"]
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
defp reportable_types("chat"), do: ["chat_message", "message"]
|
||||
defp reportable_types(_), do: ["message", "social_message"]
|
||||
|
||||
defp count_open_reports(types) do
|
||||
from(r in Report,
|
||||
where: r.reportable_type in ^types and r.status in ["pending", "reviewing"]
|
||||
)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
defp redact_admin_messages(messages, "chat"), do: ContentAccess.redact_chat_messages(messages)
|
||||
defp redact_admin_messages(messages, _), do: ContentAccess.redact_social_messages(messages)
|
||||
|
||||
defp maybe_filter_search(query, _content_type, search_query)
|
||||
when search_query in [nil, ""],
|
||||
do: query
|
||||
|
||||
defp maybe_filter_search(query, "chat", search_query) do
|
||||
search_pattern = "%#{search_query}%"
|
||||
id_match = parse_numeric_id(search_query)
|
||||
|
||||
filter =
|
||||
dynamic(
|
||||
[m, c, u],
|
||||
ilike(fragment("COALESCE(?, '')", u.username), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", u.handle), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_pattern) or
|
||||
(^id_match != 0 and m.id == ^id_match)
|
||||
)
|
||||
|
||||
from([m, c] in query,
|
||||
left_join: u in Accounts.User,
|
||||
on: m.sender_id == u.id,
|
||||
where: ^filter
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_filter_search(query, _content_type, search_query) do
|
||||
search_pattern = "%#{search_query}%"
|
||||
id_match = parse_numeric_id(search_query)
|
||||
|
||||
filter =
|
||||
dynamic(
|
||||
[m, c, u],
|
||||
ilike(u.username, ^search_pattern) or
|
||||
ilike(u.handle, ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
|
||||
ilike(fragment("COALESCE(?, '')", m.title), ^search_pattern) or
|
||||
(^id_match != 0 and m.id == ^id_match)
|
||||
)
|
||||
|
||||
from([m, c] in query,
|
||||
join: u in Accounts.User,
|
||||
on: m.sender_id == u.id,
|
||||
where: ^filter
|
||||
)
|
||||
end
|
||||
|
||||
defp parse_numeric_id(search_query) do
|
||||
case Integer.parse(String.trim(search_query)) do
|
||||
{id, ""} when id > 0 -> id
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
def unsubscribe_stats(conn, params) do
|
||||
page = SafeConvert.parse_page(params)
|
||||
per_page = 50
|
||||
|
|
@ -196,7 +260,6 @@ defmodule ElektrineWeb.Admin.ModerationController do
|
|||
)
|
||||
end
|
||||
|
||||
# Helper for pagination
|
||||
defp pagination_range(_current_page, total_pages) when total_pages <= 7 do
|
||||
1..max(total_pages, 1) |> Enum.to_list()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -55,6 +55,20 @@ defmodule ElektrineWeb.Admin.SecurityController do
|
|||
{:ok, verified_user, credential} <-
|
||||
Passkeys.verify_authentication_with_credential(challenge, assertion),
|
||||
true <- verified_user.id == user.id do
|
||||
_ =
|
||||
Elektrine.AuditLog.log(
|
||||
user.id,
|
||||
"elevate",
|
||||
"admin_session",
|
||||
details: %{
|
||||
return_to: return_to,
|
||||
credential_id_prefix: credential_id_prefix(credential.credential_id)
|
||||
},
|
||||
ip_address: ElektrineWeb.ClientIP.client_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first(),
|
||||
via_tor: conn.assigns[:via_tor] == true
|
||||
)
|
||||
|
||||
conn
|
||||
|> AdminSecurity.refresh_after_passkey(credential.credential_id)
|
||||
|> json(%{
|
||||
|
|
@ -79,6 +93,12 @@ defmodule ElektrineWeb.Admin.SecurityController do
|
|||
end
|
||||
end
|
||||
|
||||
defp credential_id_prefix(id) when is_binary(id) and byte_size(id) > 8 do
|
||||
String.slice(id, 0, 8) <> "…"
|
||||
end
|
||||
|
||||
defp credential_id_prefix(_), do: nil
|
||||
|
||||
def start_action(conn, %{"method" => method, "path" => path}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
trust_level_locked: user.trust_level_locked
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -291,7 +291,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
target_user_id: user.id,
|
||||
details: %{username: user.username, reason: ban_params["reason"]},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -319,7 +319,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
target_user_id: user.id,
|
||||
details: %{username: user.username},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
# Determine redirect based on where the request came from
|
||||
|
|
@ -390,7 +390,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
reason: suspend_params["suspension_reason"]
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -423,7 +423,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
target_user_id: user.id,
|
||||
details: %{username: user.username},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -459,7 +459,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
admin_username: admin_user.username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
# Clear impersonation session and restore admin user
|
||||
|
|
@ -500,7 +500,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
"user",
|
||||
details: %{username: user.username, user_id: user.id},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -532,7 +532,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
reset_by_admin: admin.username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -571,7 +571,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
username: user.username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -626,7 +626,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
exact_match: is_exact_match
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
search_results =
|
||||
|
|
@ -667,7 +667,7 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
reset_by_admin: admin.username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
user_agent: get_user_agent(conn)
|
||||
)
|
||||
|
||||
conn
|
||||
|
|
@ -965,6 +965,16 @@ defmodule ElektrineWeb.Admin.UsersController do
|
|||
end
|
||||
|
||||
defp get_remote_ip(conn) do
|
||||
ElektrineWeb.ClientIP.client_ip(conn)
|
||||
Elektrine.Privacy.Network.login_ip(
|
||||
ElektrineWeb.ClientIP.client_ip(conn),
|
||||
via_tor: conn.assigns[:via_tor] == true
|
||||
)
|
||||
end
|
||||
|
||||
defp get_user_agent(conn) do
|
||||
Elektrine.Privacy.Network.analytics_user_agent(
|
||||
get_req_header(conn, "user-agent") |> List.first(),
|
||||
via_tor: conn.assigns[:via_tor] == true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -230,14 +230,16 @@
|
|||
class="mx-auto mb-3 h-10 w-10 text-base-content/30"
|
||||
/>
|
||||
<h3 class="text-lg font-medium">
|
||||
No {String.downcase(content_label)} content found
|
||||
No reported {String.downcase(content_label)} content
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-sm text-base-content/55">
|
||||
<%= if search_active do %>
|
||||
Try changing your query or clearing filters.
|
||||
<% else %>
|
||||
New content will appear here as users post.
|
||||
Only items with an open report appear here. Open the
|
||||
<.link href={~p"/pripyat/reports"} class="link link-primary">reports queue</.link>
|
||||
to triage new reports.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,22 @@ defmodule ElektrineWeb.JsonLogFormatter do
|
|||
@moduledoc """
|
||||
JSON log formatter for production environments.
|
||||
Outputs logs in JSON format so log pipelines can parse levels reliably.
|
||||
|
||||
Sensitive metadata keys (tokens, passwords, email addresses, subjects, bodies)
|
||||
are redacted so host log retention cannot reconstruct private content.
|
||||
"""
|
||||
|
||||
@sensitive_exact ~w(
|
||||
authorization cookie set-cookie token secret password api_key
|
||||
email subject from to cc bcc text_body html_body body content
|
||||
message message_body raw_source recovery_email
|
||||
)
|
||||
|
||||
@sensitive_substrings ~w(
|
||||
token secret password api_key authorization cookie
|
||||
email subject text_body html_body raw_source
|
||||
)
|
||||
|
||||
@doc """
|
||||
Formats a log message as JSON.
|
||||
"""
|
||||
|
|
@ -12,17 +26,17 @@ defmodule ElektrineWeb.JsonLogFormatter do
|
|||
|
||||
json = %{
|
||||
level: level,
|
||||
message: IO.chardata_to_string(message),
|
||||
message: redact_message_text(IO.chardata_to_string(message)),
|
||||
timestamp: format_timestamp(date, hour, minute, second),
|
||||
metadata: format_metadata(metadata)
|
||||
}
|
||||
|
||||
case Jason.encode(json) do
|
||||
{:ok, encoded} -> [encoded, "\n"]
|
||||
{:error, _} -> ["[#{level}] #{message}\n"]
|
||||
{:error, _} -> ["[#{level}] #{redact_message_text(message)}\n"]
|
||||
end
|
||||
rescue
|
||||
_ -> ["[#{level}] #{message}\n"]
|
||||
_ -> ["[#{level}] log format error\n"]
|
||||
end
|
||||
|
||||
defp format_timestamp({year, month, day}, hour, minute, second) do
|
||||
|
|
@ -45,20 +59,36 @@ defmodule ElektrineWeb.JsonLogFormatter do
|
|||
end
|
||||
|
||||
defp redact_metadata(key, value) do
|
||||
key
|
||||
|> to_string()
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
name
|
||||
when name in ["authorization", "cookie", "set-cookie", "token", "secret", "password"] ->
|
||||
name = key |> to_string() |> String.downcase()
|
||||
|
||||
cond do
|
||||
name in @sensitive_exact ->
|
||||
"[redacted]"
|
||||
|
||||
name ->
|
||||
if String.contains?(name, ["token", "secret", "password", "api_key"]) do
|
||||
"[redacted]"
|
||||
else
|
||||
value
|
||||
end
|
||||
Enum.any?(@sensitive_substrings, &String.contains?(name, &1)) ->
|
||||
"[redacted]"
|
||||
|
||||
is_binary(value) and looks_like_email?(value) ->
|
||||
"[redacted]"
|
||||
|
||||
true ->
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
defp redact_message_text(message) when is_binary(message) do
|
||||
message
|
||||
|> String.replace(~r/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, "[redacted-email]")
|
||||
|> String.replace(
|
||||
~r/(?i)(password|token|secret|authorization)\s*[:=]\s*\S+/,
|
||||
"\\1=[redacted]"
|
||||
)
|
||||
end
|
||||
|
||||
defp redact_message_text(message), do: to_string(message)
|
||||
|
||||
defp looks_like_email?(value) do
|
||||
String.contains?(value, "@") and
|
||||
String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,16 +8,20 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
|
|||
alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessages}
|
||||
|
||||
describe "admin arblarg chat message views" do
|
||||
test "renders message console and logs list view", %{conn: conn} do
|
||||
%{admin: admin} = admin_chat_message_fixture()
|
||||
test "renders message console, redacts list bodies, and logs list view", %{conn: conn} do
|
||||
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
|
||||
%{admin: admin} = admin_chat_message_fixture(secret)
|
||||
|
||||
conn =
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/arblarg/messages")
|
||||
|> html_response(200)
|
||||
|
||||
assert html_response(conn, 200) =~ "Chat Message Console"
|
||||
assert html =~ "Chat Message Console"
|
||||
assert html =~ "not available in admin"
|
||||
refute html =~ secret
|
||||
|
||||
log = latest_list_log(admin.id)
|
||||
assert log.action == "view_chat_messages"
|
||||
|
|
@ -26,18 +30,20 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
|
|||
assert log.details["result_count"] >= 1
|
||||
end
|
||||
|
||||
test "renders message detail and logs view", %{conn: conn} do
|
||||
%{admin: admin, message: message} = admin_chat_message_fixture()
|
||||
test "renders message detail redacted and logs view", %{conn: conn} do
|
||||
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
|
||||
%{admin: admin, message: message} = admin_chat_message_fixture(secret)
|
||||
|
||||
conn =
|
||||
response =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/arblarg/messages/#{message.id}/view")
|
||||
|> html_response(200)
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Chat Message"
|
||||
assert response =~ message.content
|
||||
assert response =~ "not available in admin"
|
||||
refute response =~ secret
|
||||
|
||||
log = latest_message_log(admin.id, message.id)
|
||||
assert log.action == "view_chat_message"
|
||||
|
|
@ -45,25 +51,54 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
|
|||
assert log.details["view_format"] == "html"
|
||||
end
|
||||
|
||||
test "renders raw message and logs raw view", %{conn: conn} do
|
||||
%{admin: admin, message: message} = admin_chat_message_fixture()
|
||||
test "renders raw message redacted and logs raw view", %{conn: conn} do
|
||||
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
|
||||
%{admin: admin, message: message} = admin_chat_message_fixture(secret)
|
||||
|
||||
conn =
|
||||
body =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/arblarg/messages/#{message.id}/raw")
|
||||
|> response(200)
|
||||
|
||||
assert response(conn, 200) =~ "ARBLARG CHAT MESSAGE"
|
||||
assert body =~ "not available in admin"
|
||||
refute body =~ secret
|
||||
|
||||
log = latest_message_log(admin.id, message.id)
|
||||
assert log.action == "view_chat_message"
|
||||
assert log.resource_type == "chat_message"
|
||||
assert log.details["view_format"] == "raw"
|
||||
end
|
||||
|
||||
test "search does not match body text", %{conn: conn} do
|
||||
secret = "UNIQUE_BODY_TOKEN_#{System.unique_integer([:positive])}"
|
||||
%{admin: admin, message: message, sender: sender} = admin_chat_message_fixture(secret)
|
||||
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/arblarg/messages", %{"search" => secret})
|
||||
|> html_response(200)
|
||||
|
||||
# Body text must not find the message (search box may echo the query).
|
||||
refute html =~ ~s(/pripyat/arblarg/messages/#{message.id}/view)
|
||||
assert html =~ "No chat messages found"
|
||||
|
||||
# Username search still works (metadata).
|
||||
html_user =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/arblarg/messages", %{"search" => sender.username})
|
||||
|> html_response(200)
|
||||
|
||||
assert html_user =~ ~s(/pripyat/arblarg/messages/#{message.id}/view)
|
||||
end
|
||||
end
|
||||
|
||||
defp admin_chat_message_fixture do
|
||||
defp admin_chat_message_fixture(content) do
|
||||
admin = AccountsFixtures.user_fixture() |> make_admin()
|
||||
sender = AccountsFixtures.user_fixture()
|
||||
|
||||
|
|
@ -88,7 +123,7 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
|
|||
ChatMessages.create_text_message(
|
||||
conversation.id,
|
||||
sender.id,
|
||||
"Arblarg test message #{System.unique_integer([:positive])}"
|
||||
content
|
||||
)
|
||||
|
||||
%{admin: admin, sender: sender, conversation: conversation, message: message}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule ElektrineWeb.Admin.CommunitiesControllerTest do
|
|||
alias Elektrine.AccountsFixtures
|
||||
alias Elektrine.Repo
|
||||
alias Elektrine.Social.Conversation
|
||||
alias Elektrine.SocialFixtures
|
||||
|
||||
describe "GET /pripyat/communities" do
|
||||
test "renders with fallback timezone when admin timezone is nil", %{conn: conn} do
|
||||
|
|
@ -31,6 +32,32 @@ defmodule ElektrineWeb.Admin.CommunitiesControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "GET /pripyat/communities/:id" do
|
||||
test "redacts discussion post bodies on community show", %{conn: conn} do
|
||||
admin = AccountsFixtures.user_fixture() |> make_admin()
|
||||
author = AccountsFixtures.user_fixture()
|
||||
secret = "SECRET_COMMUNITY_POST_#{System.unique_integer([:positive])}"
|
||||
community = SocialFixtures.community_conversation_fixture(author)
|
||||
|
||||
_post =
|
||||
SocialFixtures.discussion_post_fixture(%{
|
||||
user: author,
|
||||
community: community,
|
||||
content: secret
|
||||
})
|
||||
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/communities/#{community.id}")
|
||||
|> html_response(200)
|
||||
|
||||
assert html =~ "not available in admin"
|
||||
refute html =~ secret
|
||||
end
|
||||
end
|
||||
|
||||
defp make_admin(user) do
|
||||
{:ok, admin_user} = Accounts.admin_update_user(user, %{is_admin: true})
|
||||
admin_user
|
||||
|
|
|
|||
|
|
@ -10,8 +10,13 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
|
|||
alias ElektrineWeb.AdminSecurity
|
||||
|
||||
describe "admin email view logging" do
|
||||
test "logs standard admin message view", %{conn: conn} do
|
||||
%{admin: admin, owner: owner, message: message} = admin_message_fixture()
|
||||
test "logs standard admin message view and redacts body", %{conn: conn} do
|
||||
%{admin: admin, owner: owner, message: message} =
|
||||
admin_message_fixture(%{
|
||||
text_body: "EMAIL CONTENT SECRET",
|
||||
html_body: "<p>EMAIL CONTENT SECRET</p>"
|
||||
})
|
||||
|
||||
request_path = "/pripyat/messages/#{message.id}/view"
|
||||
|
||||
conn =
|
||||
|
|
@ -19,12 +24,16 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
|
|||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|
||||
conn =
|
||||
get(conn, request_path, %{
|
||||
html =
|
||||
conn
|
||||
|> get(request_path, %{
|
||||
"_admin_action_grant" => grant_read_access(conn, admin, request_path)
|
||||
})
|
||||
|> html_response(200)
|
||||
|
||||
assert html_response(conn, 200) =~ "Message Details"
|
||||
assert html =~ "Message Details"
|
||||
assert html =~ "not available in admin"
|
||||
refute html =~ "EMAIL CONTENT SECRET"
|
||||
|
||||
log = latest_view_email_log(admin.id, message.id)
|
||||
assert log.resource_type == "email_message"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,95 @@ defmodule ElektrineWeb.Admin.ModerationControllerTest do
|
|||
|
||||
alias Elektrine.Accounts
|
||||
alias Elektrine.AccountsFixtures
|
||||
alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessages}
|
||||
alias Elektrine.Repo
|
||||
alias Elektrine.SocialFixtures
|
||||
alias ElektrineWeb.AdminSecurity
|
||||
|
||||
describe "GET /pripyat/content-moderation" do
|
||||
test "is report-only: unreported posts do not appear", %{conn: conn} do
|
||||
admin = admin_user_fixture()
|
||||
secret = "SECRET_TIMELINE_BODY_#{System.unique_integer([:positive])}"
|
||||
author = AccountsFixtures.user_fixture()
|
||||
_post = SocialFixtures.post_fixture(%{user: author, content: secret})
|
||||
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/content-moderation", %{"type" => "timeline"})
|
||||
|> html_response(200)
|
||||
|
||||
assert html =~ "No reported"
|
||||
refute html =~ secret
|
||||
end
|
||||
|
||||
test "shows reported content with redacted body", %{conn: conn} do
|
||||
admin = admin_user_fixture()
|
||||
secret = "SECRET_REPORTED_BODY_#{System.unique_integer([:positive])}"
|
||||
author = AccountsFixtures.user_fixture()
|
||||
reporter = AccountsFixtures.user_fixture()
|
||||
post = SocialFixtures.post_fixture(%{user: author, content: secret})
|
||||
|
||||
{:ok, _report} =
|
||||
Elektrine.Reports.create_report(%{
|
||||
reporter_id: reporter.id,
|
||||
reportable_type: "message",
|
||||
reportable_id: post.id,
|
||||
reason: "spam",
|
||||
description: "looks like spam",
|
||||
metadata: Elektrine.Reports.build_metadata("message", post.id)
|
||||
})
|
||||
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/content-moderation", %{"type" => "timeline"})
|
||||
|> html_response(200)
|
||||
|
||||
assert html =~ "not available in admin"
|
||||
refute html =~ secret
|
||||
assert html =~ author.username or html =~ author.handle
|
||||
end
|
||||
|
||||
test "unreported chat messages do not appear", %{conn: conn} do
|
||||
admin = admin_user_fixture()
|
||||
secret = "SECRET_CHAT_MOD_#{System.unique_integer([:positive])}"
|
||||
sender = AccountsFixtures.user_fixture()
|
||||
|
||||
conversation =
|
||||
%ChatConversation{}
|
||||
|> ChatConversation.group_changeset(%{
|
||||
creator_id: sender.id,
|
||||
name: "mod-chat-#{System.unique_integer([:positive])}",
|
||||
description: "moderation chat test"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%ChatConversationMember{}
|
||||
|> ChatConversationMember.changeset(%{
|
||||
conversation_id: conversation.id,
|
||||
user_id: sender.id,
|
||||
role: "admin"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
{:ok, _message} =
|
||||
ChatMessages.create_text_message(conversation.id, sender.id, secret)
|
||||
|
||||
html =
|
||||
conn
|
||||
|> with_elektrine_host()
|
||||
|> log_in_as(admin)
|
||||
|> get("/pripyat/content-moderation", %{"type" => "chat"})
|
||||
|> html_response(200)
|
||||
|
||||
assert html =~ "No reported"
|
||||
refute html =~ secret
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /pripyat/content-moderation/delete" do
|
||||
test "redirects with an error for malformed content ids", %{conn: conn} do
|
||||
admin = admin_user_fixture()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
defmodule ElektrineWeb.JsonLogFormatterTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias ElektrineWeb.JsonLogFormatter
|
||||
|
||||
test "redacts sensitive metadata keys and email-like values" do
|
||||
ts = {{2026, 1, 2}, {3, 4, 5, 0}}
|
||||
|
||||
out =
|
||||
JsonLogFormatter.format(
|
||||
:info,
|
||||
"user alice@example.com logged in",
|
||||
ts,
|
||||
email: "alice@example.com",
|
||||
subject: "Secret subject",
|
||||
token: "abc",
|
||||
request_id: "rid-1"
|
||||
)
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
assert out =~ "rid-1"
|
||||
assert out =~ "[redacted]"
|
||||
refute out =~ "alice@example.com"
|
||||
refute out =~ "Secret subject"
|
||||
refute out =~ "abc"
|
||||
assert out =~ "[redacted-email]"
|
||||
end
|
||||
end
|
||||
|
|
@ -185,6 +185,8 @@ config :elektrine, Oban,
|
|||
{"50 3 * * *", Elektrine.Profiles.AnalyticsRetentionWorker},
|
||||
# Null stored client IPs after retention window (default 14 days)
|
||||
{"55 3 * * *", Elektrine.Jobs.IpRetentionWorker},
|
||||
# Purge old admin audit rows (default 90 days; IPs null earlier via IpRetention)
|
||||
{"0 4 * * *", Elektrine.Jobs.AuditLogRetentionWorker},
|
||||
# Prune DNS query rollups (dns_query_stats only; not profile/site analytics)
|
||||
{"5 4 * * *", Elektrine.DNS.QueryStatsRetentionWorker},
|
||||
# Keep public site analytics rollups fresh for fast domain analytics pages
|
||||
|
|
@ -638,6 +640,14 @@ config :elektrine, :media_proxy,
|
|||
whitelist: [],
|
||||
blocklist: []
|
||||
|
||||
# Inbound ActivityPub: reject DMs (direct) by default. Followers-only allowed.
|
||||
config :elektrine, :mrf_reject_non_public,
|
||||
allow_direct: false,
|
||||
allow_followers_only: true
|
||||
|
||||
# Admin audit log row retention (IPs null earlier via :ip_retention)
|
||||
config :elektrine, :audit_log, retention_days: 90
|
||||
|
||||
# Optional per-domain MRF subchains (prepended before the global policy list)
|
||||
config :elektrine, :mrf_subchains, %{}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,18 @@ App AES protects a stolen disk when secrets stay elsewhere. It does not hide mai
|
|||
| Item | Default |
|
||||
|------|---------|
|
||||
| Admin mail and chat bodies | Not shown |
|
||||
| Admin content moderation | Report-only (open reports) |
|
||||
| Admin impersonation | Off |
|
||||
| Inbound ActivityPub DMs | Rejected (`mrf_reject_non_public.allow_direct: false`) |
|
||||
| `EMAIL_RAW_SOURCE_MAX_BYTES` | `0` (no second RFC822 copy) |
|
||||
| `EMAIL_STORE_INBOUND_REMOTE_IP` | `false` |
|
||||
| `EMAIL_TRASH_RETENTION_DAYS` | `30` |
|
||||
| `EMAIL_SPAM_RETENTION_DAYS` | `30` |
|
||||
| `EMAIL_INBOX_RETENTION_DAYS` | `0` (keep. Set e.g. `365` to cap age) |
|
||||
| `VPN_DURABLE_SESSION_LOGS` | `false` |
|
||||
| Audit log row retention | 90 days |
|
||||
| IP fields (incl. audit IP) | Null after 14 days |
|
||||
| Media proxy browser cache | 1 hour when proxy enabled |
|
||||
|
||||
## Secrets
|
||||
|
||||
|
|
@ -87,6 +92,26 @@ ELEKTRINE_RELEASE_MODULES=chat,social,nerve docker compose build app
|
|||
3. `HARAKA_DLQ_STORE_PAYLOAD=false`.
|
||||
4. Optional: `docker-compose.privacy.yml` for Redis without AOF/RDB.
|
||||
5. After triage run `scripts/purge-dlq.sh`.
|
||||
6. Assert defaults: `scripts/ops/check_haraka_privacy.sh` (set `HARAKA_DEPLOY_DIR`).
|
||||
|
||||
## Logs that forget
|
||||
|
||||
1. Prod JSON logs redact tokens, passwords, email-like fields, subjects, bodies.
|
||||
2. Prefer host journald retention of a few days; do not ship full mail subjects to log drains.
|
||||
3. Keep `VPN_DURABLE_SESSION_LOGS=false` so connect events are not stored in Postgres.
|
||||
|
||||
## Federation
|
||||
|
||||
1. User and admin deletes federate Delete/Tombstone for public social posts.
|
||||
2. Chat DMs are not federated; inbound ActivityPub directs are rejected by default.
|
||||
3. Post delete purges media-proxy failure/ban state for attachment URLs.
|
||||
|
||||
## Backups
|
||||
|
||||
1. Use `scripts/ops/pg_backup.sh` with `AGE_RECIPIENT` or `GPG_RECIPIENT`.
|
||||
2. Default retention 14 days (`RETENTION_DAYS`) — match or beat IP retention.
|
||||
3. Encrypt dumps. Keep dump keys off the dump store.
|
||||
4. Never store `.env` / `ELEKTRINE_MASTER_SECRET` next to dumps (script refuses unsafe dirs).
|
||||
|
||||
## Content that stays private from the operator
|
||||
|
||||
|
|
@ -94,6 +119,7 @@ ELEKTRINE_RELEASE_MODULES=chat,social,nerve docker compose build app
|
|||
2. PGP for high-value mail.
|
||||
3. Client E2E chat when available.
|
||||
4. Shorter trash and spam retention. Optional inbox age cap.
|
||||
5. Admin moderation is report-only; bodies stay redacted.
|
||||
|
||||
Retention deletes rows. Until delete, app secrets still decrypt normal mail.
|
||||
|
||||
|
|
|
|||
64
scripts/ops/check_haraka_privacy.sh
Executable file
64
scripts/ops/check_haraka_privacy.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
# Assert Haraka privacy defaults on a deployment (log level, no remote queue store, no DLQ payloads).
|
||||
#
|
||||
# Usage:
|
||||
# HARAKA_DEPLOY_DIR=/opt/elektrine-haraka scripts/ops/check_haraka_privacy.sh
|
||||
#
|
||||
# Exit 0 if checks pass or Haraka dir is absent (skip). Exit 1 if privacy defaults are wrong.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HARAKA_DEPLOY_DIR="${HARAKA_DEPLOY_DIR:-/opt/elektrine-haraka}"
|
||||
|
||||
if [[ ! -d "${HARAKA_DEPLOY_DIR}" ]]; then
|
||||
echo "Haraka deploy dir not found (${HARAKA_DEPLOY_DIR}); skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
check_env() {
|
||||
local key="$1"
|
||||
local expected="$2"
|
||||
local file
|
||||
file="$(find "${HARAKA_DEPLOY_DIR}" -maxdepth 3 \( -name '.env' -o -name '*.env' -o -name 'compose*.yml' \) 2>/dev/null | head -20)"
|
||||
local found=""
|
||||
local f
|
||||
for f in ${file}; do
|
||||
if grep -E "(^|[[:space:]])${key}=${expected}" "$f" >/dev/null 2>&1 ||
|
||||
grep -E "\"${key}\"[[:space:]]*:[[:space:]]*\"?${expected}\"?" "$f" >/dev/null 2>&1; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "${found}" ]]; then
|
||||
# Also accept absence of true for false defaults
|
||||
if [[ "${expected}" == "false" ]]; then
|
||||
if ! grep -R --include='*.env' --include='*.yml' -E "${key}=true" "${HARAKA_DEPLOY_DIR}" >/dev/null 2>&1; then
|
||||
echo "OK (default): ${key} is not true"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "FAIL: expected ${key}=${expected} under ${HARAKA_DEPLOY_DIR}" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "OK: ${key}=${expected}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_env "HARAKA_QUEUE_STORE_REMOTE" "false"
|
||||
check_env "HARAKA_DLQ_STORE_PAYLOAD" "false"
|
||||
|
||||
# Log level warn preferred
|
||||
if grep -R --include='*.ini' --include='*.yml' --include='*.env' -E 'loglevel[[:space:]]*=[[:space:]]*warn|LOG_LEVEL=warn|log_level:[[:space:]]*warn' \
|
||||
"${HARAKA_DEPLOY_DIR}" >/dev/null 2>&1; then
|
||||
echo "OK: log level warn configured"
|
||||
else
|
||||
echo "WARN: could not confirm Haraka log level=warn (check config by hand)" >&2
|
||||
fi
|
||||
|
||||
if [[ "${fail}" -ne 0 ]]; then
|
||||
echo "Haraka privacy checks failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Haraka privacy checks passed."
|
||||
85
scripts/ops/pg_backup.sh
Executable file
85
scripts/ops/pg_backup.sh
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env bash
|
||||
# Encrypted Postgres dump with short retention. Secrets never go in the dump.
|
||||
#
|
||||
# Usage:
|
||||
# DATABASE_URL=postgres://... BACKUP_DIR=/var/backups/elektrine \
|
||||
# RETENTION_DAYS=14 ENCRYPTION_KEY_FILE=/root/elektrine-backup.key \
|
||||
# scripts/ops/pg_backup.sh
|
||||
#
|
||||
# Env:
|
||||
# DATABASE_URL or PG* vars — required for pg_dump
|
||||
# BACKUP_DIR — dump destination (default: ./_backups)
|
||||
# RETENTION_DAYS — delete dumps older than N days (default: 14)
|
||||
# ENCRYPTION_KEY_FILE — age recipient key or gpg --recipient optional
|
||||
# AGE_RECIPIENT — if set, encrypt with age -r
|
||||
# GPG_RECIPIENT — if set, encrypt with gpg -r
|
||||
#
|
||||
# Refuses to write next to .env.production or into a path containing secrets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-./_backups}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
BASENAME="elektrine-pg-${STAMP}.sql"
|
||||
PLAIN="${BACKUP_DIR}/${BASENAME}"
|
||||
|
||||
if [[ -f "${BACKUP_DIR}/.env.production" ]] || [[ -f "${BACKUP_DIR}/.env" ]]; then
|
||||
echo "Refusing to write backups next to env files in ${BACKUP_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${BACKUP_DIR}" in
|
||||
*/.env*|*/secrets*|*/.ssh*)
|
||||
echo "Refusing unsafe BACKUP_DIR: ${BACKUP_DIR}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
chmod 700 "${BACKUP_DIR}"
|
||||
|
||||
echo "Dumping database to ${PLAIN}"
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
pg_dump --no-owner --no-acl --format=plain --file="${PLAIN}" "${DATABASE_URL}"
|
||||
else
|
||||
pg_dump --no-owner --no-acl --format=plain --file="${PLAIN}"
|
||||
fi
|
||||
chmod 600 "${PLAIN}"
|
||||
|
||||
FINAL="${PLAIN}"
|
||||
if [[ -n "${AGE_RECIPIENT:-}" ]] && command -v age >/dev/null 2>&1; then
|
||||
FINAL="${PLAIN}.age"
|
||||
age -r "${AGE_RECIPIENT}" -o "${FINAL}" "${PLAIN}"
|
||||
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
|
||||
chmod 600 "${FINAL}"
|
||||
echo "Encrypted with age -> ${FINAL}"
|
||||
elif [[ -n "${GPG_RECIPIENT:-}" ]] && command -v gpg >/dev/null 2>&1; then
|
||||
FINAL="${PLAIN}.gpg"
|
||||
gpg --batch --yes -o "${FINAL}" -r "${GPG_RECIPIENT}" --encrypt "${PLAIN}"
|
||||
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
|
||||
chmod 600 "${FINAL}"
|
||||
echo "Encrypted with gpg -> ${FINAL}"
|
||||
elif [[ -n "${ENCRYPTION_KEY_FILE:-}" ]] && command -v age >/dev/null 2>&1; then
|
||||
FINAL="${PLAIN}.age"
|
||||
age -R "${ENCRYPTION_KEY_FILE}" -o "${FINAL}" "${PLAIN}"
|
||||
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
|
||||
chmod 600 "${FINAL}"
|
||||
echo "Encrypted with age key file -> ${FINAL}"
|
||||
else
|
||||
echo "WARNING: dump is plaintext. Set AGE_RECIPIENT or GPG_RECIPIENT." >&2
|
||||
fi
|
||||
|
||||
# Never pack app secrets into the backup tree
|
||||
for f in .env .env.production ELEKTRINE_MASTER_SECRET; do
|
||||
if [[ -e "${BACKUP_DIR}/${f}" ]]; then
|
||||
echo "Removing accidental secret file from backup dir: ${f}" >&2
|
||||
rm -f "${BACKUP_DIR}/${f}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Retention: match or beat IP retention so backups do not outlive privacy policy
|
||||
find "${BACKUP_DIR}" -type f \( -name 'elektrine-pg-*.sql' -o -name 'elektrine-pg-*.sql.age' -o -name 'elektrine-pg-*.sql.gpg' \) \
|
||||
-mtime "+${RETENTION_DAYS}" -print -delete 2>/dev/null || true
|
||||
|
||||
echo "Backup complete: ${FINAL} (retention ${RETENTION_DAYS}d)"
|
||||
Loading…
Add table
Add a link
Reference in a new issue