195 lines
5.9 KiB
Elixir
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
|