Initial commit
Fresh repository on git.elektrine.com/elektrine/elektrine.
This commit is contained in:
commit
454e80a8f8
4228 changed files with 650609 additions and 0 deletions
170
.credo.exs
Normal file
170
.credo.exs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# This file contains the configuration for Credo and you are probably reading
|
||||
# this after creating it with `mix credo.gen.config`.
|
||||
#
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
configs: [
|
||||
%{
|
||||
#
|
||||
# Run any config using `mix credo -C <name>`. If no config name is given
|
||||
# "default" is used.
|
||||
#
|
||||
name: "default",
|
||||
#
|
||||
# These are the files included in the analysis:
|
||||
files: %{
|
||||
#
|
||||
# You can give explicit globs or simply directories.
|
||||
# In the latter case `**/*.{ex,exs}` will be used.
|
||||
#
|
||||
included: [
|
||||
"lib/",
|
||||
"src/",
|
||||
"test/",
|
||||
"web/",
|
||||
"apps/*/lib/",
|
||||
"apps/*/src/",
|
||||
"apps/*/test/",
|
||||
"apps/*/web/"
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
|
||||
},
|
||||
#
|
||||
# Load and configure plugins here:
|
||||
#
|
||||
plugins: [],
|
||||
#
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
requires: [],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
# experience, you can change `strict` to `true` below:
|
||||
#
|
||||
strict: true,
|
||||
#
|
||||
# To modify the timeout for parsing files, change this value:
|
||||
#
|
||||
parse_timeout: 5000,
|
||||
#
|
||||
# If you want to use uncolored output by default, you can change `color`
|
||||
# to `false` below:
|
||||
#
|
||||
color: true,
|
||||
#
|
||||
# You can customize the parameters of any check by adding a second element
|
||||
# to the tuple.
|
||||
#
|
||||
# To disable a check put `false` as second element:
|
||||
#
|
||||
# {Credo.Check.Design.DuplicatedCode, false}
|
||||
#
|
||||
checks: %{
|
||||
enabled: [
|
||||
#
|
||||
## Consistency Checks
|
||||
#
|
||||
{Credo.Check.Consistency.ExceptionNames, []},
|
||||
{Credo.Check.Consistency.LineEndings, []},
|
||||
{Credo.Check.Consistency.ParameterPatternMatching, []},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators, []},
|
||||
{Credo.Check.Consistency.SpaceInParentheses, []},
|
||||
{Credo.Check.Consistency.TabsOrSpaces, []},
|
||||
|
||||
#
|
||||
## Design Checks
|
||||
#
|
||||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
{Credo.Check.Design.AliasUsage,
|
||||
[if_nested_deeper_than: 99, if_called_more_often_than: 999]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
#
|
||||
{Credo.Check.Readability.AliasOrder, []},
|
||||
{Credo.Check.Readability.FunctionNames, []},
|
||||
{Credo.Check.Readability.LargeNumbers, []},
|
||||
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
|
||||
{Credo.Check.Readability.ModuleAttributeNames, []},
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
{Credo.Check.Readability.ModuleNames, []},
|
||||
{Credo.Check.Readability.ParenthesesInCondition, []},
|
||||
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
|
||||
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
|
||||
{Credo.Check.Readability.PredicateFunctionNames, []},
|
||||
{Credo.Check.Readability.PreferImplicitTry, []},
|
||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
|
||||
{Credo.Check.Readability.VariableNames, []},
|
||||
{Credo.Check.Readability.WithSingleClause, []},
|
||||
|
||||
#
|
||||
## Refactoring Opportunities
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 120]},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, [max_arity: 20]},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 1_000]},
|
||||
{Credo.Check.Refactor.MapJoin, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, [max_nesting: 20]},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
{Credo.Check.Refactor.WithClauses, []},
|
||||
|
||||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
{Credo.Check.Warning.IExPry, []},
|
||||
{Credo.Check.Warning.IoInspect, []},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
|
||||
{Credo.Check.Warning.OperationOnSameValues, []},
|
||||
{Credo.Check.Warning.OperationWithConstantResult, []},
|
||||
{Credo.Check.Warning.RaiseInsideRescue, []},
|
||||
{Credo.Check.Warning.SpecWithStruct, []},
|
||||
{Credo.Check.Warning.StructFieldAmount, []},
|
||||
{Credo.Check.Warning.UnsafeExec, []},
|
||||
{Credo.Check.Warning.UnusedEnumOperation, []},
|
||||
{Credo.Check.Warning.UnusedFileOperation, []},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation, []},
|
||||
{Credo.Check.Warning.UnusedListOperation, []},
|
||||
{Credo.Check.Warning.UnusedMapOperation, []},
|
||||
{Credo.Check.Warning.UnusedPathOperation, []},
|
||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
],
|
||||
disabled: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
71
.dockerignore
Normal file
71
.dockerignore
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# This file excludes paths from the Docker build context.
|
||||
#
|
||||
# By default, Docker's build context includes all files (and folders) in the
|
||||
# current directory. Even if a file isn't copied into the container it is still sent to
|
||||
# the Docker daemon.
|
||||
#
|
||||
# There are multiple reasons to exclude files from the build context:
|
||||
#
|
||||
# 1. Prevent nested folders from being copied into the container (ex: exclude
|
||||
# /assets/node_modules when copying /assets)
|
||||
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
|
||||
# 3. Avoid sending files containing sensitive information
|
||||
#
|
||||
# More information on using .dockerignore is available here:
|
||||
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
|
||||
#
|
||||
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
|
||||
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
|
||||
.git
|
||||
!.git/HEAD
|
||||
!.git/refs
|
||||
|
||||
# Common development/test artifacts
|
||||
/cover/
|
||||
/doc/
|
||||
/test/
|
||||
/tmp/
|
||||
.elixir_ls
|
||||
|
||||
# Environment files and local secrets must never be sent to the Docker daemon.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
env/*.env
|
||||
env/*.env.*
|
||||
|
||||
# Private key and certificate material
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
*.csr
|
||||
*.asc
|
||||
/certs/
|
||||
/secrets/
|
||||
|
||||
# Mix artifacts
|
||||
/_build/
|
||||
/deps/
|
||||
apps/**/_build/
|
||||
apps/**/deps/
|
||||
*.ez
|
||||
|
||||
# Generated on crash by the VM
|
||||
erl_crash.dump
|
||||
|
||||
# Static artifacts - These should be fetched and built inside the Docker image
|
||||
/assets/node_modules/
|
||||
/priv/static/assets/
|
||||
/priv/static/cache_manifest.json
|
||||
apps/**/assets/node_modules/
|
||||
apps/**/priv/static/assets/
|
||||
apps/**/priv/static/cache_manifest.json
|
||||
|
||||
# Local release artifacts
|
||||
/_deploy_release/
|
||||
292
.env.example
Normal file
292
.env.example
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Advanced example env file.
|
||||
# For the easiest first setup, run:
|
||||
# scripts/deploy/self_host.sh init --domain example.com --email admin@example.com
|
||||
# Copy this file to `.env.production` only if you want the larger kitchen-sink template.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core domains / hostnames
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
PRIMARY_DOMAIN=example.com
|
||||
|
||||
# Optional overrides for split-domain setups. If unset, deploy tooling derives
|
||||
# them from PRIMARY_DOMAIN automatically.
|
||||
# PHX_HOST=example.com
|
||||
# INSTANCE_DOMAIN=example.com
|
||||
# EMAIL_DOMAIN=example.com
|
||||
# EMAIL_RAW_SOURCE_MAX_BYTES=10485760
|
||||
# SUPPORTED_DOMAINS=example.com
|
||||
# PROFILE_BASE_DOMAINS=example.com
|
||||
|
||||
# Optional DNS branding overrides. If unset, the DNS service derives these from
|
||||
# PRIMARY_DOMAIN automatically.
|
||||
# DNS_NAMESERVERS=ns1.example.com,ns2.example.com
|
||||
# DNS_SOA_RNAME=admin.example.com
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Phoenix runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
PHX_SERVER=true
|
||||
PORT=8080
|
||||
|
||||
# Optional runtime overrides.
|
||||
# FORCE_SSL=false
|
||||
# TRUSTED_PROXY_CIDRS=172.30.0.0/24
|
||||
# PROXY_PROTOCOL_TRUSTED_CIDRS=203.0.113.10/32
|
||||
# CADDY_ASK_TRUSTED_CIDRS=172.30.0.0/24
|
||||
# ELEKTRINE_AUTO_CONFIGURE_DOCKER_SOURCE_IPS=true
|
||||
# CADDY_TRUSTED_PROXY_CIDRS=203.0.113.10/32
|
||||
# CADDY_PROXY_PROTOCOL_ALLOWED_CIDRS=203.0.113.10/32
|
||||
# EXTRA_CHECK_ORIGINS=http://203.0.113.10
|
||||
# ATOMINE_DM_CREDIT_GATE_ENABLED=false
|
||||
# ATOMINE_EMAIL_CREDIT_GATE_ENABLED=false
|
||||
|
||||
# Require NetBird for login/admin surfaces and restrict admin.<domain> at Caddy.
|
||||
# NETBIRD_ENABLED=true
|
||||
# Set this to your exact NetBird peer IP(s) or private network CIDR(s), not all CGNAT.
|
||||
# NETBIRD_ALLOWED_CIDRS=100.64.12.34/32
|
||||
# CADDY_ADMIN_HOST=admin.example.com
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database / secrets
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# DB_PASSWORD=<generate-a-long-random-secret>
|
||||
|
||||
# For the default Docker stack, `DB_PASSWORD` is enough. Set `DATABASE_URL`
|
||||
# only when you use an external Postgres instance or non-Docker deploy.
|
||||
# DATABASE_URL=ecto://elektrine:<generate-a-long-random-secret>@postgres/elektrine_prod
|
||||
# TLS is enabled by default for external DATABASE_URL connections. Only set this
|
||||
# false for an internal Docker/private-network database that does not support TLS.
|
||||
# DATABASE_SSL_ENABLED=false
|
||||
|
||||
# Minimal root secret for self-hosting. Elektrine derives internal API keys,
|
||||
# salts, and other internal-only secrets from this automatically.
|
||||
# ELEKTRINE_MASTER_SECRET=<generate-a-long-random-secret>
|
||||
|
||||
# Optional explicit override. If omitted, Elektrine derives it from
|
||||
# ELEKTRINE_MASTER_SECRET.
|
||||
# SECRET_KEY_BASE=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Modules / deploy profiles
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
ELEKTRINE_ENABLED_MODULES=all
|
||||
# Advanced build override only. Usually leave this unset.
|
||||
# ELEKTRINE_RELEASE_MODULES=chat,social,nerve,atomine
|
||||
DOCKER_PROFILES=caddy dns email tor turn bluesky vpn
|
||||
|
||||
# After each successful docker_deploy up, prune old app image tags on the host
|
||||
# so deploys do not fill the disk. Keep the N newest tags of the app image.
|
||||
# ELEKTRINE_IMAGE_KEEP_COUNT=3
|
||||
# ELEKTRINE_IMAGE_REPO=ghcr.io/atomine-elektrine/elektrine
|
||||
# ELEKTRINE_PRUNE_DANGLING_IMAGES=true
|
||||
# ELEKTRINE_PRUNE_BUILD_CACHE=true
|
||||
# ELEKTRINE_SKIP_IMAGE_PRUNE=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database pool tuning
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Advanced DB tuning only.
|
||||
# POOL_SIZE=10
|
||||
# APP_POOL_SIZE=10
|
||||
# WORKER_POOL_SIZE=5
|
||||
# MAIL_POOL_SIZE=5
|
||||
# DNS_POOL_SIZE=3
|
||||
# VPN_POOL_SIZE=2
|
||||
# MIGRATION_POOL_SIZE=2
|
||||
# DB_QUEUE_TARGET_MS=2000
|
||||
# DB_QUEUE_INTERVAL_MS=5000
|
||||
# DB_TIMEOUT_MS=30000
|
||||
# DB_POOL_TIMEOUT_MS=15000
|
||||
# DB_CONNECT_TIMEOUT_MS=15000
|
||||
# POSTGRES_EXTENSIONS=vector
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Federation / remote enrichment
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Advanced federation/remoting only.
|
||||
# MESSAGING_FEDERATION_ENABLED=true
|
||||
# MESSAGING_FEDERATION_IDENTITY_KEY_ID=default
|
||||
# MESSAGING_FEDERATION_OFFICIAL_RELAY_OPERATOR=Community-operated
|
||||
# MESSAGING_FEDERATION_OFFICIAL_RELAYS_JSON='[]'
|
||||
# MESSAGING_FEDERATION_PEERS_JSON='[]'
|
||||
|
||||
# Optional Paige search providers.
|
||||
# PAIGE_BRAVE_API_KEY=<brave-search-api-key>
|
||||
# PAIGE_GITHUB_TOKEN=<github-token>
|
||||
# Comma-separated first-party HTML scrapers: wiby,duckduckgo (default: wiby).
|
||||
# PAIGE_SCRAPERS=wiby,duckduckgo
|
||||
|
||||
# Paige's independent PostgreSQL-backed index. Seeds are comma-separated URLs;
|
||||
# same-host links are discovered up to PAIGE_INDEX_MAX_DEPTH.
|
||||
# PAIGE_INDEX_ENABLED=true
|
||||
# PAIGE_INDEX_SEEDS=https://example.com/,https://hexdocs.pm/elixir/
|
||||
# PAIGE_INDEX_MAX_DEPTH=2
|
||||
# PAIGE_INDEX_RECRAWL_SECONDS=604800
|
||||
# PAIGE_INDEX_BATCH_SIZE=100
|
||||
# OBAN_QUEUE_CRAWLER=2
|
||||
|
||||
# Optional IFTAS CARIAD denylist sync. Set either a full URL or an API key.
|
||||
# IFTAS_BLOCKLIST_ENABLED=true
|
||||
# IFTAS_BLOCKLIST_THRESHOLD=66
|
||||
# IFTAS_BLOCKLIST_API_KEY=<iftas-api-key>
|
||||
# IFTAS_BLOCKLIST_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional but recommended crypto secrets
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optional explicit overrides. If omitted, Elektrine derives them from
|
||||
# ELEKTRINE_MASTER_SECRET.
|
||||
# ENCRYPTION_MASTER_SECRET=
|
||||
# ENCRYPTION_KEY_SALT=
|
||||
# ENCRYPTION_SEARCH_SALT=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email / Haraka integration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Enable only if you actually use the `email` module/profile.
|
||||
# USE_LOCAL_EMAIL=false
|
||||
# EMAIL_AUTO_SUPPRESSION=true
|
||||
# HARAKA_ASYNC_INGEST=true
|
||||
# MAIL_SERVICE_HOST=mail.example.com
|
||||
# IMAP_HOST=mail.example.com
|
||||
# SMTP_HOST=mail.example.com
|
||||
|
||||
# Optional overrides. If omitted, Elektrine derives these from your domain and
|
||||
# ELEKTRINE_MASTER_SECRET automatically.
|
||||
# HARAKA_BASE_URL=https://mail.example.com
|
||||
# CUSTOM_DOMAIN_MX_HOST=mail.example.com
|
||||
# INTERNAL_API_KEY=
|
||||
# HARAKA_HTTP_API_KEY=
|
||||
# PHOENIX_API_KEY=
|
||||
# HARAKA_INTERNAL_SIGNING_SECRET=
|
||||
# EMAIL_RECEIVER_WEBHOOK_SECRET=
|
||||
|
||||
# CUSTOM_DOMAIN_MX_PRIORITY=10
|
||||
# CUSTOM_DOMAIN_SPF_INCLUDE=_spf.example.com
|
||||
# CUSTOM_DOMAIN_DKIM_SELECTOR=default
|
||||
# CUSTOM_DOMAIN_DKIM_SYNC_ENABLED=true
|
||||
# CUSTOM_DOMAIN_HARAKA_BASE_URL=https://mail.example.com
|
||||
# CUSTOM_DOMAIN_HARAKA_API_KEY=
|
||||
# CUSTOM_DOMAIN_HARAKA_TIMEOUT_MS=10000
|
||||
# CUSTOM_DOMAIN_HARAKA_DKIM_PATH=/api/v1/dkim/domains
|
||||
# CUSTOM_DOMAIN_DMARC_POLICY=quarantine
|
||||
# CUSTOM_DOMAIN_DMARC_RUA=mailto:dmarc@example.com
|
||||
# CUSTOM_DOMAIN_DMARC_ADKIM=s
|
||||
# CUSTOM_DOMAIN_DMARC_ASPF=s
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Edge / certificates / anti-abuse
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
ACME_EMAIL=admin@example.com
|
||||
# Minimal wildcard setup for large/dynamic username subdomains:
|
||||
# CADDY_MANAGED_SITE_1="example.com *.example.com"
|
||||
# Leave CADDY_MANAGED_SITE_2 unset unless you have a second disjoint site list.
|
||||
# CADDY_MANAGED_SITE_2="alt.example.net *.alt.example.net"
|
||||
# Deploy tooling auto-selects the wildcard-external Caddyfile when wildcard
|
||||
# sites and mounted cert paths are present. For raw `docker compose` usage,
|
||||
# set `CADDY_CONFIG_PATH` explicitly.
|
||||
# With the stock Caddyfile, keep these lists to explicit hosts only.
|
||||
# CADDY_MANAGED_SITE_1="example.com mail.example.com imap.example.com pop.example.com smtp.example.com"
|
||||
# CADDY_MANAGED_SITE_2="alt.example.net mail.alt.example.net imap.alt.example.net pop.alt.example.net smtp.alt.example.net"
|
||||
# CADDY_TLS_MOUNT_DIR=/opt/elektrine/certs
|
||||
# CADDY_MANAGED_SITE_1_CERT_PATH=/opt/elektrine/certs/example.com.fullchain.pem
|
||||
# CADDY_MANAGED_SITE_1_KEY_PATH=/opt/elektrine/certs/example.com.key.pem
|
||||
# CADDY_MANAGED_SITE_2_CERT_PATH=/opt/elektrine/certs/alt.example.net.fullchain.pem
|
||||
# CADDY_MANAGED_SITE_2_KEY_PATH=/opt/elektrine/certs/alt.example.net.key.pem
|
||||
# CADDY_ORIGIN_UPSTREAM=app_proxy:8080
|
||||
# MAIL_TLS_CERT_PATH=/opt/elektrine/certs/mail.crt
|
||||
# MAIL_TLS_KEY_PATH=/opt/elektrine/certs/mail.key
|
||||
# MAIL_TLS_MOUNT_DIR=/opt/elektrine/certs
|
||||
# IMAP_TLS_CERT_PATH=/opt/elektrine/certs/imap.crt
|
||||
# IMAP_TLS_KEY_PATH=/opt/elektrine/certs/imap.key
|
||||
# POP3_TLS_CERT_PATH=/opt/elektrine/certs/pop.crt
|
||||
# POP3_TLS_KEY_PATH=/opt/elektrine/certs/pop.key
|
||||
# TURN_HOST defaults to PRIMARY_DOMAIN when the `turn` profile is enabled.
|
||||
# TURN_PORT=3478
|
||||
# TURN_MIN_PORT=49160
|
||||
# TURN_MAX_PORT=49200
|
||||
# TURN_EXTERNAL_IP=
|
||||
# TURN_RELAY_IP=
|
||||
# TURN_SHARED_SECRET=
|
||||
# ATOMINE_POW_DIFFICULTY=20
|
||||
# ATOMINE_POW_SKIP_VERIFICATION=false
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SECRET=
|
||||
# DNS_RECURSIVE_ENABLED=true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# VPN
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Single-server self-hosted WireGuard (recommended for most installs):
|
||||
# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com
|
||||
# VPN_SELFHOST_PUBLIC_IP=203.0.113.10
|
||||
# VPN_SELFHOST_PRIVATE_KEY=optional; auto-generated and persisted by the vpn container when omitted
|
||||
# VPN_SELFHOST_ADDRESS=10.8.0.1/24
|
||||
# VPN_SELFHOST_LISTEN_PORT=51820
|
||||
# VPN_SELFHOST_ENDPOINT_PORT=51820
|
||||
# VPN_SELFHOST_PUBLIC_KEY=optional override; normally derived automatically from the private key
|
||||
# VPN_SELFHOST_INTERNAL_IP_RANGE=10.8.0.0/24
|
||||
# VPN_SELFHOST_DNS_SERVERS=1.1.1.1, 1.0.0.1
|
||||
# VPN_SELFHOST_WG_INTERFACE=wg0
|
||||
|
||||
# Multi-server fleet bootstrap:
|
||||
# VPN_FLEET_REGISTRATION_KEY=replace-with-base64-registration-key
|
||||
# Private supernet each node's /24 is carved from. The default holds 256 nodes;
|
||||
# widen it BEFORE nodes claim ranges, since changing it later strands existing
|
||||
# allocations.
|
||||
# VPN_WG_SUPERNET=10.8.0.0/16
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Onion / Tor
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ELEKTRINE_ENABLE_TOR=true
|
||||
# ONION_TLS_ENABLED=true
|
||||
# ONION_TLS_PORT=8443
|
||||
# ONION_TLS_CERTFILE=/data/certs/live/onion-cert.pem
|
||||
# ONION_TLS_KEYFILE=/data/certs/live/onion-key.pem
|
||||
# ONION_HOST=
|
||||
# ONION_HS_SECRET_KEY_B64=
|
||||
# ONION_HS_PUBLIC_KEY_B64=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bluesky
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# BLUESKY_ENABLED=false
|
||||
# BLUESKY_INBOUND_ENABLED=false
|
||||
# BLUESKY_SERVICE_URL=https://bsky.social
|
||||
# BLUESKY_TIMEOUT_MS=12000
|
||||
# BLUESKY_MAX_CHARS=300
|
||||
# BLUESKY_INBOUND_LIMIT=50
|
||||
# BLUESKY_MANAGED_ENABLED=false
|
||||
# BLUESKY_MANAGED_SERVICE_URL=http://bluesky_pds:3000
|
||||
# BLUESKY_MANAGED_DOMAIN=bsky.example.com
|
||||
# BLUESKY_MANAGED_ADMIN_PASSWORD=<generate-a-long-random-secret>
|
||||
# BLUESKY_PDS_PORT=3000
|
||||
# BLUESKY_PDS_JWT_SECRET=<generate-a-long-random-secret>
|
||||
# BLUESKY_PDS_ROTATION_KEY_HEX=<generate-a-32-byte-hex-secret>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional object storage (Magpie / S3-compatible)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Leave these unset to keep uploads on local disk.
|
||||
# S3_ACCESS_KEY_ID=<provider-access-key-id>
|
||||
# S3_SECRET_ACCESS_KEY=<provider-secret-access-key>
|
||||
# Local Magpie examples use HTTP loopback. Use HTTPS for any remote object store.
|
||||
# S3_ENDPOINT=127.0.0.1:8090
|
||||
# S3_BUCKET_NAME=elektrine-uploads
|
||||
# S3_PUBLIC_URL=http://127.0.0.1:8090/elektrine-uploads
|
||||
# S3_SCHEME=http://
|
||||
# S3_PORT=8090
|
||||
41
.forgejo/README.md
Normal file
41
.forgejo/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Forgejo Actions
|
||||
|
||||
Workflows in this directory run on the self-hosted Forgejo instance
|
||||
(`https://git.elektrine.com`) via `forgejo-runner`.
|
||||
|
||||
| Workflow | Purpose |
|
||||
|----------|---------|
|
||||
| `smoke.yml` | Minimal runner health check |
|
||||
| `ci.yml` | Quality gate (format, compile, credo, tests) |
|
||||
| `docker-deploy.yml` | Build image + SSH deploy (port of `.github/workflows/docker-deploy.yml`) |
|
||||
|
||||
## Runner
|
||||
|
||||
- Host: Forgejo droplet (`git.elektrine.com`)
|
||||
- Path: `/opt/forgejo-runner`
|
||||
- Labels: `ubuntu-latest`, `ubuntu-22.04`, `docker`
|
||||
|
||||
## Repo secrets / variables
|
||||
|
||||
Set under **Settings → Actions** on `elektrine/elektrine`:
|
||||
|
||||
**Secrets**
|
||||
|
||||
- `DEPLOY_SSH_KEY` — private key for production deploy (required)
|
||||
- `REGISTRY_USERNAME` / `REGISTRY_TOKEN` — optional Forgejo package credentials
|
||||
for optional registry push and Haraka image pulls. Legacy aliases
|
||||
`GHCR_USERNAME` / `GHCR_TOKEN` still work.
|
||||
|
||||
The main Elektrine app image is **built on the runner and transferred over
|
||||
SSH** (`docker save` → `rsync` → `docker load`). Production deploy no longer
|
||||
requires a registry pull of the app image, which avoids
|
||||
`unauthorized: authRequired.Verify` on `git.elektrine.com`.
|
||||
|
||||
**Variables**
|
||||
|
||||
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_HOST_KEY`
|
||||
- Optional: `DEPLOY_PORT`, `DEPLOY_PATH`, `DEPLOY_BRANCH`
|
||||
- Optional: `REGISTRY` (default `git.elektrine.com`), `IMAGE_NAME`, `DOCKER_BUILD_*_DOMAIN`
|
||||
|
||||
GitHub Actions under `.github/workflows/` remain for the GitHub remote until
|
||||
you cut over production deploys fully.
|
||||
129
.forgejo/workflows/ci.yml
Normal file
129
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Port of .github/workflows/ci.yml for Forgejo Actions.
|
||||
# Prefer data.forgejo.org action mirrors where available.
|
||||
name: Quality Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
allow_format_failure:
|
||||
description: Allow formatting check to fail without failing the workflow
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
allow_test_failure:
|
||||
description: Allow the test suite to fail without blocking deployment
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
allow_format_failure:
|
||||
description: Allow formatting check to fail without failing the workflow
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
allow_test_failure:
|
||||
description: Allow the test suite to fail without blocking deployment
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
MIX_ENV: test
|
||||
ELIXIR_VERSION: '1.19.5'
|
||||
OTP_VERSION: '28.1.1'
|
||||
DB_HOST: localhost
|
||||
DB_NAME: elektrine_test
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
|
||||
jobs:
|
||||
quality_checks:
|
||||
name: Code Quality (Format + Compile)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: elektrine_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d elektrine_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up Elixir
|
||||
uses: https://github.com/erlef/setup-beam@v1
|
||||
with:
|
||||
elixir-version: ${{ env.ELIXIR_VERSION }}
|
||||
otp-version: ${{ env.OTP_VERSION }}
|
||||
|
||||
- name: Restore deps cache
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
_build
|
||||
key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: mix deps.get
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm --prefix apps/elektrine/assets ci
|
||||
|
||||
- name: Build assets
|
||||
run: cd apps/elektrine && mix assets.setup && mix assets.build
|
||||
|
||||
- name: Verify formatting
|
||||
id: format_check
|
||||
continue-on-error: ${{ inputs.allow_format_failure }}
|
||||
run: mix format --check-formatted
|
||||
|
||||
- name: Check generated artifacts are not tracked
|
||||
run: scripts/check_tracked_generated_artifacts.sh
|
||||
|
||||
- name: Check JavaScript assets
|
||||
run: npm --prefix apps/elektrine/assets run check
|
||||
|
||||
- name: Run JavaScript dependency audit
|
||||
run: npm --prefix apps/elektrine/assets audit --omit=dev
|
||||
|
||||
- name: Emit warning when formatting is allowed to fail
|
||||
if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }}
|
||||
run: echo "::warning::Formatting check failed, but deploy can continue."
|
||||
|
||||
- name: Compile with warnings treated as errors
|
||||
run: mix compile --warnings-as-errors
|
||||
|
||||
- name: Run Credo
|
||||
run: mix credo --strict
|
||||
|
||||
- name: Run tests
|
||||
id: test_check
|
||||
continue-on-error: ${{ inputs.allow_test_failure != false }}
|
||||
run: mix test
|
||||
|
||||
- name: Emit warning when tests are allowed to fail
|
||||
if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }}
|
||||
run: echo "::warning::Test suite failed, but deploy can continue."
|
||||
|
||||
- name: Audit Nerve extension dependencies
|
||||
run: npm --prefix clients/nerve-extension audit --omit=dev
|
||||
|
||||
- name: Run dependency audit
|
||||
run: mix deps.audit
|
||||
|
||||
- name: Run Hex package retirement audit
|
||||
run: mix hex.audit
|
||||
373
.forgejo/workflows/docker-deploy.yml
Normal file
373
.forgejo/workflows/docker-deploy.yml
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
# Production deploy for self-hosted Forgejo Actions (no setup-beam / node24 actions).
|
||||
#
|
||||
# The app image is built on the runner and transferred over SSH (docker save/load),
|
||||
# so production does not need to pull from the Forgejo package registry.
|
||||
#
|
||||
# Secrets: DEPLOY_SSH_KEY
|
||||
# Optional secrets (registry push + Haraka pulls only):
|
||||
# REGISTRY_USERNAME + REGISTRY_TOKEN (or legacy GHCR_USERNAME / GHCR_TOKEN)
|
||||
# Variables: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_HOST_KEY, REGISTRY, IMAGE_NAME, DOCKER_BUILD_*
|
||||
name: Deploy Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repair_indexes:
|
||||
description: Rebuild all PostgreSQL indexes during deploy
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.REGISTRY || 'git.elektrine.com' }}
|
||||
IMAGE_NAME: ${{ vars.IMAGE_NAME || 'elektrine/elektrine' }}
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build, push, and deploy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || 'elektrine.com' }}
|
||||
BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || 'elektrine.com' }}
|
||||
BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || 'elektrine.com' }}
|
||||
BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'elektrine.com' }}
|
||||
ELEKTRINE_ENABLED_MODULES: all
|
||||
ELEKTRINE_RELEASE_MODULES: all
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
|
||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }}
|
||||
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }}
|
||||
DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DOCKER_PROFILES: caddy dns email tor turn bluesky vpn
|
||||
DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }}
|
||||
REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }}
|
||||
# Optional registry credentials (push/Haraka only).
|
||||
REG_USER: ${{ secrets.REGISTRY_USERNAME || secrets.GHCR_USERNAME }}
|
||||
REG_PASS: ${{ secrets.REGISTRY_TOKEN || secrets.GHCR_TOKEN }}
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Build image
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tag_sha="${image}:${GITHUB_SHA}"
|
||||
tag_latest="${image}:latest"
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile \
|
||||
--build-arg RELEASE_NAME=elektrine \
|
||||
--build-arg ELEKTRINE_RELEASE_MODULES="${ELEKTRINE_RELEASE_MODULES}" \
|
||||
--build-arg PRIMARY_DOMAIN="${BUILD_PRIMARY_DOMAIN}" \
|
||||
--build-arg EMAIL_DOMAIN="${BUILD_EMAIL_DOMAIN}" \
|
||||
--build-arg SUPPORTED_DOMAINS="${BUILD_SUPPORTED_DOMAINS}" \
|
||||
--build-arg PROFILE_BASE_DOMAINS="${BUILD_PROFILE_BASE_DOMAINS}" \
|
||||
-t "$tag_sha" \
|
||||
-t "$tag_latest" \
|
||||
.
|
||||
echo "target_image=${tag_sha}" >> "$GITHUB_OUTPUT"
|
||||
echo "Built ${tag_sha}"
|
||||
|
||||
- name: Optional registry push
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${REG_USER:-}" ] || [ -z "${REG_PASS:-}" ]; then
|
||||
echo "No registry credentials; skipping push (image will ship over SSH)."
|
||||
exit 0
|
||||
fi
|
||||
REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
if ! printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin; then
|
||||
echo "::warning::Registry login failed; continuing with SSH image transfer only."
|
||||
exit 0
|
||||
fi
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
if ! docker push "${image}:${GITHUB_SHA}"; then
|
||||
echo "::warning::docker push ${image}:${GITHUB_SHA} failed; continuing with SSH transfer."
|
||||
exit 0
|
||||
fi
|
||||
docker push "${image}:latest" || echo "::warning::docker push latest failed"
|
||||
|
||||
- name: Package image for SSH transfer
|
||||
env:
|
||||
TARGET_IMAGE: ${{ steps.build.outputs.target_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp
|
||||
docker save "$TARGET_IMAGE" | gzip -1 > /tmp/elektrine-image.tar.gz
|
||||
ls -lh /tmp/elektrine-image.tar.gz
|
||||
|
||||
- name: Validate deploy settings
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
[ -n "${DEPLOY_HOST:-}" ] || missing+=(DEPLOY_HOST)
|
||||
[ -n "${DEPLOY_USER:-}" ] || missing+=(DEPLOY_USER)
|
||||
[ -n "${DEPLOY_SSH_HOST_KEY:-}" ] || missing+=(DEPLOY_SSH_HOST_KEY)
|
||||
[ -n "${DEPLOY_SSH_KEY:-}" ] || missing+=(DEPLOY_SSH_KEY)
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::Missing deploy settings: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -m 700 -d ~/.ssh
|
||||
# Secret may arrive with literal \n — normalize to real newlines
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
: > ~/.ssh/known_hosts
|
||||
if [ -n "${DEPLOY_SSH_HOST_KEY:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_SSH_HOST_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' >> ~/.ssh/known_hosts
|
||||
fi
|
||||
# Always pin live host keys so BatchMode never prompts
|
||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa \
|
||||
"${DEPLOY_HOST}" 2>/dev/null >> ~/.ssh/known_hosts || true
|
||||
if [ -n "${DEPLOY_HOST:-}" ]; then
|
||||
ip=$(getent ahostsv4 "$DEPLOY_HOST" 2>/dev/null | awk '{print $1; exit}' || true)
|
||||
if [ -n "$ip" ]; then
|
||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa "$ip" 2>/dev/null >> ~/.ssh/known_hosts || true
|
||||
fi
|
||||
fi
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
echo "known_hosts:"; cat ~/.ssh/known_hosts
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
||||
|
||||
- name: Upload image archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RSYNC_RSH="ssh -p ${DEPLOY_PORT} -i ${HOME}/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${HOME}/.ssh/known_hosts"
|
||||
ssh -p "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '${DEPLOY_PATH}/.deploy' && chmod 700 '${DEPLOY_PATH}/.deploy'"
|
||||
rsync -az -e "$RSYNC_RSH" \
|
||||
/tmp/elektrine-image.tar.gz \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz"
|
||||
|
||||
- name: Deploy over SSH
|
||||
env:
|
||||
TARGET_IMAGE: ${{ steps.build.outputs.target_image }}
|
||||
DEPLOY_REF: ${{ github.sha }}
|
||||
# Haraka image rolled by Elektrine deploy (sibling stack on same host).
|
||||
HARAKA_IMAGE: ${{ vars.HARAKA_IMAGE || 'git.elektrine.com/elektrine/elektrine-haraka' }}
|
||||
HARAKA_IMAGE_TAG: ${{ vars.HARAKA_IMAGE_TAG || 'latest' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Optional registry creds for Haraka pulls only (main app image is loaded from archive).
|
||||
if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then
|
||||
REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
else
|
||||
REG_USER=""
|
||||
REG_PASS=""
|
||||
fi
|
||||
|
||||
remote_env="$(mktemp)"
|
||||
chmod 600 "$remote_env"
|
||||
{
|
||||
printf 'export DEPLOY_PATH=%q\n' "$DEPLOY_PATH"
|
||||
printf 'export DEPLOY_REF=%q\n' "$DEPLOY_REF"
|
||||
printf 'export DEPLOY_BRANCH=%q\n' "$DEPLOY_BRANCH"
|
||||
printf 'export TARGET_IMAGE=%q\n' "$TARGET_IMAGE"
|
||||
printf 'export ELEKTRINE_ENABLED_MODULES=%q\n' "$ELEKTRINE_ENABLED_MODULES"
|
||||
printf 'export ELEKTRINE_RELEASE_MODULES=%q\n' "$ELEKTRINE_RELEASE_MODULES"
|
||||
printf 'export DOCKER_PROFILES=%q\n' "$DOCKER_PROFILES"
|
||||
printf 'export DEPLOY_COMPOSE_OVERRIDES=%q\n' "$DEPLOY_COMPOSE_OVERRIDES"
|
||||
printf 'export REPAIR_INDEXES_ON_DEPLOY=%q\n' "$REPAIR_INDEXES_ON_DEPLOY"
|
||||
printf 'export REG_USER=%q\n' "$REG_USER"
|
||||
printf 'export REG_PASS=%q\n' "$REG_PASS"
|
||||
printf 'export HARAKA_IMAGE=%q\n' "$HARAKA_IMAGE"
|
||||
printf 'export HARAKA_IMAGE_TAG=%q\n' "$HARAKA_IMAGE_TAG"
|
||||
printf 'export REGISTRY=%q\n' "${REGISTRY:-git.elektrine.com}"
|
||||
printf 'export IMAGE_ARCHIVE=%q\n' "${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz"
|
||||
} >"$remote_env"
|
||||
|
||||
remote_env_path="/tmp/elektrine-deploy-env.$$"
|
||||
scp -P "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$remote_env" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$remote_env_path"
|
||||
rm -f "$remote_env"
|
||||
|
||||
ssh -p "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"bash -s -- $remote_env_path" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="${1:?missing remote env file}"
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
rm -f "$ENV_FILE"
|
||||
|
||||
export REGISTRY_USER="${REG_USER:-}"
|
||||
export REGISTRY_TOKEN="${REG_PASS:-}"
|
||||
export REGISTRY="${REGISTRY:-git.elektrine.com}"
|
||||
export HARAKA_IMAGE="${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}"
|
||||
export HARAKA_IMAGE_TAG="${HARAKA_IMAGE_TAG:-latest}"
|
||||
# Prefer local image (loaded from archive); never force a registry pull of ELEKTRINE_IMAGE.
|
||||
export ELEKTRINE_PULL_POLICY=never
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
DOCKER=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
DOCKER=(sudo -n docker)
|
||||
else
|
||||
echo "No Docker access for deploy user" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_ARCHIVE="${IMAGE_ARCHIVE:?missing IMAGE_ARCHIVE}"
|
||||
test -s "$IMAGE_ARCHIVE"
|
||||
|
||||
echo "Loading $TARGET_IMAGE from $IMAGE_ARCHIVE"
|
||||
gzip -dc "$IMAGE_ARCHIVE" | "${DOCKER[@]}" load
|
||||
"${DOCKER[@]}" image inspect "$TARGET_IMAGE" >/dev/null
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
|
||||
# Optional login only helps Haraka (and other private pulls). Failures are non-fatal.
|
||||
if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then
|
||||
echo "Logging into $REGISTRY as $REG_USER (optional, for Haraka)"
|
||||
if [ "${#DOCKER[@]}" -gt 1 ]; then
|
||||
"${DOCKER[@]}" login "$REGISTRY" -u "$REG_USER" --password-stdin <<<"$REG_PASS" \
|
||||
|| echo "Warn: registry login failed; Haraka pull may be skipped" >&2
|
||||
else
|
||||
printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin \
|
||||
|| echo "Warn: registry login failed; Haraka pull may be skipped" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DEPLOY_PATH/.env.production" ]; then
|
||||
echo "Remote env file missing: $DEPLOY_PATH/.env.production" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
git fetch --all --tags || true
|
||||
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
current_branch="$(git branch --show-current || true)"
|
||||
if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
|
||||
git checkout "$DEPLOY_BRANCH" || true
|
||||
fi
|
||||
git merge --ff-only "origin/$DEPLOY_BRANCH" || true
|
||||
else
|
||||
echo "Skipping checkout sync for $DEPLOY_PATH: local changes present"
|
||||
fi
|
||||
|
||||
if git rev-parse --verify "${DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
|
||||
REF="$DEPLOY_REF"
|
||||
else
|
||||
REF="origin/${DEPLOY_BRANCH}"
|
||||
fi
|
||||
|
||||
WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$"
|
||||
OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
|
||||
CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile"
|
||||
trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT
|
||||
|
||||
git worktree add --force --detach "$WORKTREE_PATH" "$REF"
|
||||
WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production"
|
||||
if [ -r "$DEPLOY_PATH/.env.production" ]; then
|
||||
ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE"
|
||||
else
|
||||
umask 077
|
||||
sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE"
|
||||
fi
|
||||
cd "$WORKTREE_PATH"
|
||||
|
||||
EXTRA_OVERRIDE_ARGS=()
|
||||
for override_file in $DEPLOY_COMPOSE_OVERRIDES; do
|
||||
[ -z "$override_file" ] && continue
|
||||
case "$override_file" in
|
||||
/*) resolved_override="$override_file" ;;
|
||||
*) resolved_override="$WORKTREE_PATH/$override_file" ;;
|
||||
esac
|
||||
if [ -f "$resolved_override" ]; then
|
||||
EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override")
|
||||
fi
|
||||
done
|
||||
|
||||
PROFILE_ARGS=()
|
||||
for profile in $DOCKER_PROFILES; do
|
||||
PROFILE_ARGS+=(--profile "$profile")
|
||||
done
|
||||
|
||||
REPAIR_INDEX_ARGS=()
|
||||
case "$REPAIR_INDEXES_ON_DEPLOY" in
|
||||
1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;;
|
||||
esac
|
||||
|
||||
# Image already loaded locally — do not --pull (that re-hits the registry).
|
||||
ELEKTRINE_IMAGE="$TARGET_IMAGE" \
|
||||
COMPOSE_PROJECT_NAME="docker" \
|
||||
COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" \
|
||||
CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" \
|
||||
bash scripts/deploy/docker_deploy.sh \
|
||||
--modules "$ELEKTRINE_ENABLED_MODULES" \
|
||||
--env-file "$WORKTREE_ENV_FILE" \
|
||||
--output "$OUTPUT_FILE" \
|
||||
--skip-build \
|
||||
"${REPAIR_INDEX_ARGS[@]}" \
|
||||
"${EXTRA_OVERRIDE_ARGS[@]}" \
|
||||
"${PROFILE_ARGS[@]}"
|
||||
|
||||
expected_image="$TARGET_IMAGE"
|
||||
expected_containers=(elektrine_app elektrine_worker)
|
||||
case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac
|
||||
case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac
|
||||
case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac
|
||||
|
||||
for container in "${expected_containers[@]}"; do
|
||||
if ! "${DOCKER[@]}" inspect "$container" >/dev/null 2>&1; then
|
||||
echo "Expected container $container was not created" >&2
|
||||
exit 1
|
||||
fi
|
||||
actual_image="$("${DOCKER[@]}" inspect "$container" --format '{{.Config.Image}}')"
|
||||
if [ "$actual_image" != "$expected_image" ]; then
|
||||
echo "Container $container is running $actual_image, expected $expected_image" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
EOF
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -p "$DEPLOY_PORT" \
|
||||
-i "$HOME/.ssh/id_ed25519" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" "bash -s" <<EOF
|
||||
set -euo pipefail
|
||||
if docker info >/dev/null 2>&1; then DOCKER_CMD="docker"; else DOCKER_CMD="sudo -n docker"; fi
|
||||
\$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | head -40
|
||||
echo "--- haraka ---"
|
||||
\$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep -i haraka || true
|
||||
EOF
|
||||
21
.forgejo/workflows/smoke.yml
Normal file
21
.forgejo/workflows/smoke.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Minimal job to verify the Forgejo runner is online.
|
||||
name: Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.forgejo/workflows/smoke.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hello:
|
||||
name: Hello from Forgejo Actions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Runner info
|
||||
run: |
|
||||
echo "Hello from Forgejo Actions"
|
||||
uname -a
|
||||
id
|
||||
df -h .
|
||||
12
.formatter.exs
Normal file
12
.formatter.exs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
subdirectories: ["apps/*/priv/*/migrations"],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: [
|
||||
"*.{heex,ex,exs}",
|
||||
"config/**/*.{heex,ex,exs}",
|
||||
"apps/*/mix.exs",
|
||||
"apps/*/{lib,test}/**/*.{heex,ex,exs}",
|
||||
"apps/*/priv/*/seeds.exs"
|
||||
]
|
||||
]
|
||||
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: Bug report
|
||||
description: Report a reproducible defect in any umbrella app
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Please include enough detail to reproduce.
|
||||
|
||||
- type: dropdown
|
||||
id: app
|
||||
attributes:
|
||||
label: Affected app
|
||||
options:
|
||||
- apps/elektrine
|
||||
- apps/elektrine_web
|
||||
- apps/elektrine_email
|
||||
- apps/elektrine_social
|
||||
- apps/elektrine_vpn
|
||||
- apps/elektrine_nerve
|
||||
- multiple apps
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / stack traces
|
||||
render: shell
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Commit SHA or branch
|
||||
placeholder: e.g. main @ abc1234
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security report
|
||||
url: mailto:security@elektrine.com
|
||||
about: Please do not report vulnerabilities in public issues.
|
||||
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Feature request
|
||||
description: Propose an enhancement for one or more umbrella apps
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: app
|
||||
attributes:
|
||||
label: Target app
|
||||
options:
|
||||
- apps/elektrine
|
||||
- apps/elektrine_web
|
||||
- apps/elektrine_email
|
||||
- apps/elektrine_social
|
||||
- apps/elektrine_vpn
|
||||
- apps/elektrine_nerve
|
||||
- multiple apps
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem statement
|
||||
description: What pain point are you trying to solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Expected impact
|
||||
description: Performance, security, UX, maintenance, migration notes.
|
||||
36
.github/dependabot.yml
vendored
Normal file
36
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
version: 2
|
||||
updates:
|
||||
# Elixir dependencies
|
||||
- package-ecosystem: "mix"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
elixir-minor:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
|
||||
# npm (if you have assets)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/elektrine/assets"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-minor:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
35
.github/pull_request_template.md
vendored
Normal file
35
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
## Summary
|
||||
|
||||
Describe what changed and why.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] `apps/elektrine`
|
||||
- [ ] `apps/elektrine_web`
|
||||
- [ ] `apps/arblarg`
|
||||
- [ ] `apps/elektrine_email`
|
||||
- [ ] `apps/elektrine_social`
|
||||
- [ ] `apps/elektrine_vpn`
|
||||
- [ ] `apps/elektrine_nerve`
|
||||
- [ ] `apps/elektrine_dns`
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] `mix check`
|
||||
- [ ] Added or updated tests for behavior changes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- [ ] None
|
||||
- [ ] Yes (describe migration or rollout steps)
|
||||
|
||||
## Security / Privacy
|
||||
|
||||
- [ ] No sensitive data added to repo
|
||||
- [ ] Secrets are still environment-driven
|
||||
- [ ] New external dependencies reviewed
|
||||
|
||||
## Notes for Reviewers
|
||||
|
||||
Any context that helps review quickly.
|
||||
119
.github/workflows/ci.yml
vendored
Normal file
119
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
name: Quality Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
allow_format_failure:
|
||||
description: Allow formatting check to fail without failing the workflow
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
allow_test_failure:
|
||||
description: Allow the test suite to fail without blocking deployment
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
env:
|
||||
MIX_ENV: test
|
||||
ELIXIR_VERSION: '1.19.5'
|
||||
OTP_VERSION: '28.1.1'
|
||||
DB_HOST: localhost
|
||||
DB_NAME: elektrine_test
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
|
||||
jobs:
|
||||
quality_checks:
|
||||
name: Code Quality (Format + Compile)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: elektrine_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d elektrine_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Elixir
|
||||
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
|
||||
with:
|
||||
elixir-version: ${{ env.ELIXIR_VERSION }}
|
||||
otp-version: ${{ env.OTP_VERSION }}
|
||||
|
||||
- name: Restore deps cache
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
_build
|
||||
key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: mix deps.get
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm --prefix apps/elektrine/assets ci
|
||||
|
||||
- name: Build assets
|
||||
# Feature (Wallaby) tests exercise the app in a real browser and need
|
||||
# the compiled CSS/JS bundle in priv/static.
|
||||
run: cd apps/elektrine && mix assets.setup && mix assets.build
|
||||
|
||||
- name: Verify formatting
|
||||
id: format_check
|
||||
continue-on-error: ${{ inputs.allow_format_failure }}
|
||||
run: mix format --check-formatted
|
||||
|
||||
- name: Check generated artifacts are not tracked
|
||||
run: scripts/check_tracked_generated_artifacts.sh
|
||||
|
||||
- name: Check JavaScript assets
|
||||
run: npm --prefix apps/elektrine/assets run check
|
||||
|
||||
- name: Run JavaScript dependency audit
|
||||
run: npm --prefix apps/elektrine/assets audit --omit=dev
|
||||
|
||||
- name: Emit warning when formatting is allowed to fail
|
||||
if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }}
|
||||
run: echo "::warning::Formatting check failed, but deploy can continue."
|
||||
|
||||
- name: Compile with warnings treated as errors
|
||||
run: mix compile --warnings-as-errors
|
||||
|
||||
- name: Run Credo
|
||||
run: mix credo --strict
|
||||
|
||||
- name: Run tests
|
||||
id: test_check
|
||||
# Always continue: tests still run and report, but red suites do not
|
||||
# block Quality Gate / deploy. workflow_call may pass allow_test_failure=false
|
||||
# to re-enable fail-closed behavior.
|
||||
continue-on-error: ${{ inputs.allow_test_failure != false }}
|
||||
run: mix test
|
||||
|
||||
- name: Emit warning when tests are allowed to fail
|
||||
if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }}
|
||||
run: echo "::warning::Test suite failed, but deploy can continue."
|
||||
|
||||
- name: Audit Nerve extension dependencies
|
||||
run: npm --prefix clients/nerve-extension audit --omit=dev
|
||||
|
||||
- name: Run dependency audit
|
||||
run: mix deps.audit
|
||||
|
||||
- name: Run Hex package retirement audit
|
||||
run: mix hex.audit
|
||||
314
.github/workflows/docker-deploy.yml
vendored
Normal file
314
.github/workflows/docker-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
name: Deploy Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repair_indexes:
|
||||
description: Rebuild all PostgreSQL indexes during deploy
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
quality_gate:
|
||||
name: Quality Gate
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
allow_format_failure: false
|
||||
allow_test_failure: true
|
||||
|
||||
build_and_publish_image:
|
||||
name: Build and Publish Image
|
||||
needs: quality_gate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || secrets.DOCKER_BUILD_PRIMARY_DOMAIN || 'example.com' }}
|
||||
BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || secrets.DOCKER_BUILD_EMAIL_DOMAIN || 'example.com' }}
|
||||
BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || secrets.DOCKER_BUILD_SUPPORTED_DOMAINS || 'example.com' }}
|
||||
BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || secrets.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'example.com' }}
|
||||
ELEKTRINE_ENABLED_MODULES: all
|
||||
ELEKTRINE_RELEASE_MODULES: all
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=,format=long
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
RELEASE_NAME=elektrine
|
||||
ELEKTRINE_RELEASE_MODULES=${{ env.ELEKTRINE_RELEASE_MODULES || env.ELEKTRINE_ENABLED_MODULES }}
|
||||
PRIMARY_DOMAIN=${{ env.BUILD_PRIMARY_DOMAIN }}
|
||||
EMAIL_DOMAIN=${{ env.BUILD_EMAIL_DOMAIN }}
|
||||
SUPPORTED_DOMAINS=${{ env.BUILD_SUPPORTED_DOMAINS }}
|
||||
PROFILE_BASE_DOMAINS=${{ env.BUILD_PROFILE_BASE_DOMAINS }}
|
||||
|
||||
deploy_to_docker:
|
||||
name: Deploy Application
|
||||
needs: build_and_publish_image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
env:
|
||||
# Non-secret deploy settings live in repository Variables.
|
||||
# Only the private key is a Secret.
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
|
||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }}
|
||||
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }}
|
||||
DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
ELEKTRINE_ENABLED_MODULES: all
|
||||
ELEKTRINE_RELEASE_MODULES: all
|
||||
DOCKER_PROFILES: caddy dns email tor turn bluesky vpn
|
||||
DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }}
|
||||
REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }}
|
||||
TARGET_IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
DEPLOY_REF: ${{ github.sha }}
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Validate deploy settings
|
||||
run: |
|
||||
missing=()
|
||||
[ -n "${DEPLOY_HOST}" ] || missing+=(DEPLOY_HOST)
|
||||
[ -n "${DEPLOY_USER}" ] || missing+=(DEPLOY_USER)
|
||||
[ -n "${DEPLOY_SSH_HOST_KEY}" ] || missing+=(DEPLOY_SSH_HOST_KEY)
|
||||
[ -n "${DEPLOY_SSH_KEY}" ] || missing+=(DEPLOY_SSH_KEY)
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
printf '::error::Missing deploy settings: %s. Use repository Variables for host/user/path/host-key; DEPLOY_SSH_KEY must remain a Secret.\n' "${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_HOST" == *@* ]]; then
|
||||
echo "::warning::DEPLOY_HOST should be a hostname only. Using the user/host split from DEPLOY_HOST."
|
||||
echo "DEPLOY_USER=${DEPLOY_HOST%@*}" >> "$GITHUB_ENV"
|
||||
echo "DEPLOY_HOST=${DEPLOY_HOST##*@}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
{
|
||||
printf '%s\n' "$DEPLOY_SSH_HOST_KEY"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
|
||||
host_field="${line%% *}"
|
||||
rest="${line#* }"
|
||||
if [[ "$host_field" == *@* ]]; then
|
||||
printf '%s %s\n' "${host_field##*@}" "$rest"
|
||||
fi
|
||||
done <<< "$DEPLOY_SSH_HOST_KEY"
|
||||
} > ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy over SSH
|
||||
run: |
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
bash -s -- "$DEPLOY_PATH" "$DEPLOY_REF" "$DEPLOY_BRANCH" "$TARGET_IMAGE" "$ELEKTRINE_ENABLED_MODULES" "$ELEKTRINE_RELEASE_MODULES" "$DOCKER_PROFILES" "$DEPLOY_COMPOSE_OVERRIDES" "$REPAIR_INDEXES_ON_DEPLOY" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_PATH="$1"
|
||||
DEPLOY_REF="$2"
|
||||
DEPLOY_BRANCH="$3"
|
||||
TARGET_IMAGE="$4"
|
||||
ELEKTRINE_ENABLED_MODULES="$5"
|
||||
ELEKTRINE_RELEASE_MODULES="$6"
|
||||
DOCKER_PROFILES="$7"
|
||||
DEPLOY_COMPOSE_OVERRIDES="$8"
|
||||
REPAIR_INDEXES_ON_DEPLOY="$9"
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
DEPLOY_PREFIX=""
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
DEPLOY_PREFIX="sudo -n"
|
||||
else
|
||||
echo "No Docker access for deploy user"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
env_grep() {
|
||||
local pattern="$1"
|
||||
local env_file="$2"
|
||||
|
||||
if [ -r "$env_file" ]; then
|
||||
grep -q "$pattern" "$env_file"
|
||||
else
|
||||
sudo -n grep -q "$pattern" "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -f "$DEPLOY_PATH/.env.production" ]; then
|
||||
echo "Remote env file is missing: $DEPLOY_PATH/.env.production" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -r "$DEPLOY_PATH/.env.production" ] && ! sudo -n test -r "$DEPLOY_PATH/.env.production"; then
|
||||
deploy_user_name="$(id -un)"
|
||||
echo "Remote env file is not readable by $deploy_user_name and sudo cannot read it: $DEPLOY_PATH/.env.production" >&2
|
||||
echo "Fix ownership/permissions, for example: sudo chown $deploy_user_name:$deploy_user_name $DEPLOY_PATH/.env.production && sudo chmod 600 $DEPLOY_PATH/.env.production" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ " $DOCKER_PROFILES " == *" turn "* ]]; then
|
||||
if ! env_grep '^TURN_SHARED_SECRET=.' "$DEPLOY_PATH/.env.production"; then
|
||||
echo "TURN profile is enabled, but TURN_SHARED_SECRET is missing from $DEPLOY_PATH/.env.production" >&2
|
||||
echo "Add TURN_SHARED_SECRET to the remote .env.production file." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
git fetch --all --tags
|
||||
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
current_branch="$(git branch --show-current || true)"
|
||||
if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
|
||||
git checkout "$DEPLOY_BRANCH"
|
||||
fi
|
||||
git merge --ff-only "origin/$DEPLOY_BRANCH"
|
||||
else
|
||||
echo "Skipping checkout sync for $DEPLOY_PATH: local changes present"
|
||||
fi
|
||||
|
||||
WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$"
|
||||
|
||||
OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
|
||||
CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile"
|
||||
trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT
|
||||
|
||||
git worktree add --force --detach "$WORKTREE_PATH" "$DEPLOY_REF"
|
||||
WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production"
|
||||
if [ -r "$DEPLOY_PATH/.env.production" ]; then
|
||||
ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE"
|
||||
else
|
||||
umask 077
|
||||
sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE"
|
||||
fi
|
||||
cd "$WORKTREE_PATH"
|
||||
|
||||
EXTRA_OVERRIDE_ARGS=()
|
||||
for override_file in $DEPLOY_COMPOSE_OVERRIDES; do
|
||||
case "$override_file" in
|
||||
/*) resolved_override="$override_file" ;;
|
||||
*) resolved_override="$WORKTREE_PATH/$override_file" ;;
|
||||
esac
|
||||
if [ ! -f "$resolved_override" ]; then
|
||||
case " $DOCKER_PROFILES " in
|
||||
*" $override_file "*)
|
||||
echo "Ignoring DEPLOY_COMPOSE_OVERRIDES entry '$override_file' because it is already enabled in DOCKER_PROFILES."
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
case "$override_file" in
|
||||
bluesky|caddy|dns|email|tor|turn|vpn)
|
||||
echo "Treating DEPLOY_COMPOSE_OVERRIDES entry '$override_file' as a Docker profile. Prefer setting DOCKER_PROFILES instead."
|
||||
DOCKER_PROFILES="$DOCKER_PROFILES $override_file"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Compose override not found: $resolved_override"
|
||||
exit 1
|
||||
fi
|
||||
EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override")
|
||||
done
|
||||
|
||||
PROFILE_ARGS=()
|
||||
for profile in $DOCKER_PROFILES; do
|
||||
PROFILE_ARGS+=(--profile "$profile")
|
||||
done
|
||||
|
||||
REPAIR_INDEX_ARGS=()
|
||||
case "$REPAIR_INDEXES_ON_DEPLOY" in
|
||||
1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;;
|
||||
esac
|
||||
|
||||
ELEKTRINE_IMAGE="$TARGET_IMAGE" COMPOSE_PROJECT_NAME="docker" COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" bash scripts/deploy/docker_deploy.sh \
|
||||
--modules "$ELEKTRINE_ENABLED_MODULES" \
|
||||
--env-file "$WORKTREE_ENV_FILE" \
|
||||
--output "$OUTPUT_FILE" \
|
||||
--pull \
|
||||
--skip-build \
|
||||
"${REPAIR_INDEX_ARGS[@]}" \
|
||||
"${EXTRA_OVERRIDE_ARGS[@]}" \
|
||||
"${PROFILE_ARGS[@]}"
|
||||
|
||||
expected_image="$TARGET_IMAGE"
|
||||
expected_containers=(elektrine_app elektrine_worker)
|
||||
case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac
|
||||
case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac
|
||||
case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac
|
||||
|
||||
for container in "${expected_containers[@]}"; do
|
||||
if ! ${DEPLOY_PREFIX} docker inspect "$container" >/dev/null 2>&1; then
|
||||
echo "Expected container $container was not created" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_image="$(${DEPLOY_PREFIX} docker inspect "$container" --format '{{.Config.Image}}')"
|
||||
|
||||
if [ "$actual_image" != "$expected_image" ]; then
|
||||
echo "Container $container is running $actual_image, expected $expected_image" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
EOF
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" <<EOF
|
||||
set -euo pipefail
|
||||
if docker info >/dev/null 2>&1; then
|
||||
DOCKER_CMD="docker"
|
||||
else
|
||||
DOCKER_CMD="sudo -n docker"
|
||||
fi
|
||||
cd "$DEPLOY_PATH"
|
||||
COMPOSE_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
|
||||
\$DOCKER_CMD compose --project-directory "$DEPLOY_PATH/deploy/docker" --env-file "$DEPLOY_PATH/.env.production" -f "\$COMPOSE_FILE" --profile caddy --profile dns --profile email --profile tor --profile turn --profile bluesky --profile vpn ps || \$DOCKER_CMD ps
|
||||
EOF
|
||||
85
.gitignore
vendored
Normal file
85
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# =============================================================================
|
||||
# Elixir / Phoenix
|
||||
|
||||
# Build artifacts
|
||||
/_build/
|
||||
*.ez
|
||||
erl_crash.dump
|
||||
# Dependencies
|
||||
/deps/
|
||||
/.fetch
|
||||
# Documentation
|
||||
/doc/
|
||||
# Test and coverage
|
||||
/cover/
|
||||
/tmp/
|
||||
/apps/*/tmp/
|
||||
# Package tarball
|
||||
elektrine-*.tar
|
||||
# Assets
|
||||
# Built assets
|
||||
/apps/*/priv/static/assets/
|
||||
/apps/*/priv/static/cache_manifest.json
|
||||
/apps/*/priv/static/uploads/
|
||||
/apps/*/priv/static/**/*.gz
|
||||
/apps/*/priv/static/**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].*
|
||||
# Node.js
|
||||
/apps/*/assets/node_modules/
|
||||
/apps/*/assets/.sass-cache/
|
||||
/apps/*/assets/npm-debug.log*
|
||||
/apps/*/assets/yarn-debug.log*
|
||||
/apps/*/assets/yarn-error.log*
|
||||
npm-debug.log*
|
||||
# Environment / Secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
# Generated / Runtime Files
|
||||
# Rate limiting ETS tables
|
||||
/apps/*/priv/imap_rate_limit_*.ets
|
||||
# ACME certificates
|
||||
/apps/*/priv/acme/
|
||||
# Export files
|
||||
/apps/*/priv/exports/
|
||||
# Wallaby screenshots
|
||||
/screenshots/
|
||||
# Deploy-time generated configs
|
||||
/deploy/docker/generated*.yml
|
||||
/deploy/docker/generated.Caddyfile
|
||||
/deploy/docker/generated.mongooseim.toml
|
||||
/deploy/generated/*
|
||||
!/deploy/generated/.gitkeep
|
||||
# IDE / Editor
|
||||
**/.claude/settings.local.json
|
||||
# macOS Finder metadata
|
||||
.DS_Store
|
||||
# Editor / temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# External Projects (separate repos)
|
||||
# ARBP conformance badge artifacts (generated by mix arbp.conformance)
|
||||
/external/arblarg/conformance/badges/
|
||||
# Tooling caches / misc
|
||||
/.elixir_ls/
|
||||
.mix
|
||||
.hex
|
||||
_deploy_release
|
||||
/certs/
|
||||
/generated.mongooseim.toml
|
||||
_build_opencode/
|
||||
apps/elektrine_web/screenshots/
|
||||
# local / agent worktrees and caches (do not ship)
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
_build/
|
||||
deps/
|
||||
**/node_modules/
|
||||
.elixir_ls/
|
||||
!.env*.example
|
||||
.claude/**/worktrees/
|
||||
127
AGENTS.md
Normal file
127
AGENTS.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Working in this repo
|
||||
|
||||
Elektrine is an Elixir umbrella app. See `README.md` for the app layout and
|
||||
what each umbrella app under `apps/` owns.
|
||||
|
||||
## Git commits
|
||||
|
||||
- Do **not** add AI attribution of any kind: no `Co-Authored-By: Claude ...`,
|
||||
no `Claude-Session:` trailers, no "🤖 Generated with ..." lines, in commit
|
||||
messages or PR bodies.
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org):
|
||||
`type(scope): subject`. Types in use: `feat`, `fix`, `refactor`, `docs`,
|
||||
`test`, `chore`. Scope is the app or feature area — `mail`, `email`, `vpn`,
|
||||
`calendar`, `booking`, `link-archive`, `portal`, `web`, `ui`.
|
||||
- Subject in the imperative, no trailing period. The body explains **why** the
|
||||
change exists, not a restatement of the diff.
|
||||
- Each commit should compile on its own. Watch for files that tie features
|
||||
together — `router.ex` and `config/config.exs` are routinely touched by
|
||||
several features at once, so a commit that splits them can leave an
|
||||
intermediate revision that does not build.
|
||||
- Do not commit or push unless asked.
|
||||
|
||||
## Before you push
|
||||
|
||||
Pushing `main` to the `github` remote **deploys to production**
|
||||
(`.github/workflows/docker-deploy.yml` runs on push to `main`).
|
||||
|
||||
- `allow_format_failure: false` — unformatted code blocks the deploy.
|
||||
- `allow_test_failure: true` — failing tests do **not** block it. Run
|
||||
`mix test` yourself; CI will not stop a broken suite from shipping.
|
||||
- `main` tracks `origin/main`, a local forge at `ssh://localhost:2222`. The
|
||||
`github` remote is the one that deploys. A bare `git push` is not a deploy.
|
||||
|
||||
## Verifying changes
|
||||
|
||||
Always run the checks that CI runs before considering a change done — do not
|
||||
report work as finished until format, compile, and credo pass.
|
||||
|
||||
After editing a file, run the fast per-file checks first:
|
||||
|
||||
- `mix format <file>` — format the file.
|
||||
- `mix credo --strict <file>` — lint just that file (fast).
|
||||
|
||||
Then, before finishing, run the broader gate:
|
||||
|
||||
- `mix format --check-formatted` — CI fails if anything is unformatted.
|
||||
- `mix compile --warnings-as-errors` — warnings fail the build; fix them.
|
||||
- `mix credo --strict` — style/consistency must pass (whole project).
|
||||
- `mix check` — the full CI gate (all of the above plus generated-artifact +
|
||||
legacy-marker checks, asset check, dep/hex audits, and `test`). Run this for
|
||||
anything non-trivial.
|
||||
|
||||
Three budget guards fail in ways that are easy to misread:
|
||||
|
||||
- `scripts/check_maintainability_budgets.sh` errors when a budgeted path no
|
||||
longer exists, so moving or deleting a budgeted file must update the budget
|
||||
in the *same* commit.
|
||||
- `scripts/check_design_consistency.sh` caps raw `btn`/`card` markup, arbitrary
|
||||
type sizes, off-convention radii, raw hex, and inline styles — per app, for
|
||||
every `*_web` tree. Lower the budget when you remove instances, so the
|
||||
surface cannot grow back. A new `*_web` app fails the check until it gets a
|
||||
`check_app` line.
|
||||
- `scripts/check_legacy_marker_budget.sh` caps `legacy|backward|compat|
|
||||
deprecated` markers across `apps`, `config`, `scripts`, and `deploy`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Run the whole suite from the umbrella root: `mix test`.
|
||||
- Run one app's tests from its dir: `cd apps/elektrine_web && mix test`.
|
||||
- Run a single file/line: `cd apps/<app> && mix test test/path/to_test.exs:42`.
|
||||
- Prefer adding a regression test with any bug fix.
|
||||
- Running one app's tests can poison `_build/test` for the umbrella; if the
|
||||
root suite then behaves oddly, `rm -rf _build/test/lib/elektrine`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Match the style and idioms of the surrounding code in each app.
|
||||
- Keep domain logic in `apps/elektrine`; keep web/LiveView code in
|
||||
`apps/elektrine_web`. Don't reach across app boundaries casually.
|
||||
- Two exceptions, both because `apps/elektrine` is the only app every other
|
||||
app depends on: UI components shared by more than one product app live in
|
||||
`apps/elektrine/lib/elektrine/components` (avatar, skeleton, badges), and the
|
||||
frontend assets live in `apps/elektrine/assets`. Import those modules
|
||||
directly — do not add a re-export shim in `apps/elektrine_web`.
|
||||
- The `ElektrineWeb.Components.UI` kit is the design system and stays in
|
||||
`apps/elektrine_web`. Buttons and cards come from it, not hand-written
|
||||
markup; use the `text-3xs`/`text-2xs` and radius tokens rather than
|
||||
arbitrary values.
|
||||
- Shared text formatting lives in `Elektrine.TextHelpers` — `truncate/2`,
|
||||
`truncate_to/2`, `abbreviate_count/1`, `time_ago_compact/1`. Call those
|
||||
rather than re-deriving them privately, so the same value renders the same
|
||||
way in every app.
|
||||
|
||||
## Federation security
|
||||
|
||||
- `Elektrine.ActivityPub.SignatureVerification` is the **only** inbound HTTP
|
||||
signature verifier. Every entrance — the inbox plug and the signature retry
|
||||
queue — goes through `verify/2`; callers differ only in the options they
|
||||
pass. Never add a second verifier or a path that checks the signature
|
||||
without also checking required signed headers, timestamp freshness, digest
|
||||
against the raw body, and replay. A partial verifier reachable from anywhere
|
||||
becomes the way in.
|
||||
- `Elektrine.ActivityPub.HTTPSignature` signs outbound requests only.
|
||||
- Only key-resolution failures are retryable
|
||||
(`SignatureVerification.retryable_error?/1`). A signature that verified but
|
||||
broke a constraint has been rejected on the merits — do not queue it.
|
||||
- Retried requests store the raw body and the arrival time. The activity is
|
||||
re-parsed from those bytes *after* the digest check, so what gets processed
|
||||
is what the signature covered, and freshness is judged against arrival
|
||||
rather than when the job happens to run.
|
||||
- `Elektrine.ActivityPub.InboundDeliveryPolicy.permit/4` is the one audience
|
||||
gate. Any new inbox entrance applies it — it is what stops a peer pushing
|
||||
content at an inbox it has no relationship with.
|
||||
|
||||
## Mail and Haraka
|
||||
|
||||
Haraka is a separate deployment (`elektrine-haraka`) that owns inbound SMTP on
|
||||
port 25 and outbound delivery; Elektrine owns mailboxes, JMAP, submission, and
|
||||
the webhooks Haraka calls. Two layouts are supported and each has its own TLS
|
||||
entry point, which write the same `compose.override.yml` — use exactly one per
|
||||
Haraka deployment:
|
||||
|
||||
- same host: `scripts/deploy/configure_haraka_wildcard_tls.sh` (the default;
|
||||
`docker_deploy.sh` keeps it configured)
|
||||
- separate host: `scripts/acme/setup_haraka_mail_tls.sh`
|
||||
|
||||
See `docs/self-hosting/mail.md` before changing either.
|
||||
56
CODE_OF_CONDUCT.md
Normal file
56
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in
|
||||
our community a harassment-free experience for everyone, regardless of age,
|
||||
body size, visible or invisible disability, ethnicity, sex characteristics,
|
||||
gender identity and expression, level of experience, education, socioeconomic
|
||||
status, nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness.
|
||||
- Being respectful of differing opinions and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility, apologizing, and learning from mistakes.
|
||||
- Focusing on what is best for the overall community.
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery.
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks.
|
||||
- Public or private harassment.
|
||||
- Publishing others' private information without explicit permission.
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior they deem inappropriate, threatening, offensive, or
|
||||
harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces and also applies when an
|
||||
individual is officially representing the project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Report unacceptable behavior to the maintainers via the security/reporting
|
||||
process in `SECURITY.md`.
|
||||
|
||||
All reports will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
66
CONTRIBUTING.md
Normal file
66
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for contributing to Elektrine.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository is an umbrella project. All apps in `apps/` are open source
|
||||
under the same root `LICENSE`:
|
||||
|
||||
- `apps/elektrine`
|
||||
- `apps/elektrine_web`
|
||||
- `apps/arblarg`
|
||||
- `apps/elektrine_email`
|
||||
- `apps/elektrine_social`
|
||||
- `apps/elektrine_vpn`
|
||||
- `apps/elektrine_nerve`
|
||||
- `apps/elektrine_dns`
|
||||
|
||||
## License
|
||||
|
||||
This repository is licensed under `AGPL-3.0-only` (see `LICENSE`).
|
||||
By contributing, you agree that your contributions are provided under that same license.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Install Elixir/Erlang and PostgreSQL.
|
||||
2. Clone the repo.
|
||||
3. Copy `env/dev.env.example` to `.env` and set local values.
|
||||
4. Run setup:
|
||||
|
||||
```bash
|
||||
mix setup
|
||||
```
|
||||
|
||||
5. Start the app:
|
||||
|
||||
```bash
|
||||
cd apps/elektrine
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
## Running Checks
|
||||
|
||||
Run these before opening a PR:
|
||||
|
||||
```bash
|
||||
mix check
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Keep PRs focused and small.
|
||||
2. Include tests for behavior changes.
|
||||
3. Update docs when behavior/config changes.
|
||||
4. Fill out the PR template.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Use clear, imperative commit messages, for example:
|
||||
|
||||
- `Add federation outbox retry worker`
|
||||
- `Fix sequence gap recovery for messaging events`
|
||||
|
||||
## Security
|
||||
|
||||
Do not open public issues for vulnerabilities. Use the process in `SECURITY.md`.
|
||||
663
LICENSE
Normal file
663
LICENSE
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by this
|
||||
License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of this
|
||||
License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in
|
||||
detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero
|
||||
General Public License "or any later version" applies to it, you have
|
||||
the option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that
|
||||
proxy's public statement of acceptance of a version permanently
|
||||
authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE
|
||||
COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES
|
||||
SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE
|
||||
WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public
|
||||
License along with this program. If not, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
206
README.md
Normal file
206
README.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Elektrine
|
||||
|
||||
Elektrine is an Elixir umbrella app. Shared runtime and persistence live in
|
||||
`apps/elektrine`, the Phoenix host app lives in `apps/elektrine_web`, and the
|
||||
product areas are split into their own umbrella apps.
|
||||
|
||||
## Layout
|
||||
|
||||
- `apps/`: umbrella apps
|
||||
- `clients/`: client-side artifacts that are not shipped with the server release
|
||||
- `config/`: shared compile-time and runtime config
|
||||
- `deploy/`: Docker, Caddy, onion, and VPN deploy assets
|
||||
- `docs/`: architecture, protocol, API, and self-hosting docs
|
||||
- `env/`: example environment files
|
||||
- `release_builder/`: module-aware release build project
|
||||
- `scripts/`: release, deploy, and ops helpers
|
||||
|
||||
## Main apps
|
||||
|
||||
- `apps/elektrine`: shared domain logic, `Repo`, supervisors, accounts, uploads, notifications, calendar, module selection, frontend assets, and the cross-app UI components in `lib/elektrine/components`
|
||||
- `apps/elektrine_web`: endpoint, router, plugs, layouts, the `ElektrineWeb.Components.UI` design-system kit, and the account/admin shell
|
||||
- `apps/arblarg`: chat facade plus LiveViews, JSON APIs, and PAT APIs
|
||||
- `apps/elektrine_social`: timeline, communities, federation, social controllers, and LiveViews
|
||||
- `apps/elektrine_email`: mailbox, contacts, mail protocols, JMAP, WKD, and mail web routes
|
||||
- `apps/elektrine_vpn`: WireGuard management and VPN UI/API
|
||||
- `apps/elektrine_nerve`: Nerve domain and extracted routes
|
||||
- `apps/elektrine_dns`: managed DNS runtime, DNS API routes, and DNS LiveViews
|
||||
- `apps/elektrine_uptime`: uptime monitors, check history, incidents, and dashboard routes
|
||||
- `apps/atomine`: proofs, personhood, account trust, and credit policy domain logic
|
||||
- `apps/kairo`: capture, indexing, and graph-oriented knowledge features
|
||||
- `apps/paige`: search provider integration and ranking support
|
||||
|
||||
Requests enter through `ElektrineWeb.Router`, pass through shared plugs and
|
||||
module guards, and then land in the controller or LiveView mounted for the
|
||||
selected feature set. Persistence stays centralized in `Elektrine.Repo`.
|
||||
|
||||
## Identity Provider
|
||||
|
||||
Elektrine exposes an OpenID Connect-compatible identity provider on top of the
|
||||
existing OAuth tables:
|
||||
|
||||
- discovery: `/.well-known/openid-configuration`
|
||||
- browser consent: `/oauth/authorize`
|
||||
- public keys: `/oauth/jwks`
|
||||
- token exchange: `/oauth/token`
|
||||
- user info: `/oauth/userinfo`
|
||||
- app management UI: `/account/developer/oidc/clients`
|
||||
- grant review UI: `/account/developer/oidc/grants`
|
||||
- dynamic registration: `POST /oauth/register` while signed in
|
||||
|
||||
Register OAuth apps with `openid`, `profile`, and `email` scopes to use this
|
||||
flow. The current implementation supports the authorization code flow and
|
||||
issues `RS256` `id_token`s for confidential clients.
|
||||
|
||||
## Module Selection
|
||||
|
||||
Normal deployments use one public module switch:
|
||||
|
||||
- `ELEKTRINE_ENABLED_MODULES` controls which product modules are turned on
|
||||
- release builds default to that same module list
|
||||
- `ELEKTRINE_RELEASE_MODULES` still exists as an advanced override when you need
|
||||
to compile more modules than you expose at runtime
|
||||
|
||||
Supported module ids are `chat`, `social`, `email`, `nerve`, `vpn`, `dns`,
|
||||
`uptime`, `atomine`, and `kairo`. `proofs` and `personhood` are accepted aliases
|
||||
for `atomine` in the deploy scripts.
|
||||
|
||||
In normal use, set `ELEKTRINE_ENABLED_MODULES` and leave
|
||||
`ELEKTRINE_RELEASE_MODULES` unset.
|
||||
|
||||
## Local Development
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
mix setup
|
||||
```
|
||||
|
||||
Start the web app:
|
||||
|
||||
```bash
|
||||
cd apps/elektrine
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
mix compile
|
||||
mix check
|
||||
mix test
|
||||
mix test apps/elektrine/test
|
||||
mix test apps/elektrine_web/test
|
||||
mix test apps/elektrine_email/test
|
||||
mix test apps/elektrine_social/test
|
||||
mix test apps/elektrine_vpn/test
|
||||
mix test apps/elektrine_nerve/test
|
||||
mix test apps/elektrine_dns/test
|
||||
```
|
||||
|
||||
Frontend assets live under `apps/elektrine/assets`.
|
||||
|
||||
## Build And Deploy
|
||||
|
||||
The supported path for hosted or subset installs is the release builder:
|
||||
|
||||
```bash
|
||||
scripts/release/deploy_release.sh --modules email,vpn
|
||||
```
|
||||
|
||||
This builds assets, selects the requested feature apps, and writes the release
|
||||
to `_deploy_release/`. `deploy/docker/Dockerfile` uses the same path.
|
||||
|
||||
For Docker deployments, use the wrapper instead of invoking `docker compose`
|
||||
against `deploy/docker/compose.full.yml` directly:
|
||||
|
||||
```bash
|
||||
scripts/deploy/docker_deploy.sh
|
||||
```
|
||||
|
||||
The wrapper renders a module-aware Compose file first, sets
|
||||
`ELEKTRINE_ENABLED_MODULES`, derives release modules from it by default, sets
|
||||
mail runtime flags, and removes POP3, IMAP, and SMTP port bindings when
|
||||
`email` is not selected. With no module or profile arguments, it deploys all
|
||||
standard modules and Docker profiles; pass `--modules` or `--profile` only for a
|
||||
smaller stack.
|
||||
|
||||
When `vpn` is selected, the Docker deploy also enables the bundled `vpn`
|
||||
service automatically. That service runs the Docker-managed WireGuard node,
|
||||
creates or restores its private key, derives the public key, and reconciles
|
||||
peer state in-app.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
The self-hosting docs are split by profile:
|
||||
|
||||
- `core`: Phoenix app and Postgres only
|
||||
- `mail`: Elektrine mail protocols; production SMTP edge/delivery still needs a separate Haraka deployment
|
||||
- `dns`: optional authoritative DNS service enabled through the Docker `dns` profile
|
||||
- `vpn`: optional Docker-managed WireGuard on the same stack, with optional fleet mode
|
||||
- `addons`: Caddy edge, TURN, Bluesky PDS, onion hosting, and client artifacts
|
||||
|
||||
Start with:
|
||||
|
||||
- `docs/api/external-api.md`
|
||||
- `docs/self-hosting/README.md`
|
||||
- `docs/self-hosting/docker.md`
|
||||
- `docs/self-hosting/core.md`
|
||||
- `docs/self-hosting/caddy.md`
|
||||
- `docs/self-hosting/mail.md`
|
||||
- `docs/architecture/dns-module.md`
|
||||
- `docs/self-hosting/turn.md`
|
||||
- `docs/self-hosting/vpn.md`
|
||||
- `docs/addons/onion.md`
|
||||
- `docs/clients/nerve-extension.md`
|
||||
- `docs/clients/static-site-github-deploys.md`
|
||||
|
||||
## Email Deployment
|
||||
|
||||
The `email` module in this repo is the mailbox/product side of the mail stack.
|
||||
Production internet mail also needs
|
||||
[`elektrine/elektrine-haraka`](https://git.elektrine.com/elektrine/elektrine-haraka),
|
||||
which owns the SMTP edge and delivery pipeline.
|
||||
|
||||
This repo owns the mailbox product: UI, aliases, contacts, JMAP, WKD, message
|
||||
storage, optional IMAP/POP3/SMTP submission listeners, and the Phoenix endpoints
|
||||
that receive mail webhooks. `elektrine-haraka` owns inbound SMTP on port 25,
|
||||
outbound delivery, Redis-backed queueing, and the worker that posts cleaned
|
||||
inbound message data back into Phoenix.
|
||||
|
||||
If you enable the `email` module for production, run `elektrine-haraka`
|
||||
alongside Elektrine and configure `HARAKA_BASE_URL`, an outbound Haraka API key,
|
||||
and an inbound webhook key. Both stacks live on the same host as separate Docker
|
||||
projects (`/opt/elektrine` and `/opt/elektrine-haraka`).
|
||||
|
||||
**Deploy split**
|
||||
|
||||
- `elektrine-haraka` CI builds and pushes
|
||||
`git.elektrine.com/elektrine/elektrine-haraka` (no SSH deploy).
|
||||
- Elektrine deploy rolls the app stack, then pulls that image and restarts the
|
||||
Haraka compose services (`maybe_roll_haraka_stack`), and applies wildcard TLS
|
||||
via `configure_haraka_wildcard_tls.sh`.
|
||||
|
||||
Optional env for the roll: `HARAKA_IMAGE`, `HARAKA_IMAGE_TAG` (default `latest`),
|
||||
`HARAKA_DEPLOY_DIR`, `HARAKA_ROLL_ON_DEPLOY=false` to skip.
|
||||
|
||||
## Bluesky Integration
|
||||
|
||||
Bluesky support is part of the social stack.
|
||||
|
||||
- Outbound sync mirrors local public posts to Bluesky and keeps linked post
|
||||
state in sync for create, edit, delete, like, repost, and follow events.
|
||||
These jobs run through Oban, so failed outbound calls do not block the local
|
||||
action.
|
||||
- Inbound sync, when `BLUESKY_INBOUND_ENABLED=true`, polls notifications for
|
||||
connected accounts and turns replies, mentions, quotes, likes, and reposts on
|
||||
mirrored posts into local notifications. It can also store timeline items
|
||||
locally.
|
||||
- Managed mode, when `BLUESKY_MANAGED_ENABLED=true`, talks to a
|
||||
Bluesky-compatible PDS admin API to create per-user accounts, issue app
|
||||
passwords, and store the linkage. Without managed mode, users connect their
|
||||
own Bluesky identifier and app password.
|
||||
|
||||
## License
|
||||
|
||||
This repository is AGPL-3.0-only unless a file says otherwise.
|
||||
30
SECURITY.md
Normal file
30
SECURITY.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are currently provided for:
|
||||
|
||||
- `main` branch (latest commit)
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not file public GitHub issues for security vulnerabilities.
|
||||
|
||||
Instead:
|
||||
|
||||
1. Email maintainers at `security@elektrine.com`.
|
||||
2. Include reproduction steps, impact, and affected components/apps.
|
||||
3. If possible, include a minimal proof of concept.
|
||||
|
||||
You should receive an initial response within 72 hours.
|
||||
|
||||
## Disclosure Process
|
||||
|
||||
1. We acknowledge receipt and assess severity.
|
||||
2. We prepare and validate a fix.
|
||||
3. We coordinate disclosure timing with the reporter.
|
||||
4. We publish a fix and release notes.
|
||||
|
||||
## Scope Notes
|
||||
|
||||
This policy applies to all umbrella apps under `apps/`.
|
||||
44
apps/README.md
Normal file
44
apps/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Umbrella Apps
|
||||
|
||||
Elektrine is organized as an Elixir umbrella project.
|
||||
|
||||
## Apps
|
||||
|
||||
- [`elektrine`](./elektrine/README.md) - Core domain logic, persistence, runtime services
|
||||
- [`elektrine_web`](./elektrine_web/README.md) - Phoenix endpoint, router, shared layout, and common components
|
||||
- [`arblarg`](./arblarg/README.md) - Chat facade, LiveViews, JSON APIs, and PAT APIs
|
||||
- [`elektrine_email`](./elektrine_email/README.md) - Mailbox domain, mail protocols, JMAP, WKD, and web routes
|
||||
- [`elektrine_social`](./elektrine_social/README.md) - Social/timeline features
|
||||
- [`elektrine_vpn`](./elektrine_vpn/README.md) - VPN features
|
||||
- [`elektrine_nerve`](./elektrine_nerve/README.md) - Password nerve domain and route macros
|
||||
- [`elektrine_dns`](./elektrine_dns/README.md) - Managed DNS runtime, PAT API, and DNS LiveViews
|
||||
- [`elektrine_uptime`](./elektrine_uptime/README.md) - Uptime monitors, check history, incidents, and dashboard routes
|
||||
- [`atomine`](./atomine) - Proofs, personhood, account trust, and credit policy domain logic
|
||||
- [`kairo`](./kairo) - Capture, indexing, and graph-oriented knowledge features
|
||||
- [`paige`](./paige) - Search provider integration and ranking support
|
||||
|
||||
## Running app-focused tests
|
||||
|
||||
From the umbrella root:
|
||||
|
||||
```bash
|
||||
mix test apps/elektrine/test
|
||||
mix test apps/elektrine_web/test
|
||||
mix test apps/elektrine_email/test
|
||||
mix test apps/elektrine_social/test
|
||||
mix test apps/elektrine_vpn/test
|
||||
mix test apps/elektrine_nerve/test
|
||||
mix test apps/elektrine_dns/test
|
||||
mix test apps/elektrine_uptime/test
|
||||
mix test apps/atomine/test
|
||||
mix test apps/kairo/test
|
||||
mix test apps/paige/test
|
||||
```
|
||||
|
||||
## Protocol docs
|
||||
|
||||
The current Arblarg messaging federation spec lives at `../docs/arblarg-1.0.md`.
|
||||
|
||||
## License
|
||||
|
||||
All umbrella apps are covered by the root repository `LICENSE`.
|
||||
4
apps/arblarg/.formatter.exs
Normal file
4
apps/arblarg/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
24
apps/arblarg/.gitignore
vendored
Normal file
24
apps/arblarg/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where third-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
elektrine_chat-*.tar
|
||||
|
||||
20
apps/arblarg/README.md
Normal file
20
apps/arblarg/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Arblarg
|
||||
|
||||
Chat application boundary for the Elektrine umbrella.
|
||||
|
||||
## What lives here
|
||||
|
||||
- Chat APIs used by web and mobile clients
|
||||
- Chat LiveViews, JSON APIs, PAT APIs, and admin Arblarg views
|
||||
- Chat-specific components and web helpers in `ArblargWeb.*` and `ElektrineWeb.*`
|
||||
|
||||
## Notes
|
||||
|
||||
- The public facade is `Arblarg`.
|
||||
- Domain calls still delegate to `Elektrine.Messaging` today.
|
||||
- Uses shared layouts and components from `elektrine_web`.
|
||||
- Router-mounted modules in this app still use the shared `ElektrineWeb.*` namespace where that keeps route integration simple.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only (see `../../LICENSE`).
|
||||
138
apps/arblarg/lib/arblarg.ex
Normal file
138
apps/arblarg/lib/arblarg.ex
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
defmodule Arblarg do
|
||||
@moduledoc """
|
||||
Chat application facade.
|
||||
|
||||
This module is the primary API surface for chat use-cases consumed by
|
||||
`elektrine_web` and other clients. It delegates to `Elektrine.Messaging`
|
||||
during migration so call sites can move first without behavior changes.
|
||||
"""
|
||||
|
||||
alias Elektrine.Messaging
|
||||
|
||||
# Conversations and discovery
|
||||
defdelegate list_conversations(user_id, opts \\ []), to: Messaging
|
||||
defdelegate user_has_conversations?(user_id), to: Messaging
|
||||
defdelegate get_conversation!(id, user_id), to: Messaging
|
||||
defdelegate get_conversation_unread_count(conversation_id, user_id), to: Messaging
|
||||
defdelegate get_conversation_for_chat!(id, user_id), to: Messaging
|
||||
defdelegate get_conversation_for_chat_by_hash!(hash, user_id), to: Messaging
|
||||
defdelegate get_conversation_by_hash(hash), to: Messaging
|
||||
defdelegate search_public_conversations(query, current_user_id, limit \\ 10), to: Messaging
|
||||
defdelegate list_public_channels(opts \\ []), to: Messaging
|
||||
defdelegate list_public_groups(opts \\ []), to: Messaging
|
||||
defdelegate join_conversation(conversation_id, user_id), to: Messaging
|
||||
defdelegate leave_conversation(conversation_id, user_id), to: Messaging
|
||||
defdelegate pin_conversation(conversation_id, user_id), to: Messaging
|
||||
defdelegate unpin_conversation(conversation_id, user_id), to: Messaging
|
||||
defdelegate update_conversation(conversation, attrs), to: Messaging
|
||||
defdelegate delete_conversation(conversation_id), to: Messaging
|
||||
defdelegate clear_history_for_user(conversation_id, user_id), to: Messaging
|
||||
defdelegate create_dm_conversation(user1_id, user2_id), to: Messaging
|
||||
defdelegate create_group_conversation(creator_id, attrs, member_ids \\ []), to: Messaging
|
||||
defdelegate create_channel(creator_id, attrs), to: Messaging
|
||||
|
||||
defdelegate add_member_to_conversation(
|
||||
conversation_id,
|
||||
user_id,
|
||||
role \\ "member",
|
||||
added_by_user_id \\ nil
|
||||
),
|
||||
to: Messaging
|
||||
|
||||
defdelegate remove_member_from_conversation(conversation_id, user_id), to: Messaging
|
||||
defdelegate update_member_role(conversation_id, user_id, new_role), to: Messaging
|
||||
defdelegate update_member_role(conversation_id, user_id, new_role, actor_user_id), to: Messaging
|
||||
defdelegate get_conversation_member(conversation_id, user_id), to: Messaging
|
||||
defdelegate get_conversation_members(conversation_id), to: Messaging
|
||||
|
||||
# Messages and reactions
|
||||
defdelegate get_conversation_messages(conversation_id, user_id, opts \\ []), to: Messaging
|
||||
defdelegate get_messages(conversation_id, user_id, opts \\ []), to: Messaging
|
||||
|
||||
defdelegate create_text_message(
|
||||
conversation_id,
|
||||
sender_id,
|
||||
content,
|
||||
reply_to_id \\ nil,
|
||||
opts \\ []
|
||||
),
|
||||
to: Messaging
|
||||
|
||||
defdelegate create_media_message(
|
||||
conversation_id,
|
||||
sender_id,
|
||||
media_urls,
|
||||
content \\ nil,
|
||||
media_metadata \\ %{}
|
||||
),
|
||||
to: Messaging
|
||||
|
||||
defdelegate create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type),
|
||||
to: Messaging
|
||||
|
||||
defdelegate delete_message(message_id, user_id, is_admin \\ false), to: Messaging
|
||||
defdelegate admin_delete_message(message_id, admin_user), to: Messaging
|
||||
defdelegate add_reaction(message_id, user_id, emoji), to: Messaging
|
||||
defdelegate pin_message(message_id, user_id), to: Messaging
|
||||
defdelegate unpin_message(message_id, user_id), to: Messaging
|
||||
|
||||
defdelegate create_chat_text_message(conversation_id, sender_id, content, opts \\ []),
|
||||
to: Messaging
|
||||
|
||||
defdelegate create_chat_media_message(
|
||||
conversation_id,
|
||||
sender_id,
|
||||
media_urls,
|
||||
content \\ nil,
|
||||
media_metadata \\ %{}
|
||||
),
|
||||
to: Messaging
|
||||
|
||||
defdelegate create_chat_voice_message(
|
||||
conversation_id,
|
||||
sender_id,
|
||||
audio_url,
|
||||
duration,
|
||||
mime_type
|
||||
),
|
||||
to: Messaging
|
||||
|
||||
defdelegate edit_chat_message(message_id, user_id, new_content), to: Messaging
|
||||
defdelegate delete_chat_message(message_id, user_id, is_admin \\ false), to: Messaging
|
||||
defdelegate add_chat_reaction(message_id, user_id, emoji), to: Messaging
|
||||
defdelegate remove_chat_reaction(message_id, user_id, emoji), to: Messaging
|
||||
|
||||
defdelegate search_messages_in_conversation(conversation_id, user_id, query, opts \\ []),
|
||||
to: Messaging
|
||||
|
||||
# Read state and unread counters
|
||||
defdelegate mark_as_read(conversation_id, user_id), to: Messaging
|
||||
defdelegate update_last_read_message(conversation_id, user_id, message_id), to: Messaging
|
||||
defdelegate get_read_status_for_messages(message_ids, conversation_id), to: Messaging
|
||||
defdelegate get_unread_count(user_id), to: Messaging
|
||||
defdelegate get_conversation_unread_counts(conversation_ids, user_id), to: Messaging
|
||||
defdelegate get_batch_last_message_read_status(message_info_list), to: Messaging
|
||||
|
||||
defdelegate mark_chat_messages_read(conversation_id, user_id, up_to_message_id \\ nil),
|
||||
to: Messaging
|
||||
|
||||
defdelegate get_all_chat_unread_counts(user_id), to: Messaging
|
||||
|
||||
# Search and moderation
|
||||
defdelegate list_servers(user_id, opts \\ []), to: Messaging
|
||||
defdelegate list_public_servers(user_id, opts \\ []), to: Messaging
|
||||
defdelegate list_public_directory_servers(opts \\ []), to: Messaging
|
||||
defdelegate get_server(server_id, user_id), to: Messaging
|
||||
defdelegate get_server_member(server_id, user_id), to: Messaging
|
||||
defdelegate create_server(creator_id, attrs), to: Messaging
|
||||
defdelegate create_server_channel(server_id, creator_id, attrs), to: Messaging
|
||||
defdelegate join_server(server_id, user_id), to: Messaging
|
||||
|
||||
defdelegate search_users(query, current_user_id, opts \\ []), to: Messaging
|
||||
defdelegate timeout_user(user_id, created_by_id, duration_seconds, opts \\ []), to: Messaging
|
||||
defdelegate remove_timeout(user_id, conversation_id \\ nil), to: Messaging
|
||||
defdelegate user_timed_out?(user_id, conversation_id \\ nil), to: Messaging
|
||||
|
||||
defdelegate log_moderation_action(action, target_user_id, performed_by_id, opts \\ []),
|
||||
to: Messaging
|
||||
end
|
||||
20
apps/arblarg/lib/arblarg/application.ex
Normal file
20
apps/arblarg/lib/arblarg/application.ex
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Arblarg.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Starts a worker by calling: Arblarg.Worker.start_link(arg)
|
||||
# {Arblarg.Worker, arg}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Arblarg.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
96
apps/arblarg/lib/arblarg_web.ex
Normal file
96
apps/arblarg/lib/arblarg_web.ex
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
defmodule ArblargWeb do
|
||||
@moduledoc """
|
||||
Shared web entrypoints for chat surfaces extracted from elektrine_web.
|
||||
"""
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller,
|
||||
formats: [:html, :json],
|
||||
layouts: [html: ElektrineWeb.Layouts]
|
||||
|
||||
use Gettext, backend: ElektrineWeb.Gettext
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Elektrine.Utils.SafeConvert
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {ElektrineWeb.Layouts, :app}
|
||||
|
||||
on_mount ElektrineWeb.Live.AnnouncementCache
|
||||
|
||||
import ElektrineWeb.Live.NotificationHelpers
|
||||
|
||||
unquote(html_helpers())
|
||||
|
||||
# Presence events are handled by ElektrineWeb's PresenceHook where it is
|
||||
# mounted; these no-op fallbacks keep stray client events from crashing
|
||||
# LiveViews that don't track presence.
|
||||
def handle_event(event, _params, socket)
|
||||
when event in [
|
||||
"user_activity",
|
||||
"auto_away_timeout",
|
||||
"device_detected",
|
||||
"connection_changed"
|
||||
] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
import Phoenix.Controller,
|
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
use Gettext, backend: ElektrineWeb.Gettext
|
||||
|
||||
import Phoenix.HTML
|
||||
import ElektrineWeb.CoreComponents
|
||||
import Elektrine.Components.User.Avatar
|
||||
import Elektrine.Components.Loaders.Skeleton
|
||||
import ArblargWeb.Components.UI.ImageModal
|
||||
|
||||
alias Elektrine.Utils.SafeConvert
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def verified_routes do
|
||||
quote do
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: ElektrineWeb.Endpoint,
|
||||
router: ElektrineWeb.Router,
|
||||
statics: ElektrineWeb.static_paths()
|
||||
end
|
||||
end
|
||||
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
||||
368
apps/arblarg/lib/arblarg_web/components/chat/call.ex
Normal file
368
apps/arblarg/lib/arblarg_web/components/chat/call.ex
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
defmodule ArblargWeb.Components.Chat.Call do
|
||||
@moduledoc false
|
||||
use Phoenix.Component
|
||||
|
||||
attr :user, :map, default: nil
|
||||
attr :conversation_id, :string, default: nil
|
||||
attr :remote_handle, :string, default: nil
|
||||
|
||||
def call_buttons(assigns) do
|
||||
~H"""
|
||||
<div class="flex gap-1 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="initiate_call"
|
||||
phx-value-user_id={@user && @user.id}
|
||||
phx-value-call_type="audio"
|
||||
phx-value-conversation_id={@conversation_id}
|
||||
phx-value-remote_handle={@remote_handle}
|
||||
class="btn btn-ghost btn-circle btn-sm p-0 min-h-0 h-8 w-8 sm:h-9 sm:w-9 flex-shrink-0"
|
||||
title="Audio call"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
phx-click="initiate_call"
|
||||
phx-value-user_id={@user && @user.id}
|
||||
phx-value-call_type="video"
|
||||
phx-value-conversation_id={@conversation_id}
|
||||
phx-value-remote_handle={@remote_handle}
|
||||
class="btn btn-ghost btn-circle btn-sm p-0 min-h-0 h-8 w-8 sm:h-9 sm:w-9 hidden sm:flex flex-shrink-0"
|
||||
title="Video call"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :call, :map, required: true
|
||||
attr :show, :boolean, default: false
|
||||
|
||||
def incoming_call_modal(assigns) do
|
||||
~H"""
|
||||
<div :if={@show} class="modal modal-open">
|
||||
<div class="modal-box modal-surface">
|
||||
<h3 class="font-bold text-lg">Incoming {@call.call_type} call</h3>
|
||||
<div class="py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<.user_avatar user={@call.caller} size="lg" />
|
||||
<div>
|
||||
<p class="font-semibold">{@call.caller.username}</p>
|
||||
<p class="text-sm opacity-70">is calling you...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="reject_call"
|
||||
phx-value-call_id={@call.id}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="answer_call"
|
||||
phx-value-call_id={@call.id}
|
||||
class="btn btn-success"
|
||||
>
|
||||
Answer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :call, :map, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :audio_enabled, :boolean, default: true
|
||||
attr :video_enabled, :boolean, default: true
|
||||
attr :is_caller, :boolean, default: false
|
||||
attr :call_status, :string, default: "connecting"
|
||||
|
||||
def active_call_overlay(assigns) do
|
||||
~H"""
|
||||
<div :if={@show} id="call-overlay" phx-hook="VideoDisplay" class="fixed inset-0 z-50 bg-base-300">
|
||||
<div :if={@call.call_type == "video"} class="relative h-full w-full">
|
||||
<video id="remote-video" class="h-full w-full object-cover" autoplay playsinline></video>
|
||||
<div class="absolute top-4 right-4 w-48 h-36 bg-base-200 rounded-lg overflow-hidden shadow-lg">
|
||||
<video id="local-video" class="h-full w-full object-cover" autoplay playsinline muted>
|
||||
</video>
|
||||
</div>
|
||||
<div class="absolute top-4 left-4 bg-base-200/80 rounded-lg px-4 py-2 border border-base-300/70">
|
||||
<div class="flex items-center gap-3">
|
||||
<.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="sm" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm">
|
||||
{if @is_caller, do: @call.callee.username, else: @call.caller.username}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs opacity-70"
|
||||
id="call-duration"
|
||||
phx-hook="CallTimer"
|
||||
data-status={@call_status}
|
||||
>
|
||||
{if @call_status == "connected", do: "00:00", else: "Connecting..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@call.call_type == "audio"}
|
||||
class="relative h-full w-full flex items-center justify-center"
|
||||
>
|
||||
<audio id="remote-audio" autoplay></audio>
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="2xl" />
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-semibold">
|
||||
{if @is_caller, do: @call.callee.username, else: @call.caller.username}
|
||||
</p>
|
||||
<p
|
||||
class="text-lg opacity-70 mt-2"
|
||||
id="call-duration-audio"
|
||||
phx-hook="CallTimer"
|
||||
data-status={@call_status}
|
||||
>
|
||||
{if @call_status == "connected", do: "00:00", else: "Connecting..."}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-60">
|
||||
<.spinner :if={@call_status == "connecting"} size="sm" />
|
||||
<span
|
||||
:if={@call_status == "connected"}
|
||||
class="w-2 h-2 bg-success rounded-full animate-pulse"
|
||||
>
|
||||
</span>
|
||||
<span>{String.capitalize(@call_status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@call.call_type == "video" && @call_status != "connected"}
|
||||
class="absolute top-20 left-4 bg-base-200/80 rounded-lg px-3 py-2 border border-base-300/70"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<.spinner size="sm" />
|
||||
<span>{String.capitalize(@call_status)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@call.call_type == "video" && @call_status == "connected"}
|
||||
class="absolute top-20 left-4 bg-success/15 rounded-lg px-3 py-2 border border-success/30"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-success">
|
||||
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="call-controls"
|
||||
phx-hook="CallControls"
|
||||
class="absolute bottom-8 left-1/2 transform -translate-x-1/2"
|
||||
>
|
||||
<div class="flex gap-4 bg-base-200/80 rounded-full px-6 py-3 border border-base-300/70">
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-audio"
|
||||
class={["btn btn-circle", if(@audio_enabled, do: "btn-ghost", else: "btn-secondary")]}
|
||||
title={if @audio_enabled, do: "Mute", else: "Unmute"}
|
||||
>
|
||||
<svg
|
||||
:if={@audio_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
:if={!@audio_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:if={@call.call_type == "video"}
|
||||
type="button"
|
||||
data-action="toggle-video"
|
||||
class={["btn btn-circle", if(@video_enabled, do: "btn-ghost", else: "btn-secondary")]}
|
||||
title={if @video_enabled, do: "Disable video", else: "Enable video"}
|
||||
>
|
||||
<svg
|
||||
:if={@video_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
:if={!@video_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="end-call"
|
||||
class="btn btn-circle btn-secondary"
|
||||
title="End call"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 8l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M5 3a2 2 0 00-2 2v1c0 8.284 6.716 15 15 15h1a2 2 0 002-2v-3.28a1 1 0 00-.684-.948l-4.493-1.498a1 1 0 00-1.21.502l-1.13 2.257a11.042 11.042 0 01-5.516-5.517l2.257-1.128a1 1 0 00.502-1.21L9.228 3.683A1 1 0 008.279 3H5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :user, :map, required: true
|
||||
attr :size, :string, default: "md"
|
||||
|
||||
defp user_avatar(assigns) do
|
||||
size_classes = %{
|
||||
"sm" => "w-8 h-8",
|
||||
"md" => "w-12 h-12",
|
||||
"lg" => "w-16 h-16",
|
||||
"2xl" => "w-24 h-24"
|
||||
}
|
||||
|
||||
assigns =
|
||||
assign(assigns, :size_class, Map.get(size_classes, assigns.size, size_classes["md"]))
|
||||
|
||||
~H"""
|
||||
<div class={["avatar", if(@user.avatar, do: "", else: "placeholder")]}>
|
||||
<div class={"rounded-full #{@size_class}"}>
|
||||
<img :if={@user.avatar} src={Elektrine.Uploads.avatar_url(@user.avatar)} />
|
||||
<span :if={!@user.avatar} class="text-lg">
|
||||
{String.first(@user.username) |> String.upcase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :size, :string, default: "md", values: ["xs", "sm", "md", "lg"]
|
||||
attr :class, :string, default: nil
|
||||
|
||||
defp spinner(assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
class={[
|
||||
"animate-spin",
|
||||
case @size do
|
||||
"xs" -> "w-3 h-3"
|
||||
"sm" -> "w-4 h-4"
|
||||
"lg" -> "w-8 h-8"
|
||||
_ -> "w-6 h-6"
|
||||
end,
|
||||
@class
|
||||
]}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.27,4.74a4.93,4.93,0,0,1,1.52,4.61,5.32,5.32,0,0,1-4.1,4.51,5.12,5.12,0,0,1-5.2-1.5,5.53,5.53,0,0,0,6.13-1.48A5.66,5.66,0,0,0,20.27,4.74ZM12.32,11.53a5.49,5.49,0,0,0-1.47-6.2A5.57,5.57,0,0,0,4.71,3.72,5.17,5.17,0,0,1,9.53,2.2,5.52,5.52,0,0,1,13.9,6.45,5.28,5.28,0,0,1,12.32,11.53ZM19.2,20.29a4.92,4.92,0,0,1-4.72,1.49,5.32,5.32,0,0,1-4.34-4.05A5.2,5.2,0,0,1,11.6,12.5a5.6,5.6,0,0,0,1.51,6.13A5.63,5.63,0,0,0,19.2,20.29ZM3.79,19.38A5.18,5.18,0,0,1,2.32,14a5.3,5.3,0,0,1,4.59-4,5,5,0,0,1,4.58,1.61,5.55,5.55,0,0,0-6.32,1.69A5.46,5.46,0,0,0,3.79,19.38ZM12.23,12a5.11,5.11,0,0,0,3.66-5,5.75,5.75,0,0,0-3.18-6,5,5,0,0,1,4.42,2.3,5.21,5.21,0,0,1,.24,5.92A5.4,5.4,0,0,1,12.23,12ZM11.76,12a5.18,5.18,0,0,0-3.68,5.09,5.58,5.58,0,0,0,3.19,5.79c-1,.35-2.9-.46-4-1.68A5.51,5.51,0,0,1,11.76,12ZM23,12.63a5.07,5.07,0,0,1-2.35,4.52,5.23,5.23,0,0,1-5.91.2,5.24,5.24,0,0,1-2.67-4.77,5.51,5.51,0,0,0,5.45,3.33A5.52,5.52,0,0,0,23,12.63ZM1,11.23a5,5,0,0,1,2.49-4.5,5.23,5.23,0,0,1,5.81-.06,5.3,5.3,0,0,1,2.61,4.74A5.56,5.56,0,0,0,6.56,8.06,5.71,5.71,0,0,0,1,11.23Z"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
end
|
||||
46
apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex
Normal file
46
apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule ArblargWeb.Components.Chat.SenderBadge do
|
||||
@moduledoc """
|
||||
Small badge shown next to automated chat message authors.
|
||||
|
||||
Webhook-authored messages hydrate a sender-shaped map with `webhook: true`
|
||||
(see `Elektrine.Messaging.ChatMessages`); bot user accounts carry an
|
||||
`is_bot` flag. Both render a small "APP"/"BOT" badge so automated authors
|
||||
are visually distinct from people.
|
||||
|
||||
Usage in message templates, next to the sender name:
|
||||
|
||||
<.sender_badge sender={message_sender(message)} />
|
||||
|
||||
Renders nothing for regular human senders.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :sender, :map, default: nil
|
||||
|
||||
def sender_badge(assigns) do
|
||||
assigns = assign(assigns, :label, badge_label(assigns.sender))
|
||||
|
||||
~H"""
|
||||
<span
|
||||
:if={@label}
|
||||
class="badge badge-neutral badge-xs uppercase tracking-wide"
|
||||
title="Automated sender"
|
||||
>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns "APP" for webhook senders, "BOT" for bot users, nil otherwise.
|
||||
"""
|
||||
def badge_label(sender) when is_map(sender) do
|
||||
cond do
|
||||
Map.get(sender, :webhook) == true -> "APP"
|
||||
Map.get(sender, :is_bot) == true -> "BOT"
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def badge_label(_sender), do: nil
|
||||
end
|
||||
50
apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex
Normal file
50
apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
defmodule ArblargWeb.Components.Platform.ENav do
|
||||
@moduledoc """
|
||||
Chat-owned wrapper for the shared platform navigation renderer.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: ElektrineWeb.Gettext
|
||||
|
||||
alias Elektrine.Platform.ENav, as: PlatformENav
|
||||
alias Elektrine.Platform.ENavComponent
|
||||
|
||||
attr :active_tab, :string, required: true
|
||||
attr :class, :string, default: "mb-6"
|
||||
attr :current_user, :any, default: nil
|
||||
attr :badge_counts, :map, default: nil
|
||||
|
||||
def e_nav(assigns) do
|
||||
assigns = assign_new(assigns, :badge_counts, fn -> nil end)
|
||||
|
||||
badge_counts =
|
||||
assigns.badge_counts || PlatformENav.notification_badge_counts(assigns.current_user)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:items, nav_items(assigns.current_user, badge_counts))
|
||||
|> assign(:secondary_items, secondary_items(assigns.current_user, badge_counts))
|
||||
|
||||
ENavComponent.render(assigns)
|
||||
end
|
||||
|
||||
defp nav_items(current_user, badge_counts) do
|
||||
PlatformENav.primary_items()
|
||||
|> Enum.map(
|
||||
&Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end)
|
||||
)
|
||||
|> Enum.filter(&PlatformENav.visible?(&1, current_user))
|
||||
|> PlatformENav.with_badge_counts(badge_counts)
|
||||
end
|
||||
|
||||
defp secondary_items(nil, _badge_counts), do: []
|
||||
|
||||
defp secondary_items(current_user, badge_counts) do
|
||||
PlatformENav.secondary_items()
|
||||
|> Enum.map(
|
||||
&Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end)
|
||||
)
|
||||
|> Enum.filter(&PlatformENav.visible?(&1, current_user))
|
||||
|> PlatformENav.with_badge_counts(badge_counts)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule ArblargWeb.Components.Social.ContentJourney do
|
||||
@moduledoc false
|
||||
|
||||
alias ArblargWeb.OptionalModule
|
||||
|
||||
@component_module :"Elixir.ElektrineSocialWeb.Components.Social.ContentJourney"
|
||||
|
||||
def content_journey(assigns) do
|
||||
OptionalModule.call(:social, @component_module, :content_journey, [assigns], "")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule ArblargWeb.Components.Social.EmbeddedPost do
|
||||
@moduledoc false
|
||||
|
||||
alias ArblargWeb.OptionalModule
|
||||
|
||||
@component_module :"Elixir.ElektrineSocialWeb.Components.Social.EmbeddedPost"
|
||||
|
||||
def embedded_post(assigns) do
|
||||
OptionalModule.call(:social, @component_module, :embedded_post, [assigns], "")
|
||||
end
|
||||
end
|
||||
11
apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex
Normal file
11
apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
defmodule ArblargWeb.Components.UI.ImageModal do
|
||||
@moduledoc false
|
||||
|
||||
alias ArblargWeb.OptionalModule
|
||||
|
||||
@component_module :"Elixir.ElektrineSocialWeb.Components.UI.ImageModal"
|
||||
|
||||
def image_modal(assigns) do
|
||||
OptionalModule.call(:social, @component_module, :image_modal, [assigns], "")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
defmodule ArblargWeb.Admin.ChatMessagesController do
|
||||
@moduledoc """
|
||||
Controller for admin chat message viewing and management.
|
||||
"""
|
||||
|
||||
use ArblargWeb, :controller
|
||||
|
||||
alias Elektrine.{Messaging, Repo}
|
||||
import Ecto.Query
|
||||
|
||||
plug :put_layout, html: {ElektrineWeb.Layouts, :admin}
|
||||
plug :assign_timezone_and_format
|
||||
|
||||
defp assign_timezone_and_format(conn, _opts) do
|
||||
current_user = conn.assigns[:current_user]
|
||||
|
||||
timezone =
|
||||
if current_user && current_user.timezone, do: current_user.timezone, else: "Etc/UTC"
|
||||
|
||||
time_format =
|
||||
if current_user && current_user.time_format, do: current_user.time_format, else: "12"
|
||||
|
||||
conn
|
||||
|> assign(:timezone, timezone)
|
||||
|> assign(:time_format, time_format)
|
||||
end
|
||||
|
||||
def index(conn, params) do
|
||||
search_query = params |> Map.get("search", "") |> String.trim()
|
||||
|
||||
conversation_type_filter =
|
||||
parse_conversation_type_filter(Map.get(params, "conversation_type"))
|
||||
|
||||
protocol_filter = parse_protocol_filter(Map.get(params, "protocol"))
|
||||
page = SafeConvert.parse_page(params)
|
||||
per_page = 50
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query =
|
||||
base_chat_messages_query()
|
||||
|> maybe_filter_conversation_type(conversation_type_filter)
|
||||
|> maybe_filter_protocol(protocol_filter)
|
||||
|> maybe_search(search_query)
|
||||
|
||||
total_count =
|
||||
query
|
||||
|> exclude(:order_by)
|
||||
|> select([m, ...], m.id)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
messages =
|
||||
query
|
||||
|> order_by([m], desc: m.inserted_at)
|
||||
|> limit(^per_page)
|
||||
|> offset(^offset)
|
||||
|> preload([_m, c, s], conversation: c, sender: s)
|
||||
|> Repo.all()
|
||||
|> Messaging.ChatMessage.decrypt_messages()
|
||||
|> Enum.map(&Map.put(&1, :protocol_kind, protocol_kind(&1)))
|
||||
|
||||
stats = %{
|
||||
local: Enum.count(messages, &(&1.protocol_kind == "local")),
|
||||
federated: Enum.count(messages, &(&1.protocol_kind == "federated")),
|
||||
mirrored: Enum.count(messages, &(&1.protocol_kind == "mirrored"))
|
||||
}
|
||||
|
||||
total_pages = ceil(total_count / per_page)
|
||||
page_range = pagination_range(page, total_pages)
|
||||
|
||||
log_admin_chat_messages_index(
|
||||
conn,
|
||||
messages,
|
||||
page,
|
||||
search_query,
|
||||
conversation_type_filter,
|
||||
protocol_filter
|
||||
)
|
||||
|
||||
render(conn, :chat_messages,
|
||||
messages: messages,
|
||||
search_query: search_query,
|
||||
conversation_type_filter: conversation_type_filter,
|
||||
protocol_filter: protocol_filter,
|
||||
page_results_count: length(messages),
|
||||
stats: stats,
|
||||
current_page: page,
|
||||
total_pages: total_pages,
|
||||
total_count: total_count,
|
||||
page_range: page_range
|
||||
)
|
||||
end
|
||||
|
||||
def view(conn, %{"id" => message_id}) do
|
||||
case SafeConvert.parse_id(message_id) do
|
||||
{:ok, id} ->
|
||||
case Messaging.get_chat_message(id) do
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
%{deleted_at: deleted_at} when not is_nil(deleted_at) ->
|
||||
not_found(conn)
|
||||
|
||||
message ->
|
||||
message =
|
||||
message
|
||||
|> Repo.preload(:conversation)
|
||||
|> Messaging.ChatMessage.decrypt_content()
|
||||
|> Map.put(:protocol_kind, protocol_kind(message))
|
||||
|
||||
log_admin_chat_message_view(conn, message, "html", "admin_arblarg_messages")
|
||||
|
||||
render(conn, :view_chat_message, message: message)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
not_found(conn)
|
||||
end
|
||||
end
|
||||
|
||||
def view_raw(conn, %{"id" => message_id}) do
|
||||
case SafeConvert.parse_id(message_id) do
|
||||
{:ok, id} ->
|
||||
case Messaging.get_chat_message(id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(404, "Chat message not found")
|
||||
|
||||
%{deleted_at: deleted_at} when not is_nil(deleted_at) ->
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(404, "Chat message not found")
|
||||
|
||||
message ->
|
||||
message =
|
||||
message
|
||||
|> Repo.preload(:conversation)
|
||||
|> Messaging.ChatMessage.decrypt_content()
|
||||
|> Map.put(:protocol_kind, protocol_kind(message))
|
||||
|
||||
log_admin_chat_message_view(conn, message, "raw", "admin_arblarg_messages")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(200, get_raw_chat_message_content(message))
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(404, "Chat message not found")
|
||||
end
|
||||
end
|
||||
|
||||
defp not_found(conn) do
|
||||
conn
|
||||
|> put_flash(:error, "Chat message not found")
|
||||
|> redirect(to: ~p"/pripyat/arblarg/messages")
|
||||
end
|
||||
|
||||
defp base_chat_messages_query do
|
||||
from(m in Messaging.ChatMessage,
|
||||
left_join: c in Messaging.ChatConversation,
|
||||
on: m.conversation_id == c.id,
|
||||
left_join: s in Elektrine.Accounts.User,
|
||||
on: m.sender_id == s.id,
|
||||
where: is_nil(m.deleted_at)
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_search(query, ""), do: query
|
||||
|
||||
defp maybe_search(query, search_query) do
|
||||
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
|
||||
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)
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_filter_conversation_type(query, "dm"), do: where(query, [_m, c, _s], c.type == "dm")
|
||||
|
||||
defp maybe_filter_conversation_type(query, "group"),
|
||||
do: where(query, [_m, c, _s], c.type == "group")
|
||||
|
||||
defp maybe_filter_conversation_type(query, "channel"),
|
||||
do: where(query, [_m, c, _s], c.type == "channel")
|
||||
|
||||
defp maybe_filter_conversation_type(query, "all"), do: query
|
||||
defp maybe_filter_conversation_type(query, _), do: query
|
||||
|
||||
defp maybe_filter_protocol(query, "local"),
|
||||
do:
|
||||
where(
|
||||
query,
|
||||
[m, _c, _s],
|
||||
is_nil(m.federated_source) and is_nil(m.origin_domain) and m.is_federated_mirror == false
|
||||
)
|
||||
|
||||
defp maybe_filter_protocol(query, "mirrored"),
|
||||
do: where(query, [m, _c, _s], m.is_federated_mirror == true)
|
||||
|
||||
defp maybe_filter_protocol(query, "federated"),
|
||||
do:
|
||||
where(
|
||||
query,
|
||||
[m, _c, _s],
|
||||
not is_nil(m.federated_source) or not is_nil(m.origin_domain) or
|
||||
m.is_federated_mirror == true
|
||||
)
|
||||
|
||||
defp maybe_filter_protocol(query, "all"), do: query
|
||||
defp maybe_filter_protocol(query, _), do: query
|
||||
|
||||
defp parse_conversation_type_filter("dm"), do: "dm"
|
||||
defp parse_conversation_type_filter("group"), do: "group"
|
||||
defp parse_conversation_type_filter("channel"), do: "channel"
|
||||
defp parse_conversation_type_filter(_), do: "all"
|
||||
|
||||
defp parse_protocol_filter("local"), do: "local"
|
||||
defp parse_protocol_filter("federated"), do: "federated"
|
||||
defp parse_protocol_filter("mirrored"), do: "mirrored"
|
||||
defp parse_protocol_filter(_), do: "all"
|
||||
|
||||
defp protocol_kind(message) do
|
||||
cond do
|
||||
message.is_federated_mirror -> "mirrored"
|
||||
present?(message.federated_source) or present?(message.origin_domain) -> "federated"
|
||||
true -> "local"
|
||||
end
|
||||
end
|
||||
|
||||
defp present?(value), do: Elektrine.Strings.present?(value)
|
||||
|
||||
defp get_raw_chat_message_content(message) do
|
||||
conversation = Map.get(message, :conversation)
|
||||
sender = Map.get(message, :sender)
|
||||
|
||||
media_urls =
|
||||
message.media_urls
|
||||
|> List.wrap()
|
||||
|> Enum.join("\n")
|
||||
|> case do
|
||||
"" -> "(No media URLs)"
|
||||
value -> value
|
||||
end
|
||||
|
||||
media_metadata =
|
||||
case message.media_metadata do
|
||||
value when is_map(value) and map_size(value) > 0 -> Jason.encode!(value, pretty: true)
|
||||
_ -> "(No media metadata)"
|
||||
end
|
||||
|
||||
"""
|
||||
===============================================================================
|
||||
ARBLARG CHAT MESSAGE
|
||||
===============================================================================
|
||||
|
||||
MESSAGE FIELDS:
|
||||
---------------
|
||||
ID: #{message.id}
|
||||
Message Type: #{message.message_type || "text"}
|
||||
Protocol Kind: #{message.protocol_kind}
|
||||
Conversation ID: #{message.conversation_id || "N/A"}
|
||||
Sender ID: #{message.sender_id || "N/A"}
|
||||
Reply To ID: #{message.reply_to_id || "(None)"}
|
||||
Federated Source: #{message.federated_source || "(None)"}
|
||||
Origin Domain: #{message.origin_domain || "(None)"}
|
||||
Mirrored: #{message.is_federated_mirror || false}
|
||||
Edited At: #{message.edited_at || "(Never)"}
|
||||
Inserted At: #{message.inserted_at}
|
||||
Updated At: #{message.updated_at}
|
||||
|
||||
CONVERSATION:
|
||||
-------------
|
||||
#{if conversation do
|
||||
"""
|
||||
Name: #{conversation.name || "(No name)"}
|
||||
Type: #{conversation.type || "(Unknown)"}
|
||||
Public: #{conversation.is_public || false}
|
||||
Hash: #{conversation.hash || "(No hash)"}
|
||||
"""
|
||||
else
|
||||
"Conversation not loaded"
|
||||
end}
|
||||
|
||||
SENDER:
|
||||
-------
|
||||
#{if sender do
|
||||
"""
|
||||
Username: #{sender.username}
|
||||
Handle: #{sender.handle || "(No handle)"}
|
||||
Is Admin: #{sender.is_admin || false}
|
||||
"""
|
||||
else
|
||||
"Sender not loaded"
|
||||
end}
|
||||
|
||||
CONTENT:
|
||||
--------
|
||||
#{message.content || "(No text content)"}
|
||||
|
||||
MEDIA URLS:
|
||||
-----------
|
||||
#{media_urls}
|
||||
|
||||
MEDIA METADATA:
|
||||
---------------
|
||||
#{media_metadata}
|
||||
|
||||
RAW STRUCT:
|
||||
-----------
|
||||
#{inspect(message, pretty: true, limit: :infinity)}
|
||||
|
||||
===============================================================================
|
||||
END OF ARBLARG CHAT MESSAGE
|
||||
===============================================================================
|
||||
"""
|
||||
end
|
||||
|
||||
defp log_admin_chat_messages_index(
|
||||
conn,
|
||||
messages,
|
||||
page,
|
||||
search_query,
|
||||
conversation_type_filter,
|
||||
protocol_filter
|
||||
) do
|
||||
case conn.assigns[:current_user] do
|
||||
%{id: admin_id, is_admin: true, username: admin_username} ->
|
||||
Elektrine.AuditLog.log(
|
||||
admin_id,
|
||||
"view_chat_messages",
|
||||
"chat_message_list",
|
||||
details: %{
|
||||
route_context: "admin_arblarg_messages",
|
||||
page: page,
|
||||
search_query: search_query,
|
||||
conversation_type_filter: conversation_type_filter,
|
||||
protocol_filter: protocol_filter,
|
||||
result_count: length(messages),
|
||||
message_ids: Enum.map(messages, & &1.id),
|
||||
viewer_username: admin_username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp log_admin_chat_message_view(conn, message, view_format, route_context) do
|
||||
case conn.assigns[:current_user] do
|
||||
%{id: admin_id, is_admin: true, username: admin_username} ->
|
||||
Elektrine.AuditLog.log(
|
||||
admin_id,
|
||||
"view_chat_message",
|
||||
"chat_message",
|
||||
target_user_id: message.sender_id,
|
||||
resource_id: message.id,
|
||||
details: %{
|
||||
view_format: view_format,
|
||||
route_context: route_context,
|
||||
message_type: message.message_type,
|
||||
conversation_id: message.conversation_id,
|
||||
conversation_type: message.conversation && message.conversation.type,
|
||||
protocol_kind: message.protocol_kind,
|
||||
federated_source: message.federated_source,
|
||||
origin_domain: message.origin_domain,
|
||||
mirrored: message.is_federated_mirror || false,
|
||||
viewer_username: admin_username
|
||||
},
|
||||
ip_address: get_remote_ip(conn),
|
||||
user_agent: get_req_header(conn, "user-agent") |> List.first()
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp get_remote_ip(conn) do
|
||||
ElektrineWeb.ClientIP.client_ip(conn)
|
||||
end
|
||||
|
||||
defp pagination_range(_current_page, total_pages) when total_pages <= 7 do
|
||||
1..total_pages//1 |> Enum.to_list()
|
||||
end
|
||||
|
||||
defp pagination_range(current_page, total_pages) do
|
||||
cond do
|
||||
current_page <= 4 ->
|
||||
1..7//1 |> Enum.to_list()
|
||||
|
||||
current_page >= total_pages - 3 ->
|
||||
(total_pages - 6)..total_pages//1 |> Enum.to_list()
|
||||
|
||||
true ->
|
||||
(current_page - 3)..(current_page + 3)//1 |> Enum.to_list()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
defmodule ArblargWeb.Admin.ChatMessagesHTML do
|
||||
@moduledoc """
|
||||
View helpers and templates for admin chat message views.
|
||||
"""
|
||||
|
||||
use ArblargWeb, :html
|
||||
|
||||
embed_templates "chat_messages_html/*"
|
||||
|
||||
def truncate_content(nil), do: ""
|
||||
|
||||
def truncate_content(content) when is_binary(content) do
|
||||
if String.length(content) > 150 do
|
||||
String.slice(content, 0, 150) <> "..."
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
<div class="admin-page">
|
||||
<% search_active = @search_query != "" %>
|
||||
<% conversation_type_filter = assigns[:conversation_type_filter] || "all" %>
|
||||
<% protocol_filter = assigns[:protocol_filter] || "all" %>
|
||||
<% page_results_count = assigns[:page_results_count] || length(@messages) %>
|
||||
<% stats = assigns[:stats] || %{local: 0, federated: 0, mirrored: 0} %>
|
||||
<% base_params =
|
||||
%{
|
||||
"conversation_type" => conversation_type_filter,
|
||||
"protocol" => protocol_filter
|
||||
}
|
||||
|> then(fn params ->
|
||||
if search_active, do: Map.put(params, "search", @search_query), else: params
|
||||
end) %>
|
||||
<% base_params_without_search = Map.delete(base_params, "search") %>
|
||||
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="flex flex-col gap-6 px-5 py-6 sm:px-8 sm:py-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="max-w-2xl">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.32em] text-primary/80">
|
||||
Arblarg
|
||||
</div>
|
||||
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Chat Message Console
|
||||
</h1>
|
||||
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/70 sm:text-base">
|
||||
Admin view for chat traffic across DMs, groups, and channels. Powered by Arblarg.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-muted rounded-box px-3 py-2 text-sm text-base-content/70">
|
||||
Page <span class="font-semibold text-base-content">{@current_page}</span>
|
||||
of <span class="font-semibold text-base-content">{@total_pages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
|
||||
<section class="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<div class="surface-muted rounded-box px-4 py-4">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
On This Page
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-base-content">{page_results_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-muted rounded-box px-4 py-4">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Local
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-info">{stats.local}</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-muted rounded-box px-4 py-4">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Federated
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-success">{stats.federated}</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-muted rounded-box px-4 py-4">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Mirrored
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-warning">{stats.mirrored}</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-muted rounded-box px-4 py-4">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Total Matches
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-primary">{@total_count}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
|
||||
Filters
|
||||
</div>
|
||||
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">Search Messages</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 sm:px-6">
|
||||
<form
|
||||
method="get"
|
||||
action={~p"/pripyat/arblarg/messages"}
|
||||
class="grid grid-cols-1 gap-4 lg:grid-cols-12"
|
||||
>
|
||||
<div class="lg:col-span-6">
|
||||
<label class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Search messages
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={@search_query}
|
||||
placeholder="Search by content, sender, conversation, source, or domain"
|
||||
class="input input-bordered mt-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
<label class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Conversation
|
||||
</label>
|
||||
<select name="conversation_type" class="select select-bordered mt-2 w-full">
|
||||
<option value="all" selected={conversation_type_filter == "all"}>All</option>
|
||||
<option value="dm" selected={conversation_type_filter == "dm"}>DM</option>
|
||||
<option value="group" selected={conversation_type_filter == "group"}>Group</option>
|
||||
<option value="channel" selected={conversation_type_filter == "channel"}>
|
||||
Channel
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
<label class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Protocol
|
||||
</label>
|
||||
<select name="protocol" class="select select-bordered mt-2 w-full">
|
||||
<option value="all" selected={protocol_filter == "all"}>All</option>
|
||||
<option value="local" selected={protocol_filter == "local"}>Local</option>
|
||||
<option value="federated" selected={protocol_filter == "federated"}>
|
||||
Federated
|
||||
</option>
|
||||
<option value="mirrored" selected={protocol_filter == "mirrored"}>Mirrored</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 lg:col-span-12">
|
||||
<.button type="submit" variant="primary">
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Search
|
||||
</.button>
|
||||
<%= if search_active or conversation_type_filter != "all" or protocol_filter != "all" do %>
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages?#{base_params_without_search}"}
|
||||
variant="ghost"
|
||||
data-search-clear="true"
|
||||
>
|
||||
Reset Search
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<%= if search_active do %>
|
||||
<div class="mt-4 rounded-box border border-info/20 bg-info/10 px-4 py-3 text-sm text-base-content/75">
|
||||
Showing results for <strong>{@search_query}</strong>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
|
||||
<%= if @messages == [] do %>
|
||||
<div class="rounded-box border border-dashed border-base-content/15 bg-base-200/45 px-4 py-8 text-center">
|
||||
<.icon
|
||||
name="hero-chat-bubble-left-right"
|
||||
class="mx-auto mb-3 h-10 w-10 text-base-content/30"
|
||||
/>
|
||||
<h3 class="text-lg font-medium">No chat messages found</h3>
|
||||
<p class="mt-1 text-sm text-base-content/55">
|
||||
Adjust your filters or search query.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
|
||||
Results
|
||||
</div>
|
||||
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">Messages</h2>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-base-content/55">
|
||||
{page_results_count} on this page
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 sm:px-6">
|
||||
<div class="hidden overflow-x-auto lg:block">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Message</th>
|
||||
<th>Conversation</th>
|
||||
<th>Sender</th>
|
||||
<th>Protocol</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for message <- @messages do %>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="truncate font-medium"
|
||||
title={message.content || "(No text content)"}
|
||||
>
|
||||
{truncate_content(message.content || "(No text content)")}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="badge badge-ghost badge-sm">{message.message_type}</span>
|
||||
<span class="text-base-content/60">ID: {message.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium">
|
||||
{message.conversation && (message.conversation.name || "(No name)")}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{message.conversation &&
|
||||
String.upcase(message.conversation.type || "unknown")}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium">{message.sender && message.sender.username}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
@{message.sender && message.sender.handle}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class={
|
||||
case message.protocol_kind do
|
||||
"mirrored" -> "badge badge-warning badge-sm"
|
||||
"federated" -> "badge badge-success badge-sm"
|
||||
_ -> "badge badge-info badge-sm"
|
||||
end
|
||||
}>
|
||||
{String.capitalize(message.protocol_kind)}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-base-content/60"
|
||||
title={message.origin_domain || message.federated_source}
|
||||
>
|
||||
{message.origin_domain || message.federated_source || "local"}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm text-base-content/70">
|
||||
<.local_time
|
||||
datetime={message.inserted_at}
|
||||
format="datetime"
|
||||
timezone={@timezone}
|
||||
time_format={@time_format}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages/#{message.id}/view"}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="View message"
|
||||
>
|
||||
<.icon name="hero-eye" class="h-4 w-4" />
|
||||
</.button>
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages/#{message.id}/raw"}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
title="View raw payload"
|
||||
>
|
||||
<.icon name="hero-code-bracket" class="h-4 w-4" />
|
||||
</.button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 lg:hidden">
|
||||
<%= for message <- @messages do %>
|
||||
<div class="rounded-box border border-base-content/10 bg-base-200/45 px-4 py-4">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4
|
||||
class="truncate text-sm font-medium"
|
||||
title={message.content || "(No text content)"}
|
||||
>
|
||||
{truncate_content(message.content || "(No text content)")}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-base-content/70">
|
||||
{message.conversation && (message.conversation.name || "(No name)")} • {message.conversation &&
|
||||
String.upcase(message.conversation.type || "unknown")}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
@{message.sender && message.sender.handle}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 gap-1">
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages/#{message.id}/view"}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
title="View message"
|
||||
>
|
||||
<.icon name="hero-eye" class="h-3 w-3" />
|
||||
</.button>
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages/#{message.id}/raw"}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
title="View raw payload"
|
||||
>
|
||||
<.icon name="hero-code-bracket" class="h-3 w-3" />
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-base-content/60">ID: {message.id}</span>
|
||||
<span class="text-base-content/70">
|
||||
<.local_time
|
||||
datetime={message.inserted_at}
|
||||
format="datetime"
|
||||
timezone={@timezone}
|
||||
time_format={@time_format}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
<% end %>
|
||||
|
||||
<%= if @total_pages > 1 do %>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="join">
|
||||
<%= if @current_page > 1 do %>
|
||||
<.button
|
||||
href={
|
||||
~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page - 1)}"
|
||||
}
|
||||
variant="default"
|
||||
class="join-item"
|
||||
>
|
||||
«
|
||||
</.button>
|
||||
<% end %>
|
||||
|
||||
<%= for page_num <- @page_range do %>
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", page_num)}"}
|
||||
variant={if page_num == @current_page, do: "primary", else: "ghost"}
|
||||
class="join-item"
|
||||
>
|
||||
{page_num}
|
||||
</.button>
|
||||
<% end %>
|
||||
|
||||
<%= if @current_page < @total_pages do %>
|
||||
<.button
|
||||
href={
|
||||
~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page + 1)}"
|
||||
}
|
||||
variant="default"
|
||||
class="join-item"
|
||||
>
|
||||
»
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-base-content/70">
|
||||
{@total_count} total message{if @total_count != 1, do: "s"}
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<div class="admin-page">
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="flex flex-col gap-6 px-5 py-6 sm:px-8 sm:py-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="max-w-2xl">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.32em] text-primary/80">
|
||||
Arblarg
|
||||
</div>
|
||||
|
||||
<h1 class="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">Chat Message</h1>
|
||||
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/70 sm:text-base">
|
||||
Inspect a single message, its delivery metadata, and raw payload for moderation or debugging work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<.button
|
||||
href={~p"/pripyat/arblarg/messages/#{@message.id}/raw"}
|
||||
variant="ghost"
|
||||
target="_blank"
|
||||
>
|
||||
<.icon name="hero-code-bracket" class="h-4 w-4" /> View Raw
|
||||
</.button>
|
||||
<.button href={~p"/pripyat/arblarg/messages"} variant="ghost">
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Messages
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
|
||||
Delivery
|
||||
</div>
|
||||
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">Message Info</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 px-5 py-5 sm:grid-cols-2 sm:px-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Message ID
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium">#{@message.id}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Type
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="badge badge-ghost badge-sm">{@message.message_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Sender
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
<.link
|
||||
href={~p"/pripyat/users/#{@message.sender_id}/edit"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{@message.sender && @message.sender.username} (@{@message.sender &&
|
||||
@message.sender.handle})
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Conversation
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
{@message.conversation && (@message.conversation.name || "(No name)")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Conversation Type
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
{@message.conversation && String.upcase(@message.conversation.type || "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Created
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
<.local_time
|
||||
datetime={@message.inserted_at}
|
||||
format="datetime"
|
||||
timezone={@timezone}
|
||||
time_format={@time_format}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Edited At
|
||||
</div>
|
||||
<div class="mt-1 text-sm">{@message.edited_at || "(Never)"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Protocol
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class={
|
||||
case @message.protocol_kind do
|
||||
"mirrored" -> "badge badge-warning badge-sm"
|
||||
"federated" -> "badge badge-success badge-sm"
|
||||
_ -> "badge badge-info badge-sm"
|
||||
end
|
||||
}>
|
||||
{String.capitalize(@message.protocol_kind)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Origin Domain
|
||||
</div>
|
||||
<div class="mt-1 text-sm">{@message.origin_domain || "(Local)"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Federated Source
|
||||
</div>
|
||||
<div class="mt-1 text-xs break-all">{@message.federated_source || "(None)"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Mirrored
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
{if @message.is_federated_mirror, do: "Yes", else: "No"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
|
||||
Body
|
||||
</div>
|
||||
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">Content</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 sm:px-6">
|
||||
<%= if @message.content && String.trim(@message.content) != "" do %>
|
||||
<div class="whitespace-pre-wrap rounded-box border border-base-content/10 bg-base-200/35 p-4 text-sm leading-relaxed break-words">
|
||||
{@message.content}
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-box border border-dashed border-base-content/15 bg-base-200/45 px-4 py-8 text-center text-sm text-base-content/55">
|
||||
No text content on this message.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
|
||||
<.card class="panel-card" body_class="p-0">
|
||||
<:body>
|
||||
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
|
||||
Attachments
|
||||
</div>
|
||||
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">Media & Metadata</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Media URLs
|
||||
</div>
|
||||
|
||||
<%= if @message.media_urls && @message.media_urls != [] do %>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= for media_url <- @message.media_urls do %>
|
||||
<div class="text-sm break-all">
|
||||
<a
|
||||
href={media_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-primary"
|
||||
>
|
||||
{media_url}
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="mt-2 text-sm text-base-content/60">No media URLs.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-base-content/10 px-5 py-5 sm:px-6">
|
||||
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
|
||||
Media Metadata
|
||||
</div>
|
||||
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap rounded-box border border-base-content/10 bg-base-200/35 p-4 text-xs break-words">{if is_map(@message.media_metadata), do: Jason.encode!(@message.media_metadata, pretty: true), else: inspect(@message.media_metadata, pretty: true)}</pre>
|
||||
</div>
|
||||
</:body>
|
||||
</.card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,907 @@
|
|||
defmodule ArblargWeb.API.ConversationController do
|
||||
@moduledoc """
|
||||
API controller for chat conversations.
|
||||
"""
|
||||
use ArblargWeb, :controller
|
||||
|
||||
alias Elektrine.Messaging
|
||||
alias Elektrine.Uploads
|
||||
|
||||
action_fallback ElektrineWeb.FallbackController
|
||||
|
||||
@doc """
|
||||
GET /api/conversations
|
||||
Lists all conversations for the current user.
|
||||
"""
|
||||
def index(conn, params) do
|
||||
user = conn.assigns[:current_user]
|
||||
limit = parse_int(params["limit"], 50)
|
||||
|
||||
conversations = Messaging.list_chat_conversations(user.id, limit: limit)
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
conversations: Enum.map(conversations, &format_conversation(&1, user.id))
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/conversations/:id
|
||||
Gets a specific conversation with recent messages.
|
||||
"""
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case Messaging.get_conversation!(conversation_id, user.id) do
|
||||
{:ok, conversation} ->
|
||||
{conv_data, messages} = format_conversation_with_messages(conversation, user.id)
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{conversation: conv_data, messages: messages})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
{:error, :not_member} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You are not a member of this conversation"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations
|
||||
Creates a new conversation (DM, group, or channel).
|
||||
|
||||
Params:
|
||||
- type: "dm" | "group" | "channel" (required)
|
||||
- user_id: Target user ID (required for DM)
|
||||
- name: Conversation name (required for group/channel)
|
||||
- description: Optional description
|
||||
- member_ids: List of user IDs to add (for group)
|
||||
- is_public: Whether group/channel is public (default false)
|
||||
"""
|
||||
def create(conn, %{"type" => "dm", "user_id" => target_user_id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, target_user_id, fn parsed_target_user_id ->
|
||||
case Messaging.create_dm_conversation(user.id, parsed_target_user_id) do
|
||||
{:ok, conversation} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Conversation created",
|
||||
conversation: format_conversation(conversation, user.id)
|
||||
})
|
||||
|
||||
{:error, :self_dm} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Cannot create conversation with yourself"})
|
||||
|
||||
{:error, :user_not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "User not found"})
|
||||
|
||||
{:error, :privacy_blocked} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Cannot message this user due to privacy settings"})
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "Too many conversations created. Please try again later."})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create conversation: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def create(conn, %{"type" => "group"} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
attrs = %{
|
||||
name: params["name"],
|
||||
description: params["description"],
|
||||
avatar_url: params["avatar_url"],
|
||||
is_public: params["is_public"] == true
|
||||
}
|
||||
|
||||
member_ids = params["member_ids"] || []
|
||||
|
||||
case Messaging.create_chat_group_conversation(user.id, attrs, member_ids) do
|
||||
{:ok, conversation} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Group created",
|
||||
conversation: format_conversation(conversation, user.id)
|
||||
})
|
||||
|
||||
{:error, :limit_reached} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Maximum group limit reached"})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create group: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, %{"type" => "channel"} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
attrs = %{
|
||||
name: params["name"],
|
||||
description: params["description"],
|
||||
avatar_url: params["avatar_url"],
|
||||
# Channels are public by default
|
||||
is_public: params["is_public"] != false
|
||||
}
|
||||
|
||||
case Messaging.create_channel(user.id, attrs) do
|
||||
{:ok, conversation} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Channel created",
|
||||
conversation: format_conversation(conversation, user.id)
|
||||
})
|
||||
|
||||
{:error, :limit_reached} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Maximum channel limit reached"})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create channel: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing required parameter: type (dm, group, or channel)"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
PUT /api/conversations/:id
|
||||
Updates a conversation (name, description, etc.).
|
||||
"""
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
# Check if user has permission to update
|
||||
case Messaging.get_conversation_member(conversation_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
member when member.role not in ["owner", "admin"] ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Only owners and admins can update conversations"})
|
||||
|
||||
_member ->
|
||||
# Get the conversation and update it
|
||||
case Messaging.get_conversation!(conversation_id, user.id) do
|
||||
{:ok, conversation} ->
|
||||
attrs =
|
||||
%{}
|
||||
|> maybe_put(:name, params["name"])
|
||||
|> maybe_put(:description, params["description"])
|
||||
|> maybe_put(:avatar_url, params["avatar_url"])
|
||||
|
||||
case Messaging.update_conversation(conversation, attrs) do
|
||||
{:ok, updated} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
message: "Conversation updated",
|
||||
conversation: format_conversation(updated, user.id)
|
||||
})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/conversations/:id
|
||||
Deletes a conversation (owner only) or leaves it.
|
||||
"""
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case Messaging.get_conversation_member(conversation_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
member ->
|
||||
if member.role == "owner" do
|
||||
# Owner deletes the entire conversation
|
||||
case Messaging.delete_conversation(conversation_id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Conversation deleted"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to delete: #{inspect(reason)}"})
|
||||
end
|
||||
else
|
||||
# Non-owner leaves the conversation
|
||||
case Messaging.remove_member_from_conversation(conversation_id, user.id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Left conversation"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to leave: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/join
|
||||
Joins a public conversation.
|
||||
"""
|
||||
def join(conn, %{"conversation_id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case Messaging.join_conversation(conversation_id, user.id) do
|
||||
{:ok, :pending} ->
|
||||
conn
|
||||
|> put_status(:accepted)
|
||||
|> json(%{message: "Join request sent"})
|
||||
|
||||
{:ok, _member} ->
|
||||
{:ok, conversation} = Messaging.get_conversation!(conversation_id, user.id)
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
message: "Joined conversation",
|
||||
conversation: format_conversation(conversation, user.id)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
{:error, :not_public} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "This conversation is not public"})
|
||||
|
||||
{:error, :not_public_channel} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "This conversation is not public"})
|
||||
|
||||
{:error, :must_join_server} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Join the server first before joining this channel"})
|
||||
|
||||
{:error, :already_member} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Already a member of this conversation"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to join: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/leave
|
||||
Leaves a conversation.
|
||||
"""
|
||||
def leave(conn, %{"conversation_id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case Messaging.remove_member_from_conversation(conversation_id, user.id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Left conversation"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found or not a member"})
|
||||
|
||||
{:error, :owner_cannot_leave} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Owners must transfer ownership or delete the conversation"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to leave: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/read
|
||||
Marks conversation as read.
|
||||
"""
|
||||
def mark_read(conn, %{"conversation_id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case Messaging.mark_as_read(conversation_id, user.id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Marked as read"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to mark as read: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/conversations/:id/members
|
||||
Lists members of a conversation.
|
||||
"""
|
||||
def members(conn, %{"conversation_id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
# Verify user is a member
|
||||
case Messaging.get_conversation_member(conversation_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Not a member of this conversation"})
|
||||
|
||||
_member ->
|
||||
members = Messaging.get_conversation_members(conversation_id)
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{members: Enum.map(members, &format_member/1)})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/conversations/:id/remote-join-requests
|
||||
Lists pending remote join requests for a locally authoritative channel.
|
||||
"""
|
||||
def pending_remote_join_requests(conn, %{"conversation_id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
case manage_conversation_membership_permission(conversation_id, user.id) do
|
||||
:ok ->
|
||||
requests = Messaging.list_pending_remote_join_requests(conversation_id)
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{requests: Enum.map(requests, &format_remote_join_request/1)})
|
||||
|
||||
{:error, status, error} ->
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{error: error})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/remote-join-requests/approve
|
||||
Approves a pending remote join request.
|
||||
"""
|
||||
def approve_remote_join_request(
|
||||
conn,
|
||||
%{"conversation_id" => id, "remote_actor_id" => remote_actor_id}
|
||||
) do
|
||||
review_remote_join_request(conn, id, remote_actor_id, :approve)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/remote-join-requests/decline
|
||||
Declines a pending remote join request.
|
||||
"""
|
||||
def decline_remote_join_request(
|
||||
conn,
|
||||
%{"conversation_id" => id, "remote_actor_id" => remote_actor_id}
|
||||
) do
|
||||
review_remote_join_request(conn, id, remote_actor_id, :decline)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:id/members
|
||||
Adds a member to a conversation.
|
||||
"""
|
||||
def add_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
with_valid_id(conn, target_user_id, fn parsed_target_user_id ->
|
||||
# Check if user has permission to add members
|
||||
case Messaging.get_conversation_member(conversation_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
member when member.role not in ["owner", "admin", "moderator"] ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You don't have permission to add members"})
|
||||
|
||||
_member ->
|
||||
role = "member"
|
||||
|
||||
case Messaging.add_member_to_conversation(
|
||||
conversation_id,
|
||||
parsed_target_user_id,
|
||||
role,
|
||||
user.id
|
||||
) do
|
||||
{:ok, new_member} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Member added",
|
||||
member: format_member(new_member)
|
||||
})
|
||||
|
||||
{:error, :user_not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "User not found"})
|
||||
|
||||
{:error, :already_member} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "User is already a member"})
|
||||
|
||||
{:error, :privacy_blocked} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Cannot add user due to privacy settings"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to add member: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
def add_member(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing required parameter: user_id"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/conversations/:id/members/:user_id
|
||||
Removes a member from a conversation.
|
||||
"""
|
||||
def remove_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn conversation_id ->
|
||||
with_valid_id(conn, target_user_id, fn target_id ->
|
||||
# Check if user has permission to remove members
|
||||
case Messaging.get_conversation_member(conversation_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Conversation not found"})
|
||||
|
||||
member
|
||||
when member.role not in ["owner", "admin", "moderator"] and user.id != target_id ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You don't have permission to remove members"})
|
||||
|
||||
_member ->
|
||||
case Messaging.remove_member_from_conversation(conversation_id, target_id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Member removed"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Member not found"})
|
||||
|
||||
{:error, :owner_cannot_leave} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Cannot remove the owner"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to remove member: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp review_remote_join_request(conn, conversation_id, remote_actor_id, decision)
|
||||
when decision in [:approve, :decline] do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with {:ok, parsed_conversation_id} <- parse_id(conversation_id),
|
||||
{:ok, parsed_remote_actor_id} <- parse_remote_actor_id(remote_actor_id),
|
||||
:ok <- manage_conversation_membership_permission(parsed_conversation_id, user.id) do
|
||||
review_result =
|
||||
case decision do
|
||||
:approve ->
|
||||
Messaging.approve_remote_join_request(
|
||||
parsed_conversation_id,
|
||||
parsed_remote_actor_id,
|
||||
user.id
|
||||
)
|
||||
|
||||
:decline ->
|
||||
Messaging.decline_remote_join_request(
|
||||
parsed_conversation_id,
|
||||
parsed_remote_actor_id,
|
||||
user.id
|
||||
)
|
||||
end
|
||||
|
||||
case review_result do
|
||||
{:ok, request} ->
|
||||
message =
|
||||
if decision == :approve do
|
||||
"Remote join request approved"
|
||||
else
|
||||
"Remote join request declined"
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: message, request: format_remote_join_request(request)})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Remote join request not found"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to review remote join request: #{inspect(reason)}"})
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
invalid_id_response(conn)
|
||||
|
||||
{:error, :invalid_remote_actor_id} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing or invalid remote_actor_id"})
|
||||
|
||||
{:error, status, error} ->
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{error: error})
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_remote_actor_id(remote_actor_id) when is_integer(remote_actor_id),
|
||||
do:
|
||||
if(remote_actor_id > 0,
|
||||
do: {:ok, remote_actor_id},
|
||||
else: {:error, :invalid_remote_actor_id}
|
||||
)
|
||||
|
||||
defp parse_remote_actor_id(remote_actor_id) when is_binary(remote_actor_id) do
|
||||
case Integer.parse(remote_actor_id) do
|
||||
{parsed_remote_actor_id, ""} when parsed_remote_actor_id > 0 ->
|
||||
{:ok, parsed_remote_actor_id}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_remote_actor_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_remote_actor_id(_remote_actor_id), do: {:error, :invalid_remote_actor_id}
|
||||
|
||||
defp manage_conversation_membership_permission(conversation_id, user_id) do
|
||||
case Messaging.get_conversation_member(conversation_id, user_id) do
|
||||
nil ->
|
||||
{:error, :not_found, "Conversation not found"}
|
||||
|
||||
member when member.role not in ["owner", "admin", "moderator"] ->
|
||||
{:error, :forbidden, "You don't have permission to manage members"}
|
||||
|
||||
_member ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp format_conversation(conversation, current_user_id) do
|
||||
# Get unread count if not already loaded
|
||||
unread_count =
|
||||
case Map.get(conversation, :unread_count) do
|
||||
nil -> Messaging.get_conversation_unread_count(conversation.id, current_user_id)
|
||||
count -> count
|
||||
end
|
||||
|
||||
%{
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
description: conversation.description,
|
||||
type: conversation.type,
|
||||
server_id: Map.get(conversation, :server_id),
|
||||
avatar_url: conversation.avatar_url,
|
||||
is_public: conversation.is_public,
|
||||
member_count: conversation.member_count,
|
||||
hash: conversation.hash,
|
||||
channel_topic: Map.get(conversation, :channel_topic),
|
||||
channel_position: Map.get(conversation, :channel_position, 0),
|
||||
creator_id: conversation.creator_id,
|
||||
last_message_at: conversation.last_message_at,
|
||||
unread_count: unread_count,
|
||||
last_message: format_last_message(conversation),
|
||||
inserted_at: conversation.inserted_at,
|
||||
updated_at: conversation.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_conversation_with_messages(conversation, current_user_id) do
|
||||
base = format_conversation(conversation, current_user_id)
|
||||
|
||||
members =
|
||||
case Map.get(conversation, :members) do
|
||||
nil -> []
|
||||
members -> Enum.map(members, &format_member/1)
|
||||
end
|
||||
|
||||
# Fetch messages from messages table
|
||||
messages =
|
||||
case Messaging.get_messages(conversation.id, current_user_id, limit: 50) do
|
||||
{:ok, msgs} -> Enum.map(msgs, &format_message/1)
|
||||
{:error, _} -> []
|
||||
end
|
||||
|
||||
conv_data = Map.put(base, :members, members)
|
||||
{conv_data, messages}
|
||||
end
|
||||
|
||||
defp format_last_message(%{last_message: nil}), do: nil
|
||||
defp format_last_message(%{last_message: %Ecto.Association.NotLoaded{}}), do: nil
|
||||
defp format_last_message(%{last_message: message}), do: format_message(message)
|
||||
defp format_last_message(_), do: nil
|
||||
|
||||
defp format_message(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
media_urls: attachment_urls_for_message(message),
|
||||
sender_id: message.sender_id,
|
||||
sender: format_user(message.sender),
|
||||
reply_to_id: message.reply_to_id,
|
||||
like_count: message.like_count || 0,
|
||||
reply_count: message.reply_count || 0,
|
||||
edited_at: message.edited_at,
|
||||
deleted_at: message.deleted_at,
|
||||
created_at: message.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp attachment_urls_for_message(message) do
|
||||
Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
|
||||
end
|
||||
|
||||
defp format_remote_join_request(request) when is_map(request) do
|
||||
%{
|
||||
remote_actor_id: request[:remote_actor_id] || request["remote_actor_id"],
|
||||
actor_uri: request[:actor_uri] || request["actor_uri"],
|
||||
origin_domain: request[:origin_domain] || request["origin_domain"],
|
||||
role: request[:role] || request["role"],
|
||||
state: request[:state] || request["state"],
|
||||
handle: request[:handle] || request["handle"],
|
||||
display_name: request[:display_name] || request["display_name"],
|
||||
display_label: request[:display_label] || request["display_label"],
|
||||
avatar_url: request[:avatar_url] || request["avatar_url"],
|
||||
requested_at: maybe_iso8601(request[:requested_at] || request["requested_at"]),
|
||||
updated_at: maybe_iso8601(request[:updated_at] || request["updated_at"]),
|
||||
metadata: request[:metadata] || request["metadata"] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp format_remote_join_request(_request), do: %{}
|
||||
|
||||
defp maybe_iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
|
||||
defp maybe_iso8601(value), do: value
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:conversation_id/upload
|
||||
Uploads a media file for a conversation.
|
||||
"""
|
||||
def upload_media(conn, %{"conversation_id" => conversation_id, "file" => upload}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, conversation_id, fn conv_id ->
|
||||
# Verify user is a member
|
||||
case Messaging.get_conversation_member(conv_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Not a member of this conversation"})
|
||||
|
||||
_member ->
|
||||
case Elektrine.Uploads.upload_chat_attachment(upload, user.id) do
|
||||
{:ok, metadata} ->
|
||||
url = Elektrine.Uploads.attachment_url(metadata.key, %{type: "dm"})
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
url: url,
|
||||
key: metadata.key,
|
||||
filename: metadata.filename,
|
||||
content_type: metadata.content_type,
|
||||
size: metadata.size
|
||||
})
|
||||
|
||||
{:error, {:file_too_large, msg}} ->
|
||||
conn
|
||||
|> put_status(:request_entity_too_large)
|
||||
|> json(%{error: msg})
|
||||
|
||||
{:error, {:invalid_file_type, msg}} ->
|
||||
conn
|
||||
|> put_status(:unsupported_media_type)
|
||||
|> json(%{error: msg})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Upload failed: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def upload_media(conn, %{"conversation_id" => _conversation_id}) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing file parameter"})
|
||||
end
|
||||
|
||||
defp format_member(member) do
|
||||
%{
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
role: member.role,
|
||||
joined_at: member.joined_at,
|
||||
last_read_at: member.last_read_at,
|
||||
user: format_user(member.user)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_user(nil), do: nil
|
||||
defp format_user(%Ecto.Association.NotLoaded{}), do: nil
|
||||
|
||||
defp format_user(user) do
|
||||
%{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_put(map, _key, nil), do: map
|
||||
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||
|
||||
defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
|
||||
case parse_id(value) do
|
||||
{:ok, id} -> fun.(id)
|
||||
:error -> invalid_id_response(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp invalid_id_response(conn) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid id"})
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(_value), do: :error
|
||||
|
||||
defp parse_int(nil, default), do: default
|
||||
defp parse_int(value, _default) when is_integer(value), do: value
|
||||
|
||||
defp parse_int(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} -> int
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
defmodule ArblargWeb.API.ExtChatController do
|
||||
@moduledoc """
|
||||
External API controller for chat access.
|
||||
"""
|
||||
|
||||
use ArblargWeb, :controller
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Elektrine.Accounts
|
||||
alias Elektrine.Messaging
|
||||
alias Elektrine.Messaging.{ChatConversation, ChatConversationMember}
|
||||
alias Elektrine.Messaging.ChatMessages
|
||||
alias Elektrine.Repo
|
||||
alias Elektrine.Social.Conversation
|
||||
alias Elektrine.Uploads
|
||||
alias ElektrineWeb.API.Response
|
||||
|
||||
@chat_types ~w(dm group channel)
|
||||
@default_limit 20
|
||||
@max_limit 100
|
||||
@default_message_limit 50
|
||||
|
||||
@doc """
|
||||
GET /api/ext/v1/chat/conversations
|
||||
"""
|
||||
def index(conn, params) do
|
||||
user = conn.assigns.current_user
|
||||
limit = parse_positive_int(params["limit"], @default_limit) |> min(@max_limit)
|
||||
|
||||
conversations = list_chat_conversations(user.id, limit)
|
||||
|
||||
Response.ok(
|
||||
conn,
|
||||
%{conversations: Enum.map(conversations, &format_conversation(&1, user.id))},
|
||||
%{pagination: %{limit: limit, total_count: length(conversations)}}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/ext/v1/chat/conversations/:id
|
||||
"""
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
with {:ok, conversation_id} <- parse_id(id),
|
||||
{:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do
|
||||
page =
|
||||
ChatMessages.get_conversation_messages(conversation.id, user.id,
|
||||
limit: @default_message_limit
|
||||
)
|
||||
|
||||
Response.ok(
|
||||
conn,
|
||||
%{
|
||||
conversation:
|
||||
format_conversation(conversation, user.id, latest_message(conversation, user.id)),
|
||||
messages: Enum.map(page.messages, &format_message/1)
|
||||
},
|
||||
%{pagination: pagination_meta(page, @default_message_limit)}
|
||||
)
|
||||
else
|
||||
{:error, :invalid_id} ->
|
||||
Response.error(conn, :bad_request, "invalid_id", "Invalid conversation id")
|
||||
|
||||
{:error, :not_found} ->
|
||||
Response.error(conn, :not_found, "not_found", "Conversation not found")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/ext/v1/chat/conversations/:id/messages
|
||||
"""
|
||||
def messages(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
limit = parse_positive_int(params["limit"], @default_message_limit) |> min(@max_limit)
|
||||
|
||||
with {:ok, conversation_id} <- parse_id(id),
|
||||
{:ok, before_id} <- parse_optional_id(params["before_id"]),
|
||||
{:ok, after_id} <- parse_optional_id(params["after_id"]),
|
||||
{:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do
|
||||
page =
|
||||
ChatMessages.get_conversation_messages(conversation.id, user.id,
|
||||
limit: limit,
|
||||
before_id: before_id,
|
||||
after_id: after_id
|
||||
)
|
||||
|
||||
Response.ok(
|
||||
conn,
|
||||
%{messages: Enum.map(page.messages, &format_message/1)},
|
||||
%{pagination: pagination_meta(page, limit)}
|
||||
)
|
||||
else
|
||||
{:error, :invalid_id} ->
|
||||
Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id")
|
||||
|
||||
{:error, :not_found} ->
|
||||
Response.error(conn, :not_found, "not_found", "Conversation not found")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/ext/v1/chat/conversations/:id/messages
|
||||
"""
|
||||
def create(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
source = Map.get(params, "message", params)
|
||||
message_type = source["message_type"] || "text"
|
||||
|
||||
with {:ok, conversation_id} <- parse_id(id),
|
||||
{:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id),
|
||||
{:ok, reply_to_id} <- parse_optional_id(source["reply_to_id"]) do
|
||||
result =
|
||||
case message_type do
|
||||
"text" ->
|
||||
opts = if is_nil(reply_to_id), do: [], else: [reply_to_id: reply_to_id]
|
||||
|
||||
Messaging.create_chat_text_message(
|
||||
conversation.id,
|
||||
user.id,
|
||||
source["content"] || "",
|
||||
opts
|
||||
)
|
||||
|
||||
"image" ->
|
||||
Messaging.create_chat_media_message(
|
||||
conversation.id,
|
||||
user.id,
|
||||
normalize_media_urls(source["media_urls"]),
|
||||
source["content"],
|
||||
source["media_metadata"] || %{}
|
||||
)
|
||||
|
||||
"file" ->
|
||||
Messaging.create_chat_media_message(
|
||||
conversation.id,
|
||||
user.id,
|
||||
normalize_media_urls(source["media_urls"]),
|
||||
source["content"],
|
||||
source["media_metadata"] || %{}
|
||||
)
|
||||
|
||||
"voice" ->
|
||||
Messaging.create_chat_voice_message(
|
||||
conversation.id,
|
||||
user.id,
|
||||
List.first(normalize_media_urls(source["media_urls"])),
|
||||
source["duration"] || 0,
|
||||
source["mime_type"] || "audio/webm"
|
||||
)
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_message_type}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, message} ->
|
||||
Response.created(conn, %{message: format_message(message)})
|
||||
|
||||
{:error, :empty_message} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"empty_message",
|
||||
"Message content cannot be empty"
|
||||
)
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
Response.error(conn, :too_many_requests, "rate_limited", "Message rate limit exceeded")
|
||||
|
||||
{:error, :timed_out} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"timed_out",
|
||||
"User is timed out from this conversation"
|
||||
)
|
||||
|
||||
{:error, :invalid_message_type} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:bad_request,
|
||||
"invalid_message_type",
|
||||
"Invalid message type. Use: text, image, file, or voice"
|
||||
)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"validation_failed",
|
||||
"Message failed validation",
|
||||
errors_on(changeset)
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"message_send_failed",
|
||||
"Failed to send message",
|
||||
inspect(reason)
|
||||
)
|
||||
end
|
||||
else
|
||||
{:error, :invalid_id} ->
|
||||
Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id")
|
||||
|
||||
{:error, :not_found} ->
|
||||
Response.error(conn, :not_found, "not_found", "Conversation not found")
|
||||
end
|
||||
end
|
||||
|
||||
defp list_chat_conversations(user_id, limit) do
|
||||
from(conversation in ChatConversation,
|
||||
join: membership in ChatConversationMember,
|
||||
on: membership.conversation_id == conversation.id and membership.user_id == ^user_id,
|
||||
where: is_nil(membership.left_at) and conversation.type in ^@chat_types,
|
||||
order_by: [
|
||||
desc: membership.pinned,
|
||||
desc: conversation.last_message_at,
|
||||
desc: conversation.updated_at
|
||||
],
|
||||
limit: ^limit,
|
||||
preload: [members: [user: [:profile]]]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.reject(&blocked_dm?(&1, user_id))
|
||||
end
|
||||
|
||||
defp fetch_chat_conversation(conversation_id, user_id) do
|
||||
case Messaging.get_conversation_for_chat!(conversation_id, user_id) do
|
||||
{:ok, %ChatConversation{type: type} = conversation} when type in @chat_types ->
|
||||
if blocked_dm?(conversation, user_id) do
|
||||
{:error, :not_found}
|
||||
else
|
||||
{:ok, conversation}
|
||||
end
|
||||
|
||||
{:ok, _conversation} ->
|
||||
{:error, :not_found}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp latest_message(conversation, user_id) do
|
||||
conversation.id
|
||||
|> ChatMessages.get_messages(user_id: user_id, limit: 1)
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
defp blocked_dm?(%ChatConversation{type: "dm", members: members}, user_id)
|
||||
when is_list(members) do
|
||||
case Enum.find(members, fn member ->
|
||||
member.user_id != user_id and is_nil(member.left_at)
|
||||
end) do
|
||||
%{user_id: other_user_id} ->
|
||||
Accounts.user_blocked?(user_id, other_user_id) or
|
||||
Accounts.user_blocked?(other_user_id, user_id)
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp blocked_dm?(_conversation, _user_id), do: false
|
||||
|
||||
defp format_conversation(conversation, current_user_id) do
|
||||
format_conversation(
|
||||
conversation,
|
||||
current_user_id,
|
||||
latest_message(conversation, current_user_id)
|
||||
)
|
||||
end
|
||||
|
||||
defp format_conversation(conversation, current_user_id, latest_message) do
|
||||
members =
|
||||
conversation.members
|
||||
|> List.wrap()
|
||||
|> Enum.filter(&is_nil(&1.left_at))
|
||||
|> Enum.map(&format_member/1)
|
||||
|
||||
%{
|
||||
id: conversation.id,
|
||||
type: conversation.type,
|
||||
name: Conversation.display_name(conversation, current_user_id),
|
||||
description: conversation.description,
|
||||
avatar_url: Conversation.avatar_url(conversation, current_user_id),
|
||||
is_public: conversation.is_public,
|
||||
member_count: length(members),
|
||||
last_message_at: conversation.last_message_at,
|
||||
latest_message: format_message_preview(latest_message),
|
||||
members: members
|
||||
}
|
||||
end
|
||||
|
||||
defp format_member(member) do
|
||||
%{
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
role: member.role,
|
||||
joined_at: member.joined_at,
|
||||
last_read_at: member.last_read_at,
|
||||
user: format_user(member.user)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_message_preview(nil), do: nil
|
||||
|
||||
defp format_message_preview(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
sender: format_user(message.sender),
|
||||
inserted_at: message.inserted_at,
|
||||
edited_at: message.edited_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_message(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
media_urls: attachment_urls_for_message(message),
|
||||
media_metadata: message.media_metadata || %{},
|
||||
conversation_id: message.conversation_id,
|
||||
sender_id: message.sender_id,
|
||||
sender: format_user(message.sender),
|
||||
reply_to_id: message.reply_to_id,
|
||||
reply_to: format_reply_to(message.reply_to),
|
||||
edited_at: message.edited_at,
|
||||
deleted_at: message.deleted_at,
|
||||
inserted_at: message.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp attachment_urls_for_message(message) do
|
||||
Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
|
||||
end
|
||||
|
||||
defp format_reply_to(nil), do: nil
|
||||
defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil
|
||||
|
||||
defp format_reply_to(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
sender: format_user(message.sender)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_user(nil), do: nil
|
||||
defp format_user(%Ecto.Association.NotLoaded{}), do: nil
|
||||
|
||||
defp format_user(user) do
|
||||
%{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
handle: user.handle,
|
||||
display_name: user.display_name,
|
||||
avatar: user.avatar
|
||||
}
|
||||
end
|
||||
|
||||
defp pagination_meta(page, limit) do
|
||||
%{
|
||||
limit: limit,
|
||||
has_more_older: page.has_more_older,
|
||||
has_more_newer: page.has_more_newer,
|
||||
oldest_id: page.oldest_id,
|
||||
newest_id: page.newest_id
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> {:ok, int}
|
||||
_ -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(_value), do: {:error, :invalid_id}
|
||||
|
||||
defp parse_optional_id(nil), do: {:ok, nil}
|
||||
defp parse_optional_id(""), do: {:ok, nil}
|
||||
|
||||
defp parse_optional_id(value) do
|
||||
parse_id(value)
|
||||
end
|
||||
|
||||
defp parse_positive_int(value, _default) when is_integer(value) and value > 0, do: value
|
||||
|
||||
defp parse_positive_int(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> int
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_positive_int(_value, default), do: default
|
||||
|
||||
defp normalize_media_urls(urls) when is_list(urls), do: urls
|
||||
defp normalize_media_urls(_urls), do: []
|
||||
|
||||
defp errors_on(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||
interpolate_error(message, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
defp interpolate_error(message, opts) do
|
||||
Enum.reduce(opts, message, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
defmodule ArblargWeb.API.MessageController do
|
||||
@moduledoc """
|
||||
API controller for chat messages.
|
||||
|
||||
Uses the new ChatMessages module for DM/group/channel messages,
|
||||
separate from timeline posts which remain in the Messages module.
|
||||
"""
|
||||
use ArblargWeb, :controller
|
||||
|
||||
alias Elektrine.Messaging
|
||||
alias Elektrine.Uploads
|
||||
|
||||
action_fallback ElektrineWeb.FallbackController
|
||||
|
||||
@doc """
|
||||
GET /api/conversations/:conversation_id/messages
|
||||
Lists messages for a conversation with pagination.
|
||||
|
||||
Query params:
|
||||
- before_id: Get messages before this ID (for loading older)
|
||||
- after_id: Get messages after this ID (for loading newer)
|
||||
- limit: Number of messages to return (default 50, max 100)
|
||||
"""
|
||||
def index(conn, %{"conversation_id" => conversation_id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, conversation_id, fn conv_id ->
|
||||
# Verify user is a member
|
||||
case Messaging.get_conversation_member(conv_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Not a member of this conversation"})
|
||||
|
||||
_member ->
|
||||
opts =
|
||||
[
|
||||
limit: min(parse_int(params["limit"], 50), 100),
|
||||
before_id: parse_int(params["before_id"], nil)
|
||||
]
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|
||||
case Messaging.get_messages(conv_id, user.id, opts) do
|
||||
{:ok, messages} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
messages: Enum.map(messages, &format_message/1),
|
||||
has_more: length(messages) >= Keyword.get(opts, :limit, 50)
|
||||
})
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Unable to load messages"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:conversation_id/messages
|
||||
Sends a new message in a conversation.
|
||||
|
||||
Params:
|
||||
- content: Message text (required)
|
||||
- reply_to_id: ID of message being replied to (optional)
|
||||
- message_type: "text" | "image" | "file" (default: "text")
|
||||
- media_urls: Array of media URLs (for image/file messages)
|
||||
"""
|
||||
def create(conn, %{"conversation_id" => conversation_id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, conversation_id, fn conv_id ->
|
||||
# Verify user is a member
|
||||
case Messaging.get_conversation_member(conv_id, user.id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Not a member of this conversation"})
|
||||
|
||||
member when member.role == "readonly" ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You don't have permission to send messages"})
|
||||
|
||||
_member ->
|
||||
content = params["content"] || ""
|
||||
reply_to_id = parse_int(params["reply_to_id"], nil)
|
||||
message_type = params["message_type"] || "text"
|
||||
|
||||
result =
|
||||
case message_type do
|
||||
"text" ->
|
||||
opts = if reply_to_id, do: [reply_to_id: reply_to_id], else: []
|
||||
Messaging.create_chat_text_message(conv_id, user.id, content, opts)
|
||||
|
||||
"image" ->
|
||||
media_urls = params["media_urls"] || []
|
||||
media_metadata = params["media_metadata"] || %{}
|
||||
|
||||
Messaging.create_chat_media_message(
|
||||
conv_id,
|
||||
user.id,
|
||||
media_urls,
|
||||
content,
|
||||
media_metadata
|
||||
)
|
||||
|
||||
"file" ->
|
||||
media_urls = params["media_urls"] || []
|
||||
media_metadata = params["media_metadata"] || %{}
|
||||
|
||||
Messaging.create_chat_media_message(
|
||||
conv_id,
|
||||
user.id,
|
||||
media_urls,
|
||||
content,
|
||||
media_metadata
|
||||
)
|
||||
|
||||
"voice" ->
|
||||
audio_url = List.first(params["media_urls"] || [])
|
||||
duration = params["duration"] || 0
|
||||
mime_type = params["mime_type"] || "audio/webm"
|
||||
|
||||
Messaging.create_chat_voice_message(
|
||||
conv_id,
|
||||
user.id,
|
||||
audio_url,
|
||||
duration,
|
||||
mime_type
|
||||
)
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_message_type}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, message} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: format_message(message)
|
||||
})
|
||||
|
||||
{:error, :empty_message} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Message content cannot be empty"})
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "You're sending messages too quickly. Please slow down."})
|
||||
|
||||
{:error, :timed_out} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You are currently timed out from this conversation"})
|
||||
|
||||
{:error, :invalid_message_type} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid message type. Use: text, image, file, or voice"})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to send message: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
PUT /api/messages/:id
|
||||
Edits a message.
|
||||
|
||||
Params:
|
||||
- content: New message content (required)
|
||||
"""
|
||||
def update(conn, %{"id" => id, "content" => new_content}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn message_id ->
|
||||
case Messaging.edit_chat_message(message_id, user.id, new_content) do
|
||||
{:ok, message} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
message: format_message(message)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Message not found"})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You cannot edit this message"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to edit message: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => _id}) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing required parameter: content"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/messages/:id
|
||||
Deletes a message (soft delete).
|
||||
"""
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn message_id ->
|
||||
# Check if user is admin (for admin delete)
|
||||
is_admin = user.admin == true
|
||||
|
||||
case Messaging.delete_chat_message(message_id, user.id, is_admin) do
|
||||
{:ok, _message} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Message deleted"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Message not found"})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You can only delete your own messages"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to delete message: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/messages/:id/reactions
|
||||
Adds a reaction to a message.
|
||||
|
||||
Params:
|
||||
- emoji: The emoji to react with (required)
|
||||
"""
|
||||
def add_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn message_id ->
|
||||
case Messaging.add_chat_reaction(message_id, user.id, emoji) do
|
||||
{:ok, reaction} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Reaction added",
|
||||
reaction: format_reaction(reaction)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Message not found"})
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "You're adding reactions too quickly"})
|
||||
|
||||
{:error, :already_exists} ->
|
||||
# Toggle behavior - remove the reaction
|
||||
case Messaging.remove_chat_reaction(message_id, user.id, emoji) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Reaction removed"})
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to toggle reaction"})
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to add reaction: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def add_reaction(conn, %{"message_id" => _id}) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing required parameter: emoji"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/messages/:id/reactions/:emoji
|
||||
Removes a reaction from a message.
|
||||
"""
|
||||
def remove_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn message_id ->
|
||||
# URL decode emoji (emojis may be encoded)
|
||||
decoded_emoji = URI.decode(emoji)
|
||||
|
||||
case Messaging.remove_chat_reaction(message_id, user.id, decoded_emoji) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{message: "Reaction removed"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Reaction not found"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to remove reaction: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
|
||||
case parse_id(value) do
|
||||
{:ok, id} -> fun.(id)
|
||||
:error -> invalid_id_response(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp invalid_id_response(conn) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid id"})
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(_value), do: :error
|
||||
|
||||
defp format_message(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
media_urls: attachment_urls_for_message(message),
|
||||
media_metadata: message.media_metadata || %{},
|
||||
conversation_id: message.conversation_id,
|
||||
sender_id: message.sender_id,
|
||||
sender: format_user(message.sender),
|
||||
webhook_id: Map.get(message, :webhook_id),
|
||||
reply_to_id: message.reply_to_id,
|
||||
reply_to: format_reply_to(message.reply_to),
|
||||
like_count: Map.get(message, :like_count, 0) || 0,
|
||||
reply_count: Map.get(message, :reply_count, 0) || 0,
|
||||
reactions: format_reactions(message.reactions),
|
||||
edited_at: message.edited_at,
|
||||
deleted_at: message.deleted_at,
|
||||
created_at: message.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp attachment_urls_for_message(message) do
|
||||
Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
|
||||
end
|
||||
|
||||
defp format_reply_to(nil), do: nil
|
||||
defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil
|
||||
|
||||
defp format_reply_to(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
sender_id: message.sender_id,
|
||||
sender: format_user(message.sender)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_reactions(nil), do: []
|
||||
defp format_reactions(%Ecto.Association.NotLoaded{}), do: []
|
||||
|
||||
defp format_reactions(reactions) when is_list(reactions) do
|
||||
# Return individual reactions for the iOS client
|
||||
Enum.map(reactions, fn reaction ->
|
||||
# Handle both ChatMessageReaction (chat_message_id) and MessageReaction (message_id)
|
||||
message_id =
|
||||
cond do
|
||||
Map.has_key?(reaction, :chat_message_id) -> reaction.chat_message_id
|
||||
Map.has_key?(reaction, :message_id) -> reaction.message_id
|
||||
true -> nil
|
||||
end
|
||||
|
||||
%{
|
||||
id: reaction.id,
|
||||
emoji: reaction.emoji,
|
||||
user_id: reaction.user_id,
|
||||
message_id: message_id
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_reaction(reaction) do
|
||||
%{
|
||||
id: reaction.id,
|
||||
message_id: reaction.message_id,
|
||||
user_id: reaction.user_id,
|
||||
emoji: reaction.emoji,
|
||||
created_at: reaction.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_user(nil), do: nil
|
||||
defp format_user(%Ecto.Association.NotLoaded{}), do: nil
|
||||
|
||||
defp format_user(user) do
|
||||
%{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
is_bot: Map.get(user, :is_bot, false) == true,
|
||||
webhook: Map.get(user, :webhook, false) == true
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_int(nil, default), do: default
|
||||
defp parse_int(value, _default) when is_integer(value), do: value
|
||||
|
||||
defp parse_int(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} -> int
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
defmodule ArblargWeb.API.ServerController do
|
||||
@moduledoc """
|
||||
API controller for community servers and server channels.
|
||||
"""
|
||||
use ArblargWeb, :controller
|
||||
|
||||
alias Elektrine.Messaging
|
||||
|
||||
action_fallback ElektrineWeb.FallbackController
|
||||
|
||||
@doc """
|
||||
GET /api/servers
|
||||
Lists all servers for the current user. Supports `scope=discover` (or `discover=true`) for joinable public server discovery.
|
||||
"""
|
||||
def index(conn, params) do
|
||||
user = conn.assigns[:current_user]
|
||||
limit = parse_int(params["limit"], 50)
|
||||
query = params["query"]
|
||||
|
||||
servers =
|
||||
if discover_scope?(params) do
|
||||
Messaging.list_public_servers(user.id, limit: limit, query: query)
|
||||
else
|
||||
Messaging.list_servers(user.id, limit: limit)
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{servers: Enum.map(servers, &format_server_summary(&1, user.id))})
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/servers/:id
|
||||
Shows a server with its channels.
|
||||
"""
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn server_id ->
|
||||
case Messaging.get_server(server_id, user.id) do
|
||||
{:ok, server} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
server: format_server_summary(server, user.id),
|
||||
channels: Enum.map(server.channels, &format_channel/1)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Server not found"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/servers
|
||||
Creates a new server and a default #general channel.
|
||||
"""
|
||||
def create(conn, params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
attrs = %{
|
||||
name: params["name"],
|
||||
description: params["description"],
|
||||
icon_url: params["icon_url"],
|
||||
is_public: parse_bool(params["is_public"], false)
|
||||
}
|
||||
|
||||
case Messaging.create_server(user.id, attrs) do
|
||||
{:ok, server} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Server created",
|
||||
server: format_server_summary(server, user.id),
|
||||
channels: Enum.map(server.channels, &format_channel/1)
|
||||
})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
|
||||
{:error, :already_member} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to initialize server membership"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create server: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/servers/:server_id/join
|
||||
Joins a public server.
|
||||
"""
|
||||
def join(conn, %{"server_id" => server_id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, server_id, fn parsed_server_id ->
|
||||
case Messaging.join_server(parsed_server_id, user.id) do
|
||||
{:ok, _member} ->
|
||||
case Messaging.get_server(parsed_server_id, user.id) do
|
||||
{:ok, server} ->
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
message: "Joined server",
|
||||
server: format_server_summary(server, user.id),
|
||||
channels: Enum.map(server.channels, &format_channel/1)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Server not found"})
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Server not found"})
|
||||
|
||||
{:error, :not_public} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "This server is private"})
|
||||
|
||||
{:error, :already_member} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Already a member of this server"})
|
||||
|
||||
{:error, :federation_sync_unavailable} ->
|
||||
conn
|
||||
|> put_status(:service_unavailable)
|
||||
|> json(%{error: "This federated server is temporarily unavailable to join"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to join server: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/servers/:server_id/channels
|
||||
Creates a new channel in the server.
|
||||
"""
|
||||
def create_channel(conn, %{"server_id" => server_id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
attrs = %{
|
||||
name: params["name"],
|
||||
description: params["description"],
|
||||
channel_topic: params["channel_topic"],
|
||||
channel_position: parse_int(params["channel_position"], nil)
|
||||
}
|
||||
|
||||
with_valid_id(conn, server_id, fn parsed_server_id ->
|
||||
case Messaging.create_server_channel(parsed_server_id, user.id, attrs) do
|
||||
{:ok, channel} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
message: "Channel created",
|
||||
channel: format_channel(channel)
|
||||
})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Server not found"})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You don't have permission to create channels in this server"})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create channel: #{inspect(reason)}"})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
|
||||
case parse_id(value) do
|
||||
{:ok, id} -> fun.(id)
|
||||
:error -> invalid_id_response(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp invalid_id_response(conn) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid id"})
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(_value), do: :error
|
||||
|
||||
defp format_server_summary(server, current_user_id) do
|
||||
role =
|
||||
case Messaging.get_server_member(server.id, current_user_id) do
|
||||
nil -> nil
|
||||
member -> member.role
|
||||
end
|
||||
|
||||
%{
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
icon_url: server.icon_url,
|
||||
is_public: server.is_public,
|
||||
member_count: server.member_count,
|
||||
creator_id: server.creator_id,
|
||||
origin_domain: server.origin_domain,
|
||||
federation_id: server.federation_id,
|
||||
is_federated_mirror: server.is_federated_mirror,
|
||||
role: role,
|
||||
inserted_at: server.inserted_at,
|
||||
updated_at: server.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_channel(channel) do
|
||||
%{
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
type: channel.type,
|
||||
channel_topic: channel.channel_topic,
|
||||
channel_position: channel.channel_position,
|
||||
server_id: channel.server_id,
|
||||
inserted_at: channel.inserted_at,
|
||||
updated_at: channel.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_bool(nil, default), do: default
|
||||
defp parse_bool(value, _default) when is_boolean(value), do: value
|
||||
|
||||
defp parse_bool(value, default) when is_binary(value) do
|
||||
case String.downcase(value) do
|
||||
"true" -> true
|
||||
"1" -> true
|
||||
"false" -> false
|
||||
"0" -> false
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_int(nil, default), do: default
|
||||
defp parse_int(value, _default) when is_integer(value), do: value
|
||||
|
||||
defp parse_int(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} -> int
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp discover_scope?(params) do
|
||||
scope =
|
||||
case params["scope"] do
|
||||
value when is_binary(value) -> value |> String.trim() |> String.downcase()
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
parse_bool(params["discover"], false) or scope in ["discover", "public"]
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
defmodule ArblargWeb.API.WebhookController do
|
||||
@moduledoc """
|
||||
API controller for incoming chat webhooks.
|
||||
|
||||
Management endpoints (list/create/update/rotate/deactivate/delete) run on
|
||||
the authenticated API surface and require the `manage_webhooks` permission
|
||||
on the target conversation.
|
||||
|
||||
The execute endpoint (`POST /api/webhooks/:id/:token`) is unauthenticated:
|
||||
the webhook token is the credential. Unknown ids and bad tokens both
|
||||
return 404 to avoid oracle behavior.
|
||||
"""
|
||||
use ArblargWeb, :controller
|
||||
|
||||
alias Elektrine.Messaging
|
||||
|
||||
action_fallback ElektrineWeb.FallbackController
|
||||
|
||||
@doc """
|
||||
GET /api/conversations/:conversation_id/webhooks
|
||||
Lists the webhooks of a conversation.
|
||||
"""
|
||||
def index(conn, %{"conversation_id" => conversation_id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, conversation_id, fn conv_id ->
|
||||
case Messaging.list_chat_webhooks(conv_id, user.id) do
|
||||
{:ok, webhooks} ->
|
||||
json(conn, %{webhooks: Enum.map(webhooks, &format_webhook/1)})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/conversations/:conversation_id/webhooks
|
||||
Creates a webhook. The response includes the plaintext token exactly once.
|
||||
"""
|
||||
def create(conn, %{"conversation_id" => conversation_id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, conversation_id, fn conv_id ->
|
||||
attrs = %{
|
||||
"name" => params["name"],
|
||||
"avatar_url" => params["avatar_url"]
|
||||
}
|
||||
|
||||
case Messaging.create_chat_webhook(conv_id, user.id, attrs) do
|
||||
{:ok, webhook} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{webhook: format_webhook(webhook)})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
PUT /api/webhooks/:id
|
||||
Renames a webhook and/or updates its avatar.
|
||||
"""
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn webhook_id ->
|
||||
case Messaging.update_chat_webhook(webhook_id, user.id, params) do
|
||||
{:ok, webhook} ->
|
||||
json(conn, %{webhook: format_webhook(webhook)})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/webhooks/:id/rotate
|
||||
Rotates a webhook token. The response includes the new plaintext token
|
||||
exactly once.
|
||||
"""
|
||||
def rotate(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn webhook_id ->
|
||||
case Messaging.rotate_chat_webhook_token(webhook_id, user.id) do
|
||||
{:ok, webhook} ->
|
||||
json(conn, %{webhook: format_webhook(webhook)})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/webhooks/:id/deactivate
|
||||
Deactivates a webhook without deleting it.
|
||||
"""
|
||||
def deactivate(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn webhook_id ->
|
||||
case Messaging.deactivate_chat_webhook(webhook_id, user.id) do
|
||||
{:ok, webhook} ->
|
||||
json(conn, %{webhook: format_webhook(webhook)})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/webhooks/:id
|
||||
Deletes a webhook.
|
||||
"""
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
with_valid_id(conn, id, fn webhook_id ->
|
||||
case Messaging.delete_chat_webhook(webhook_id, user.id) do
|
||||
{:ok, _webhook} ->
|
||||
json(conn, %{success: true})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/webhooks/:id/:token
|
||||
Executes a webhook: posts a message into the webhook's conversation.
|
||||
|
||||
No session or API token required -- the webhook token is the credential.
|
||||
Body: {"content": "...", "username": optional, "avatar_url": optional}.
|
||||
"""
|
||||
def execute(conn, %{"id" => id, "token" => token} = params) do
|
||||
case Messaging.execute_chat_webhook(id, token, params) do
|
||||
{:ok, message} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{id: message.id, conversation_id: message.conversation_id})
|
||||
|
||||
{:error, reason} ->
|
||||
handle_error(conn, reason)
|
||||
end
|
||||
end
|
||||
|
||||
# Error mapping
|
||||
|
||||
defp handle_error(conn, :not_found) do
|
||||
conn |> put_status(:not_found) |> json(%{error: "Not found"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :unauthorized) do
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "You do not have permission to manage webhooks here"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :unsupported_conversation) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Webhooks are only supported in channels and groups"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :webhook_limit_reached) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Webhook limit reached for this conversation"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :webhook_inactive) do
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Webhook is inactive"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :rate_limited) do
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "Rate limit exceeded. Please slow down."})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :invalid_content) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Content is required and must be at most 4000 characters"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, :invalid_override) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Invalid username or avatar_url override"})
|
||||
end
|
||||
|
||||
defp handle_error(conn, %Ecto.Changeset{} = changeset) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Validation failed", errors: format_errors(changeset)})
|
||||
end
|
||||
|
||||
defp handle_error(conn, _reason) do
|
||||
conn |> put_status(:internal_server_error) |> json(%{error: "Something went wrong"})
|
||||
end
|
||||
|
||||
# Serialization
|
||||
|
||||
defp format_webhook(webhook) do
|
||||
base = %{
|
||||
id: webhook.id,
|
||||
conversation_id: webhook.conversation_id,
|
||||
name: webhook.name,
|
||||
avatar_url: webhook.avatar_url,
|
||||
active: webhook.active,
|
||||
creator_id: webhook.creator_id,
|
||||
created_at: webhook.inserted_at
|
||||
}
|
||||
|
||||
# Plaintext token is only present right after create/rotate.
|
||||
case webhook.token do
|
||||
nil -> base
|
||||
token -> Map.put(base, :token, token)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp with_valid_id(conn, value, fun) do
|
||||
case parse_id(value) do
|
||||
{:ok, id} -> fun.(id)
|
||||
:error -> conn |> put_status(:bad_request) |> json(%{error: "Invalid id"})
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int > 0 -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(_value), do: :error
|
||||
end
|
||||
104
apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex
Normal file
104
apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule ArblargWeb.ChatLive.Bootstrap do
|
||||
@moduledoc false
|
||||
|
||||
alias ArblargWeb.ChatLive.Operations.Helpers
|
||||
alias ArblargWeb.ChatLive.State
|
||||
|
||||
def initialize_socket(socket, opts) do
|
||||
cached_conversations = Keyword.fetch!(opts, :cached_conversations)
|
||||
cached_unread = Keyword.fetch!(opts, :cached_unread)
|
||||
cached_servers = Keyword.fetch!(opts, :cached_servers)
|
||||
chat_attachment_limit = Keyword.fetch!(opts, :chat_attachment_limit)
|
||||
user_token = Keyword.fetch!(opts, :user_token)
|
||||
custom_emojis = Keyword.fetch!(opts, :custom_emojis)
|
||||
federation_preview = Keyword.fetch!(opts, :federation_preview)
|
||||
loading_conversations = Keyword.fetch!(opts, :loading_conversations)
|
||||
|
||||
filtered_cached_conversations =
|
||||
Helpers.scope_conversations_to_server(cached_conversations, nil)
|
||||
|
||||
socket
|
||||
|> Phoenix.Component.assign(:page_title, "Chat")
|
||||
|> Phoenix.Component.assign(:ui, %State.UI{})
|
||||
|> Phoenix.Component.assign(:search, %State.Search{user_results: []})
|
||||
|> Phoenix.Component.assign(:call, %State.Call{})
|
||||
|> Phoenix.Component.assign(:form, %State.Form{})
|
||||
|> Phoenix.Component.assign(:context_menu, %State.ContextMenu{})
|
||||
|> Phoenix.Component.assign(:message, %State.Message{})
|
||||
|> Phoenix.Component.assign(
|
||||
:conversation,
|
||||
%State.Conversation{
|
||||
list: cached_conversations,
|
||||
selected: nil,
|
||||
filtered: filtered_cached_conversations,
|
||||
last_message_read_status: %{},
|
||||
unread_count: cached_unread,
|
||||
unread_counts: %{}
|
||||
}
|
||||
)
|
||||
|> Phoenix.Component.assign(:joined_servers, cached_servers)
|
||||
|> Phoenix.Component.assign(:active_server_id, nil)
|
||||
|> Phoenix.Component.assign(:server_categories, [])
|
||||
|> Phoenix.Component.assign(:collapsed_category_ids, MapSet.new())
|
||||
|> Phoenix.Component.assign(:pinned_messages, [])
|
||||
|> Phoenix.Component.assign(:threads, %State.Threads{})
|
||||
|> Phoenix.Component.assign(:voice, %State.Voice{})
|
||||
|> Phoenix.Component.assign(:moderation, %State.Moderation{})
|
||||
|> Phoenix.Component.assign(:browse, %State.Browse{})
|
||||
|> Phoenix.Component.assign(:profile, %State.Profile{})
|
||||
|> Phoenix.Component.assign(:messages, [])
|
||||
|> Phoenix.Component.assign(:uploaded_files, [])
|
||||
|> Phoenix.Component.assign(:can_send_messages, true)
|
||||
|> Phoenix.LiveView.allow_upload(:chat_attachments,
|
||||
accept:
|
||||
~w(.jpg .jpeg .png .gif .webp .heic .heif .avif .pdf .doc .docx .xls .xlsx .txt .mp3 .wav .webm),
|
||||
max_entries: 5,
|
||||
max_file_size: chat_attachment_limit
|
||||
)
|
||||
|> Phoenix.LiveView.allow_upload(:server_icon_upload,
|
||||
accept: ~w(.jpg .jpeg .png .gif .webp),
|
||||
max_entries: 1,
|
||||
max_file_size: 5 * 1024 * 1024
|
||||
)
|
||||
|> Phoenix.LiveView.allow_upload(:group_avatar_upload,
|
||||
accept: ~w(.jpg .jpeg .png .gif .webp),
|
||||
max_entries: 1,
|
||||
max_file_size: 5 * 1024 * 1024
|
||||
)
|
||||
|> Phoenix.LiveView.allow_upload(:channel_avatar_upload,
|
||||
accept: ~w(.jpg .jpeg .png .gif .webp),
|
||||
max_entries: 1,
|
||||
max_file_size: 5 * 1024 * 1024
|
||||
)
|
||||
|> Phoenix.Component.assign(:user_token, user_token)
|
||||
|> Phoenix.Component.assign(:show_mobile_search, false)
|
||||
|> Phoenix.Component.assign(:show_report_modal, false)
|
||||
|> Phoenix.Component.assign(:report_type, nil)
|
||||
|> Phoenix.Component.assign(:report_id, nil)
|
||||
|> Phoenix.Component.assign(:report_metadata, %{})
|
||||
|> Phoenix.Component.assign(:has_more_older_messages, false)
|
||||
|> Phoenix.Component.assign(:has_more_newer_messages, false)
|
||||
|> Phoenix.Component.assign(:oldest_message_id, nil)
|
||||
|> Phoenix.Component.assign(:processed_call_events, MapSet.new())
|
||||
|> Phoenix.Component.assign(:newest_message_id, nil)
|
||||
|> Phoenix.Component.assign(:loading_older_messages, false)
|
||||
|> Phoenix.Component.assign(:loading_newer_messages, false)
|
||||
|> Phoenix.Component.assign(:initial_messages_loading, false)
|
||||
|> Phoenix.Component.assign(:first_unread_message_id, nil)
|
||||
|> Phoenix.Component.assign(:show_image_modal, false)
|
||||
|> Phoenix.Component.assign(:modal_image_url, nil)
|
||||
|> Phoenix.Component.assign(:modal_images, [])
|
||||
|> Phoenix.Component.assign(:modal_image_index, 0)
|
||||
|> Phoenix.Component.assign(:modal_post, nil)
|
||||
|> Phoenix.Component.assign(:public_server_search_results, [])
|
||||
|> Phoenix.Component.assign(:public_group_search_results, [])
|
||||
|> Phoenix.Component.assign(:custom_emojis, custom_emojis)
|
||||
|> Phoenix.Component.assign(:federation_preview, federation_preview)
|
||||
|> Phoenix.Component.assign(:federation_presence, %{})
|
||||
|> Phoenix.Component.assign(:chat_e2ee_devices, [])
|
||||
|> Phoenix.Component.assign(:room_presence_timer_ref, nil)
|
||||
|> Phoenix.Component.assign(:pending_remote_join_requests, [])
|
||||
|> Phoenix.Component.assign(:loading_conversations, loading_conversations)
|
||||
|> Phoenix.Component.assign(:refresh_conversations_scheduled, false)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
defmodule ArblargWeb.ChatLive.Components.ChannelModal do
|
||||
@moduledoc false
|
||||
use ArblargWeb, :live_component
|
||||
|
||||
attr :uploads, :map, default: %{}
|
||||
attr :categories, :list, default: []
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="modal modal-open">
|
||||
<div
|
||||
class="modal-box modal-surface p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto"
|
||||
phx-click-away="close_modal"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold">Create Server Channel</h2>
|
||||
<button phx-click="close_modal" phx-target={@myself} class="btn btn-ghost btn-sm btn-circle">
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form phx-submit="create_channel" phx-target={@myself} class="space-y-4">
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Channel Type</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 rounded-lg border border-base-300 px-3 py-2 cursor-pointer hover:bg-base-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel[channel_type]"
|
||||
value="channel"
|
||||
class="radio radio-secondary radio-sm"
|
||||
checked
|
||||
/>
|
||||
<.icon name="hero-hashtag" class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Text</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-lg border border-base-300 px-3 py-2 cursor-pointer hover:bg-base-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel[channel_type]"
|
||||
value="voice_channel"
|
||||
class="radio radio-secondary radio-sm"
|
||||
/>
|
||||
<.icon name="hero-speaker-wave" class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Voice</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Voice channels are live audio rooms members can join. They stay on this server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Channel Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="channel[name]"
|
||||
placeholder="Server channel name"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="channel[description]"
|
||||
placeholder="Channel description (optional)"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<%= if @uploads[:channel_avatar_upload] do %>
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Channel Image</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= if @uploads.channel_avatar_upload.entries != [] do %>
|
||||
<% entry = List.first(@uploads.channel_avatar_upload.entries) %>
|
||||
<div class="w-14 h-14 rounded-xl overflow-hidden bg-base-200 border border-base-300">
|
||||
<.live_img_preview entry={entry} class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-14 h-14 rounded-xl bg-base-200 border border-dashed border-base-300 flex items-center justify-center">
|
||||
<.icon name="hero-photo" class="w-6 h-6 text-base-content/60" />
|
||||
</div>
|
||||
<% end %>
|
||||
<label class="btn btn-ghost btn-sm">
|
||||
Choose Image
|
||||
<.live_file_input
|
||||
upload={@uploads.channel_avatar_upload}
|
||||
class="hidden"
|
||||
phx-change="validate_upload"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<%= for entry <- @uploads.channel_avatar_upload.entries do %>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs">
|
||||
<span class="truncate flex-1">{entry.client_name}</span>
|
||||
<progress
|
||||
class="progress progress-secondary w-28 h-2"
|
||||
value={entry.progress}
|
||||
max="100"
|
||||
>
|
||||
</progress>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-target="#chat-container"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload_name="channel_avatar_upload"
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
title="Remove image"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @categories != [] do %>
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Category</span>
|
||||
</label>
|
||||
<select name="channel[category_id]" class="select select-bordered w-full">
|
||||
<option value="">No category</option>
|
||||
<%= for category <- @categories do %>
|
||||
<option value={category.id}>{category.name}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Topic</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="channel[channel_topic]"
|
||||
placeholder="Routing topic (optional)"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Private Channel</span>
|
||||
<input type="hidden" name="channel[is_private]" value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
name="channel[is_private]"
|
||||
value="true"
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Private channels are restricted. Public channels are visible to all server members.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="btn btn-secondary flex-1">
|
||||
<.icon name="hero-megaphone" class="w-4 h-4 mr-2" /> Create Channel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("close_modal", _params, socket) do
|
||||
send(self(), {:close_channel_modal})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("create_channel", %{"channel" => channel_params}, socket) do
|
||||
send(self(), {:create_channel, channel_params})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
defmodule ArblargWeb.ChatLive.Components.GroupModal do
|
||||
@moduledoc false
|
||||
use ArblargWeb, :live_component
|
||||
import Elektrine.Components.User.Avatar
|
||||
alias ArblargWeb.ChatLive.HandleFormatter
|
||||
|
||||
attr :group_name, :string, default: ""
|
||||
attr :group_description, :string, default: ""
|
||||
attr :group_is_public, :boolean, default: false
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
attr :selected_users, :list, default: []
|
||||
attr :uploads, :map, default: %{}
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="modal modal-open">
|
||||
<div
|
||||
class="modal-box modal-surface p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto"
|
||||
phx-click-away="close_modal"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold">Create Group Chat</h2>
|
||||
<button phx-click="close_modal" phx-target={@myself} class="btn btn-ghost btn-sm btn-circle">
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form phx-submit="create_group" phx-target={@myself} class="space-y-4">
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Group Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="group[name]"
|
||||
value={@group_name}
|
||||
placeholder="Group chat name"
|
||||
class="input input-bordered w-full"
|
||||
phx-change="update_group_form"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="group[description]"
|
||||
placeholder="Group description (optional)"
|
||||
class="textarea textarea-bordered w-full"
|
||||
phx-change="update_group_form"
|
||||
rows="2"
|
||||
>{@group_description}</textarea>
|
||||
</div>
|
||||
|
||||
<%= if @uploads[:group_avatar_upload] do %>
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Group Image</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= if @uploads.group_avatar_upload.entries != [] do %>
|
||||
<% entry = List.first(@uploads.group_avatar_upload.entries) %>
|
||||
<div class="w-14 h-14 rounded-xl overflow-hidden bg-base-200 border border-base-300">
|
||||
<.live_img_preview entry={entry} class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-14 h-14 rounded-xl bg-base-200 border border-dashed border-base-300 flex items-center justify-center">
|
||||
<.icon name="hero-photo" class="w-6 h-6 text-base-content/60" />
|
||||
</div>
|
||||
<% end %>
|
||||
<label class="btn btn-ghost btn-sm">
|
||||
Choose Image
|
||||
<.live_file_input
|
||||
upload={@uploads.group_avatar_upload}
|
||||
class="hidden"
|
||||
phx-change="validate_upload"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<%= for entry <- @uploads.group_avatar_upload.entries do %>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs">
|
||||
<span class="truncate flex-1">{entry.client_name}</span>
|
||||
<progress
|
||||
class="progress progress-secondary w-28 h-2"
|
||||
value={entry.progress}
|
||||
max="100"
|
||||
>
|
||||
</progress>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-target="#chat-container"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload_name="group_avatar_upload"
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
title="Remove image"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Make Public</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="group[is_public]"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={@group_is_public}
|
||||
phx-change="update_group_form"
|
||||
value="true"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Public groups can be discovered and joined by anyone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Add Members</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users to add..."
|
||||
value={@search_query}
|
||||
phx-keyup="search_users"
|
||||
phx-debounce="300"
|
||||
phx-target={@myself}
|
||||
class="input input-bordered w-full"
|
||||
name="query"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected Users -->
|
||||
<%= if @selected_users != [] do %>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for user <- @selected_users do %>
|
||||
<div class="badge badge-primary gap-2">
|
||||
{user.handle || user.username}
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_user_selection"
|
||||
phx-value-user_id={user.id}
|
||||
phx-target={@myself}
|
||||
class="hover:text-primary-content"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- User Search Results -->
|
||||
<%= if @search_results != [] do %>
|
||||
<div class="bg-base-200 rounded border max-h-40 overflow-y-auto">
|
||||
<%= for user <- @search_results do %>
|
||||
<div
|
||||
class={[
|
||||
"flex items-center gap-3 p-3 hover:bg-base-300 cursor-pointer",
|
||||
Enum.any?(@selected_users, &(&1.id == user.id)) && "bg-primary/20"
|
||||
]}
|
||||
phx-click="toggle_user_selection"
|
||||
phx-value-user_id={user.id}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={Enum.any?(@selected_users, &(&1.id == user.id))}
|
||||
readonly
|
||||
/>
|
||||
<div class="avatar">
|
||||
<div class="w-8 h-8 rounded-full">
|
||||
<.user_avatar user={user} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
{user.display_name || user.handle || user.username}
|
||||
</p>
|
||||
<p class="text-xs opacity-70">{HandleFormatter.at_handle(user)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-secondary flex-1"
|
||||
disabled={length(@selected_users) == 0}
|
||||
>
|
||||
<.icon name="hero-users" class="w-4 h-4 mr-2" />
|
||||
Create Group ({length(@selected_users)} members)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("close_modal", _params, socket) do
|
||||
send(self(), {:close_group_modal})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("create_group", %{"group" => group_params}, socket) do
|
||||
send(self(), {:create_group, group_params, socket.assigns.selected_users})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("search_users", %{"query" => query}, socket) do
|
||||
send(self(), {:search_users_for_group, query})
|
||||
{:noreply, assign(socket, :search_query, query)}
|
||||
end
|
||||
|
||||
def handle_event("search_users", %{"value" => query}, socket) do
|
||||
send(self(), {:search_users_for_group, query})
|
||||
{:noreply, assign(socket, :search_query, query)}
|
||||
end
|
||||
|
||||
def handle_event("update_group_form", %{"group" => group_params}, socket) do
|
||||
send(self(), {:update_group_form, group_params})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("update_group_form", %{"_target" => ["group", "is_public"]} = params, socket) do
|
||||
# Handle checkbox toggle - when unchecked, the value won't be in params
|
||||
group_params = Map.get(params, "group", %{})
|
||||
group_params = Map.put(group_params, "is_public", Map.has_key?(group_params, "is_public"))
|
||||
|
||||
send(self(), {:update_group_form, group_params})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("update_group_form", params, socket) do
|
||||
# Fallback for other form updates
|
||||
group_params = Map.get(params, "group", %{})
|
||||
send(self(), {:update_group_form, group_params})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle_user_selection", %{"user_id" => user_id}, socket) do
|
||||
send(self(), {:toggle_user_selection, user_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
108
apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex
Normal file
108
apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
defmodule ArblargWeb.ChatLive.HandleFormatter do
|
||||
@moduledoc false
|
||||
|
||||
alias Elektrine.Messaging.Federation
|
||||
|
||||
def at_handle(user), do: "@" <> handle(user)
|
||||
|
||||
def handle(%Ecto.Association.NotLoaded{}), do: unknown_handle()
|
||||
def handle(nil), do: unknown_handle()
|
||||
|
||||
def handle(user) when is_map(user) do
|
||||
identifier =
|
||||
first_present([
|
||||
map_get(user, :remote_handle),
|
||||
map_get(user, :handle),
|
||||
map_get(user, :username)
|
||||
])
|
||||
|
||||
fallback_domain =
|
||||
first_present([
|
||||
map_get(user, :domain),
|
||||
map_get(user, :origin_domain)
|
||||
])
|
||||
|
||||
case identifier do
|
||||
nil -> unknown_handle()
|
||||
value -> normalize_handle(value, fallback_domain)
|
||||
end
|
||||
end
|
||||
|
||||
def handle(value) when is_binary(value), do: normalize_handle(value, nil)
|
||||
def handle(_), do: unknown_handle()
|
||||
|
||||
def domain(user) do
|
||||
case String.split(handle(user), "@", parts: 2) do
|
||||
[_local, domain] -> domain
|
||||
_ -> local_domain()
|
||||
end
|
||||
end
|
||||
|
||||
def local_domain do
|
||||
case Federation.local_domain() do
|
||||
domain when is_binary(domain) ->
|
||||
case String.trim(domain) do
|
||||
"" -> Elektrine.Domains.default_user_handle_domain()
|
||||
value -> String.downcase(value)
|
||||
end
|
||||
|
||||
_ ->
|
||||
Elektrine.Domains.default_user_handle_domain()
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_handle(value, fallback_domain) when is_binary(value) do
|
||||
cleaned =
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.trim_leading("@")
|
||||
|
||||
case String.split(cleaned, "@", parts: 2) do
|
||||
[local, domain] when local != "" and domain != "" ->
|
||||
local <> "@" <> normalize_domain(domain)
|
||||
|
||||
[local] when local != "" ->
|
||||
local <> "@" <> normalize_domain(fallback_domain || local_domain())
|
||||
|
||||
_ ->
|
||||
unknown_handle()
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_handle(_value, fallback_domain),
|
||||
do: unknown_handle(fallback_domain || local_domain())
|
||||
|
||||
defp normalize_domain(domain) when is_binary(domain) do
|
||||
domain
|
||||
|> String.trim()
|
||||
|> String.trim_leading("@")
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
"" -> local_domain()
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_domain(_), do: local_domain()
|
||||
|
||||
defp first_present(values) do
|
||||
Enum.find_value(values, fn
|
||||
value when is_binary(value) ->
|
||||
case String.trim(value) do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp map_get(map, key) when is_map(map) do
|
||||
Map.get(map, key) || Map.get(map, Atom.to_string(key))
|
||||
end
|
||||
|
||||
defp unknown_handle(domain \\ local_domain()) do
|
||||
"unknown@" <> normalize_domain(domain)
|
||||
end
|
||||
end
|
||||
3604
apps/arblarg/lib/arblarg_web/live/chat_live/index.ex
Normal file
3604
apps/arblarg/lib/arblarg_web/live/chat_live/index.ex
Normal file
File diff suppressed because it is too large
Load diff
2873
apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex
Normal file
2873
apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,103 @@
|
|||
defmodule ArblargWeb.ChatLive.Operations.CallInfoOperations do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [push_event: 3]
|
||||
import ElektrineWeb.Live.NotificationHelpers, only: [notify_info: 2]
|
||||
|
||||
@doc false
|
||||
def route_info(info, socket) do
|
||||
case info do
|
||||
{:incoming_call, call} -> {:handled, handle_incoming_call(socket, call)}
|
||||
{:call_rejected, call} -> {:handled, handle_call_rejected(socket, call)}
|
||||
{:call_ended, call} -> {:handled, handle_call_ended(socket, call)}
|
||||
{:call_missed, call} -> {:handled, handle_call_missed(socket, call)}
|
||||
_ -> :unhandled
|
||||
end
|
||||
end
|
||||
|
||||
def handle_incoming_call(socket, call) do
|
||||
# Deduplicate - ignore if already showing this call
|
||||
if socket.assigns.call && socket.assigns.call.incoming_call &&
|
||||
socket.assigns.call.incoming_call.id == call.id do
|
||||
{:noreply, socket}
|
||||
else
|
||||
call =
|
||||
if local_call_with_loaded_users?(call) do
|
||||
call
|
||||
else
|
||||
maybe_reload_local_call(call)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:call, %{socket.assigns.call | incoming_call: call, status: "ringing"})
|
||||
|> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, true))
|
||||
|> push_event("play_incoming_ringtone", %{})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call_rejected(socket, call),
|
||||
do: handle_call_terminal_event(socket, call, "call_rejected", "Call was rejected")
|
||||
|
||||
def handle_call_ended(socket, call),
|
||||
do: handle_call_terminal_event(socket, call | ||||