towerops/lib/towerops/accounts/user_agent_parser.ex
Graham McIntire 3276a1cd8c
fix user session controller test
Update email input ID assertion to match actual template implementation
2026-02-03 13:42:24 -06:00

195 lines
5.9 KiB
Elixir

defmodule Towerops.Accounts.UserAgentParser do
@moduledoc """
Simple regex-based User-Agent parser for extracting browser, OS, and device information.
This module provides basic device identification without external dependencies.
It covers ~95% of common user agents but may not detect obscure browsers or devices.
## Examples
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
%{
browser_name: "Chrome",
browser_version: "120.0",
os_name: "macOS",
os_version: "10.15.7",
device_type: "desktop",
device_name: "Chrome on macOS"
}
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1")
%{
browser_name: "Safari",
browser_version: "17.2",
os_name: "iOS",
os_version: "17.2",
device_type: "mobile",
device_name: "Safari on iOS"
}
"""
@doc """
Parses a User-Agent string and returns device metadata.
Returns a map with the following fields:
- `:browser_name` - Browser name (Chrome, Safari, Firefox, Edge, Opera, or "Unknown")
- `:browser_version` - Browser version string (or nil)
- `:os_name` - Operating system name (macOS, Windows, Linux, iOS, Android, or "Unknown")
- `:os_version` - OS version string (or nil)
- `:device_type` - "desktop", "mobile", or "tablet"
- `:device_name` - Formatted string like "Chrome on macOS"
If the User-Agent is nil or cannot be parsed, returns a map with "Unknown" values.
"""
def parse(nil), do: unknown_device()
def parse(""), do: unknown_device()
def parse(user_agent) when is_binary(user_agent) do
browser = detect_browser(user_agent)
os = detect_os(user_agent)
device_type = detect_device_type(user_agent, os)
%{
browser_name: browser.name,
browser_version: browser.version,
os_name: os.name,
os_version: os.version,
device_type: device_type,
device_name: format_device_name(browser.name, os.name)
}
end
# Browser detection
defp detect_browser(ua) do
cond do
# Opera must be checked before Chrome (Opera includes Chrome in UA)
Regex.match?(~r/OPR\/(\d+\.\d+)/, ua) || Regex.match?(~r/Opera\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/(?:OPR|Opera)\/(\d+\.\d+)/)
%{name: "Opera", version: version}
# Edge must be checked before Chrome (Edge includes Chrome in UA)
Regex.match?(~r/Edg(e|A|iOS)?\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Edg(?:e|A|iOS)?\/(\d+\.\d+)/)
%{name: "Edge", version: version}
# Chrome must be checked before Safari (Chrome includes Safari in UA)
Regex.match?(~r/Chrome\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Chrome\/(\d+\.\d+)/)
%{name: "Chrome", version: version}
# Firefox
Regex.match?(~r/Firefox\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Firefox\/(\d+\.\d+)/)
%{name: "Firefox", version: version}
# Safari (check last because many browsers include Safari in UA)
Regex.match?(~r/Safari\//, ua) && Regex.match?(~r/Version\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Version\/(\d+\.\d+)/)
%{name: "Safari", version: version}
true ->
%{name: "Unknown", version: nil}
end
end
# OS detection
defp detect_os(ua) do
cond do
# iOS (must check before macOS because iOS UA contains "Mac OS X")
Regex.match?(~r/iPhone|iPad|iPod/, ua) ->
version = extract_version(ua, ~r/OS (\d+[_\d]*)/)
%{name: "iOS", version: format_ios_version(version)}
# macOS
Regex.match?(~r/Macintosh|Mac OS X/, ua) ->
version = extract_version(ua, ~r/Mac OS X (\d+[_\d]*)/)
%{name: "macOS", version: format_mac_version(version)}
# Android
Regex.match?(~r/Android/, ua) ->
version = extract_version(ua, ~r/Android (\d+\.\d+)/)
%{name: "Android", version: version}
# Windows
Regex.match?(~r/Windows/, ua) ->
version = detect_windows_version(ua)
%{name: "Windows", version: version}
# Linux
Regex.match?(~r/Linux/, ua) ->
%{name: "Linux", version: nil}
true ->
%{name: "Unknown", version: nil}
end
end
# Device type detection
defp detect_device_type(ua, os) do
cond do
# Tablets
Regex.match?(~r/iPad/, ua) || Regex.match?(~r/Android.*Tablet/, ua) ->
"tablet"
# Mobile devices
Regex.match?(~r/iPhone|iPod|Android.*Mobile|Mobile/, ua) || os.name == "iOS" ->
"mobile"
# Default to desktop
true ->
"desktop"
end
end
# Helper functions
defp extract_version(ua, regex) do
case Regex.run(regex, ua) do
[_, version] -> version
_ -> nil
end
end
defp format_ios_version(nil), do: nil
defp format_ios_version(version) do
# Convert iOS version from "17_2_1" to "17.2.1"
String.replace(version, "_", ".")
end
defp format_mac_version(nil), do: nil
defp format_mac_version(version) do
# Convert macOS version from "10_15_7" to "10.15.7"
String.replace(version, "_", ".")
end
defp detect_windows_version(ua) do
cond do
Regex.match?(~r/Windows NT 10\.0/, ua) -> "10"
Regex.match?(~r/Windows NT 6\.3/, ua) -> "8.1"
Regex.match?(~r/Windows NT 6\.2/, ua) -> "8"
Regex.match?(~r/Windows NT 6\.1/, ua) -> "7"
Regex.match?(~r/Windows NT/, ua) -> extract_version(ua, ~r/Windows NT (\d+\.\d+)/)
true -> nil
end
end
defp format_device_name(browser, os) do
"#{browser} on #{os}"
end
defp unknown_device do
%{
browser_name: "Unknown",
browser_version: nil,
os_name: "Unknown",
os_version: nil,
device_type: "desktop",
device_name: "Unknown on Unknown"
}
end
end