aprs.me/lib/aprsme/cache.ex
Graham McIntire 288b9fbbb2
fix: address security vulnerabilities and concurrency issues
- Fix SQL injection in partition_manager, db_optimizer, and release.ex
- Fix XSS vulnerabilities with proper HTML escaping in LiveViews
- Add proper error handling for email delivery functions
- Fix race conditions with advisory locks and atomic operations
- Replace unsupervised spawn/Task.start with supervised alternatives
- Convert ETS operations to GenServer serialization for thread safety
- Change ETS tables from :public to :protected access
- Add client limits to prevent unbounded memory growth
- Add PubSub cleanup in GenServer terminate callbacks
- Fix device upsert to use atomic Repo.insert_all
2026-03-23 16:59:09 -05:00

201 lines
4.8 KiB
Elixir

defmodule Aprsme.Cache do
@moduledoc """
Cache abstraction layer using ETS (Erlang Term Storage).
Provides a unified API for in-memory caching with optional TTL support.
Entries are stored as `{key, value, expires_at}` tuples where `expires_at`
is a monotonic time in milliseconds, or `:infinity` for no expiration.
Expired entries are lazily evicted on read.
"""
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
{:ok, %{}}
end
@doc """
Get a value from cache. Returns `{:ok, nil}` for expired entries.
"""
def get(cache_name, key) do
GenServer.call(__MODULE__, {:get, cache_name, key})
end
@doc """
Put a value in cache with optional TTL.
## Options
* `:ttl` - Time to live in milliseconds. Use `Cache.to_timeout/1` for convenience.
"""
def put(cache_name, key, value, opts \\ []) do
GenServer.call(__MODULE__, {:put, cache_name, key, value, opts})
end
@doc """
Delete a key from cache
"""
def del(cache_name, key) do
GenServer.call(__MODULE__, {:del, cache_name, key})
end
@doc """
Clear all keys from cache
"""
def clear(cache_name) do
GenServer.call(__MODULE__, {:clear, cache_name})
end
@doc """
Get cache statistics (simplified for ETS)
"""
def stats(cache_name) do
GenServer.call(__MODULE__, {:stats, cache_name})
end
@doc """
Check if key exists and is not expired.
"""
def exists?(cache_name, key) do
case get(cache_name, key) do
{:ok, nil} -> false
{:ok, _} -> true
_ -> false
end
end
@doc """
Get TTL for a key. Returns remaining milliseconds or nil if no TTL.
"""
def ttl(cache_name, key) do
GenServer.call(__MODULE__, {:ttl, cache_name, key})
end
@doc """
Convert timeout keyword list to milliseconds.
"""
def to_timeout(opts) do
Enum.reduce(opts, 0, fn
{:second, n}, acc -> acc + n * 1000
{:seconds, n}, acc -> acc + n * 1000
{:minute, n}, acc -> acc + n * 60 * 1000
{:minutes, n}, acc -> acc + n * 60 * 1000
{:hour, n}, acc -> acc + n * 60 * 60 * 1000
{:hours, n}, acc -> acc + n * 60 * 60 * 1000
{:day, n}, acc -> acc + n * 24 * 60 * 60 * 1000
{:days, n}, acc -> acc + n * 24 * 60 * 60 * 1000
{:millisecond, n}, acc -> acc + n
{:milliseconds, n}, acc -> acc + n
end)
end
@impl true
def handle_call({:get, cache_name, key}, _from, state) do
result =
try do
case :ets.lookup(cache_name, key) do
[{^key, value, :infinity}] ->
{:ok, value}
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(cache_name, key)
{:ok, nil}
end
[{^key, value}] ->
{:ok, value}
[] ->
{:ok, nil}
end
rescue
ArgumentError ->
{:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:put, cache_name, key, value, opts}, _from, state) do
result =
try do
expires_at =
case Keyword.get(opts, :ttl) do
nil -> :infinity
ttl when is_integer(ttl) and ttl > 0 -> System.monotonic_time(:millisecond) + ttl
_ -> :infinity
end
:ets.insert(cache_name, {key, value, expires_at})
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:del, cache_name, key}, _from, state) do
result =
try do
:ets.delete(cache_name, key)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:clear, cache_name}, _from, state) do
result =
try do
:ets.delete_all_objects(cache_name)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:stats, cache_name}, _from, state) do
result =
try do
info = :ets.info(cache_name)
{:ok, %{size: Keyword.get(info, :size, 0)}}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:ttl, cache_name, key}, _from, state) do
result =
try do
case :ets.lookup(cache_name, key) do
[{^key, _value, :infinity}] -> {:ok, nil}
[{^key, _value, expires_at}] -> {:ok, max(0, expires_at - System.monotonic_time(:millisecond))}
[{^key, _value}] -> {:ok, nil}
[] -> {:ok, nil}
end
rescue
ArgumentError -> {:ok, nil}
end
{:reply, result, state}
end
end