feat: allow unauthenticated users to perform domain name lookups

This commit is contained in:
Maël Gangloff 2025-12-08 18:18:33 +01:00
parent eddb267275
commit 5476ee7acc
No known key found for this signature in database
GPG Key ID: 11FDC81C24A7F629
16 changed files with 214 additions and 110 deletions

View File

@ -9,8 +9,8 @@ import WatchlistPage from './pages/tracking/WatchlistPage'
import UserPage from './pages/UserPage'
import type {PropsWithChildren} from 'react'
import React, { useCallback, useEffect, useMemo, useState} from 'react'
import {getUser} from './utils/api'
import LoginPage, {AuthenticatedContext} from './pages/LoginPage'
import {getConfiguration, getUser, type InstanceConfig} from './utils/api'
import LoginPage from './pages/LoginPage'
import ConnectorPage from './pages/tracking/ConnectorPage'
import NotFoundPage from './pages/NotFoundPage'
import useBreakpoint from './hooks/useBreakpoint'
@ -19,12 +19,14 @@ import {jt, t} from 'ttag'
import {MenuOutlined} from '@ant-design/icons'
import TrackedDomainPage from './pages/tracking/TrackedDomainPage'
import IcannRegistrarPage from "./pages/infrastructure/IcannRegistrarPage"
import type {AuthContextType} from "./contexts"
import { AuthenticatedContext, ConfigurationContext} from "./contexts"
const PROJECT_LINK = 'https://github.com/maelgangloff/domain-watchdog'
const LICENSE_LINK = 'https://www.gnu.org/licenses/agpl-3.0.txt'
const ProjectLink = <Typography.Link key="projectLink" target='_blank' href={PROJECT_LINK}>Domain Watchdog</Typography.Link>
const LicenseLink = <Typography.Link key="licenceLink" target='_blank' href={LICENSE_LINK}>AGPL-3.0-or-later</Typography.Link>
const LicenseLink = <Typography.Link key="licenceLink" target='_blank' rel='license' href={LICENSE_LINK}>AGPL-3.0-or-later</Typography.Link>
function SiderWrapper(props: PropsWithChildren<{sidebarCollapsed: boolean, setSidebarCollapsed: (collapsed: boolean) => void}>): React.ReactElement {
const {sidebarCollapsed, setSidebarCollapsed, children} = props
@ -68,18 +70,21 @@ export default function App(): React.ReactElement {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const authenticated = useCallback((authenticated: boolean) => {
setIsAuthenticated(authenticated)
}, [])
const contextValue = useMemo(() => ({
authenticated,
setIsAuthenticated
}), [authenticated, setIsAuthenticated])
const [configuration, setConfiguration] = useState<InstanceConfig | undefined>(undefined)
const [darkMode, setDarkMode] = useState(false)
const windowQuery = window.matchMedia('(prefers-color-scheme:dark)')
const authContextValue: AuthContextType = useMemo(() => ({
isAuthenticated,
setIsAuthenticated
}), [isAuthenticated])
const configContextValue = useMemo(() => ({
configuration,
}), [configuration])
const darkModeChange = useCallback((event: MediaQueryListEvent) => {
setDarkMode(event.matches)
}, [])
@ -93,13 +98,18 @@ export default function App(): React.ReactElement {
useEffect(() => {
setDarkMode(windowQuery.matches)
getUser().then(() => {
setIsAuthenticated(true)
if (location.pathname === '/login') navigate('/home')
}).catch(() => {
setIsAuthenticated(false)
const pathname = location.pathname
if (!['/login', '/tos', '/faq', '/privacy'].includes(pathname)) navigate('/home')
getConfiguration().then(configuration => {
setConfiguration(configuration)
getUser().then(() => {
setIsAuthenticated(true)
if (location.pathname === '/login') navigate('/home')
}).catch(() => {
setIsAuthenticated(false)
const pathname = location.pathname
if(configuration.publicRdapLookupEnabled) return navigate('/search/domain')
if (!['/login', '/tos', '/faq', '/privacy'].includes(pathname)) return navigate('/home')
})
})
}, [])
@ -109,10 +119,11 @@ export default function App(): React.ReactElement {
algorithm: darkMode ? theme.darkAlgorithm : undefined
}}
>
<AuthenticatedContext.Provider value={contextValue}>
<ConfigurationContext.Provider value={configContextValue}>
<AuthenticatedContext.Provider value={authContextValue}>
<Layout hasSider style={{minHeight: '100vh'}}>
<SiderWrapper sidebarCollapsed={sidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed}>
<Sider isAuthenticated={isAuthenticated}/>
<Sider />
</SiderWrapper>
<Layout>
<Layout.Header style={{padding: 0}}>
@ -129,7 +140,7 @@ export default function App(): React.ReactElement {
}}
>
<Routes>
<Route path='/' element={<Navigate to='/home'/>}/>
<Route path='/' element={<Navigate to={configuration?.publicRdapLookupEnabled ? '/search/domain' : '/home'}/>}/>
<Route path='/home' element={<TextPage resource='home.md'/>}/>
<Route path='/search/domain' element={<DomainSearchPage/>}/>
@ -158,8 +169,8 @@ export default function App(): React.ReactElement {
</Layout.Content>
<Layout.Footer style={{textAlign: 'center'}}>
<Flex gap='middle' wrap justify='center'>
<Link to='/tos'><Button type='text'>{t`TOS`}</Button></Link>
<Link to='/privacy'><Button type='text'>{t`Privacy Policy`}</Button></Link>
<Link to='/tos' rel='terms-of-service'><Button type='text'>{t`TOS`}</Button></Link>
<Link to='/privacy' rel='privacy-policy'><Button type='text'>{t`Privacy Policy`}</Button></Link>
<Link to='/faq'><Button type='text'>{t`FAQ`}</Button></Link>
<Typography.Link
target='_blank'
@ -193,6 +204,7 @@ export default function App(): React.ReactElement {
</Layout>
</Layout>
</AuthenticatedContext.Provider>
</ConfigurationContext.Provider>
</ConfigProvider>
)
}

View File

@ -2,10 +2,10 @@ import {Button, Flex, Form, Input, message} from 'antd'
import {t} from 'ttag'
import React, {useContext, useEffect, useState} from 'react'
import {getUser, login} from '../utils/api'
import {AuthenticatedContext} from '../pages/LoginPage'
import {useNavigate} from 'react-router-dom'
import {showErrorAPI} from '../utils/functions/showErrorAPI'
import {AuthenticatedContext} from "../contexts"
interface FieldType {
username: string
@ -66,9 +66,9 @@ export function LoginForm({ssoLogin}: { ssoLogin?: boolean }) {
</Form.Item>
<Flex wrap justify="center" gap="middle">
<Button type='primary' htmlType='submit'>
{t`Submit`}
</Button>
<Button type='primary' htmlType='submit'>
{t`Submit`}
</Button>
{ssoLogin &&
<Button type='dashed' htmlType='button' href='/login/oauth'>
{t`Log in with SSO`}

View File

@ -17,10 +17,14 @@ import {
UserOutlined
} from '@ant-design/icons'
import {Menu} from 'antd'
import React from 'react'
import React, {useContext} from 'react'
import {useLocation, useNavigate} from 'react-router-dom'
import {AuthenticatedContext, ConfigurationContext} from "../contexts"
export function Sider() {
const {isAuthenticated} = useContext(AuthenticatedContext)
const {configuration} = useContext(ConfigurationContext)
export function Sider({isAuthenticated}: { isAuthenticated: boolean }) {
const navigate = useNavigate()
const location = useLocation()
@ -42,7 +46,7 @@ export function Sider({isAuthenticated}: { isAuthenticated: boolean }) {
icon: <CompassOutlined/>,
label: t`Domain`,
title: t`Domain Finder`,
disabled: !isAuthenticated,
disabled: !configuration?.publicRdapLookupEnabled && !isAuthenticated,
onClick: () => navigate('/search/domain')
},
/*

24
assets/contexts/index.ts Normal file
View File

@ -0,0 +1,24 @@
import type React from 'react'
import {createContext} from 'react'
import type {InstanceConfig} from "../utils/api"
export type ConfigurationContextType = {
configuration: InstanceConfig | undefined
}
export const ConfigurationContext = createContext<ConfigurationContextType>({
configuration: undefined,
})
export type AuthContextType = {
isAuthenticated: boolean
setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean>>
}
export const AuthenticatedContext = createContext<AuthContextType>({
isAuthenticated: false,
setIsAuthenticated: () => {
},
})

View File

@ -1,28 +1,16 @@
import React, {createContext, useEffect, useState} from 'react'
import React, { useContext, useEffect, useState} from 'react'
import {Button, Card} from 'antd'
import {t} from 'ttag'
import TextPage from './TextPage'
import {LoginForm} from '../components/LoginForm'
import type { InstanceConfig} from '../utils/api'
import {getConfiguration} from '../utils/api'
import {RegisterForm} from '../components/RegisterForm'
import useBreakpoint from "../hooks/useBreakpoint"
export const AuthenticatedContext = createContext<
{
authenticated: (authenticated: boolean) => void
setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean>>
}
>({
authenticated: () => {
},
setIsAuthenticated: () => {
}
})
import {ConfigurationContext} from "../contexts"
export default function LoginPage() {
const [wantRegister, setWantRegister] = useState<boolean>(false)
const [configuration, setConfiguration] = useState<InstanceConfig>()
const { configuration } = useContext(ConfigurationContext)
const md = useBreakpoint('md')
const toggleWantRegister = () => {
@ -30,14 +18,11 @@ export default function LoginPage() {
}
useEffect(() => {
getConfiguration().then((configuration) => {
if(!configuration.registerEnabled && configuration.ssoLogin && configuration.ssoAutoRedirect) {
window.location.href = '/login/oauth'
return
}
setConfiguration(configuration)
})
}, [])
if(!configuration?.registerEnabled && configuration?.ssoLogin && configuration?.ssoAutoRedirect) {
window.location.href = '/login/oauth'
return
}
}, [configuration])
const grid = [
<Card.Grid key="form" style={{width: md ? '100%' : '50%', textAlign: 'center'}} hoverable={false}>

View File

@ -1,19 +1,18 @@
import React, {useEffect, useState} from 'react'
import type { FormProps} from 'antd'
import {FloatButton} from 'antd'
import {Empty, Flex, message, Skeleton} from 'antd'
import React, {useContext, useEffect, useState} from 'react'
import type {FormProps} from 'antd'
import {Empty, Flex, FloatButton, message, Skeleton} from 'antd'
import type {Domain, Watchlist} from '../../utils/api'
import {addDomainToWatchlist} from '../../utils/api'
import {getDomain} from '../../utils/api'
import {addDomainToWatchlist, getDomain} from '../../utils/api'
import type {AxiosError} from 'axios'
import {t} from 'ttag'
import type { FieldType} from '../../components/search/DomainSearchBar'
import type {FieldType} from '../../components/search/DomainSearchBar'
import {DomainSearchBar} from '../../components/search/DomainSearchBar'
import {DomainResult} from '../../components/search/DomainResult'
import {showErrorAPI} from '../../utils/functions/showErrorAPI'
import {useNavigate, useParams} from 'react-router-dom'
import {PlusOutlined} from '@ant-design/icons'
import WatchlistSelectionModal from '../../components/tracking/watchlist/WatchlistSelectionModal'
import {AuthenticatedContext} from "../../contexts"
export default function DomainSearchPage() {
const {query} = useParams()
@ -21,6 +20,8 @@ export default function DomainSearchPage() {
const domainLdhName = domain?.ldhName
const [loading, setLoading] = useState(false)
const [addToWatchlistModal, setAddToWatchlistModal] = useState(false)
const {isAuthenticated} = useContext(AuthenticatedContext)
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
@ -72,7 +73,7 @@ export default function DomainSearchPage() {
}
</Skeleton>
</Flex>
{domain
{domain && isAuthenticated
&& <FloatButton
style={{
position: 'fixed',
@ -94,7 +95,7 @@ export default function DomainSearchPage() {
onClose: () => setAddToWatchlistModal(false),
cancelText: t`Cancel`,
okText: t`Add`
}}
}}
/>
</>
}

View File

@ -109,6 +109,7 @@ export interface InstanceConfig {
ssoLogin: boolean
limtedFeatures: boolean
registerEnabled: boolean
publicRdapLookupEnabled: boolean
}
export interface Statistics {

View File

@ -22,5 +22,10 @@ framework:
user_rdap_requests:
policy: sliding_window
limit: 10
limit: 60
interval: '1 hour'
public_rdap_requests:
policy: sliding_window
limit: 30
interval: '1 hour'

View File

@ -60,6 +60,7 @@ security:
- { path: ^/api$, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/register$, roles: PUBLIC_ACCESS }
- { path: ^/api/domains/*, roles: CAN_RDAP_LOOKUP }
- { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/calendar$", roles: PUBLIC_ACCESS }
- { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/rss", roles: PUBLIC_ACCESS }
- { path: "^/api/config$", roles: PUBLIC_ACCESS }

View File

@ -7,13 +7,27 @@ parameters:
custom_rdap_servers_file: '%kernel.project_dir%/config/app/custom_rdap_servers.yaml'
mailer_sender_email: '%env(string:MAILER_SENDER_EMAIL)%'
mailer_sender_name: '%env(string:MAILER_SENDER_NAME)%'
env(MAILER_SENDER_NAME): Domain Watchdog
oauth_enabled: '%env(OAUTH_CLIENT_ID)%'
sso_auto_redirect: '%env(bool:SSO_AUTO_REDIRECT)%'
env(SSO_AUTO_REDIRECT): false
registration_enabled: '%env(bool:REGISTRATION_ENABLED)%'
env(REGISTRATION_ENABLED): true
registration_verify_email: '%env(bool:REGISTRATION_VERIFY_EMAIL)%'
env(REGISTRATION_VERIFY_EMAIL): false
public_rdap_lookup_enabled: '%env(bool:PUBLIC_RDAP_LOOKUP_ENABLED)%'
env(PUBLIC_RDAP_LOOKUP_ENABLED): false
limited_features: '%env(bool:LIMITED_FEATURES)%'
env(LIMITED_FEATURES): false
limit_max_watchlist: '%env(int:LIMIT_MAX_WATCHLIST)%'
limit_max_watchlist_domains: '%env(int:LIMIT_MAX_WATCHLIST_DOMAINS)%'
limit_max_watchlist_webhooks: '%env(int:LIMIT_MAX_WATCHLIST_WEBHOOKS)%'
@ -21,6 +35,8 @@ parameters:
outgoing_ip: '%env(string:OUTGOING_IP)%'
influxdb_enabled: '%env(bool:INFLUXDB_ENABLED)%'
env(INFLUXDB_ENABLED): false
influxdb_url: '%env(string:INFLUXDB_URL)%'
influxdb_token: '%env(string:INFLUXDB_TOKEN)%'
influxdb_bucket: '%env(string:INFLUXDB_BUCKET)%'

View File

@ -15,7 +15,8 @@ class InstanceController extends AbstractController
->setLimitedFeatures($this->getParameter('limited_features') ?? false)
->setOauthEnabled($this->getParameter('oauth_enabled') ?? false)
->setRegisterEnabled($this->getParameter('registration_enabled') ?? false)
->setSsoAutoRedirect($this->getParameter('sso_auto_redirect') ?? false);
->setSsoAutoRedirect($this->getParameter('sso_auto_redirect') ?? false)
->setPublicRdapLookupEnabled($this->getParameter('public_rdap_lookup_enabled') ?? false);
return $instance;
}

View File

@ -71,7 +71,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
],
),
],
provider: AutoRegisterDomainProvider::class
provider: AutoRegisterDomainProvider::class,
)]
class Domain
{

View File

@ -30,6 +30,8 @@ class Instance
private ?bool $ssoAutoRedirect = null;
private ?bool $publicRdapLookupEnabled = null;
public function isSsoLogin(): ?bool
{
return $this->oauthEnabled;
@ -77,4 +79,16 @@ class Instance
return $this;
}
public function getPublicRdapLookupEnabled(): ?bool
{
return $this->publicRdapLookupEnabled;
}
public function setPublicRdapLookupEnabled(?bool $publicRdapLookupEnabled): static
{
$this->publicRdapLookupEnabled = $publicRdapLookupEnabled;
return $this;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Security\Voter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class RdapLookupVoter extends Voter
{
public const string ATTRIBUTE = 'CAN_RDAP_LOOKUP';
public function __construct(
private readonly ParameterBagInterface $parameterBag,
private readonly Security $security,
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::ATTRIBUTE === $attribute;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
if ($this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
return true;
}
return $this->parameterBag->get('public_rdap_lookup_enabled');
}
}

View File

@ -41,6 +41,7 @@ readonly class AutoRegisterDomainProvider implements ProviderInterface
private KernelInterface $kernel,
private ParameterBagInterface $parameterBag,
private RateLimiterFactory $userRdapRequestsLimiter,
private RateLimiterFactory $publicRdapRequestsLimiter,
private Security $security,
private LoggerInterface $logger,
private DomainRepository $domainRepository,
@ -68,14 +69,20 @@ readonly class AutoRegisterDomainProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$fromWatchlist = array_key_exists('root_operation', $context) && Watchlist::class === $context['root_operation']?->getClass();
$userId = $this->security->getUser()->getUserIdentifier();
$idnDomain = RDAPService::convertToIdn($uriVariables['ldhName']);
$this->logger->info('User wants to update a domain name', [
'username' => $userId,
'ldhName' => $idnDomain,
]);
$user = $this->security->getUser();
if (null !== $user) {
$this->logger->info('User wants to update a domain name', [
'username' => $user->getUserIdentifier(),
'ldhName' => $idnDomain,
]);
} else {
$this->logger->info('Anonymous wants to update a domain name', [
'ldhName' => $idnDomain,
]);
}
$request = $this->requestStack->getCurrentRequest();
@ -96,7 +103,7 @@ readonly class AutoRegisterDomainProvider implements ProviderInterface
}
if (false === $this->kernel->isDebug() && true === $this->parameterBag->get('limited_features')) {
$limiter = $this->userRdapRequestsLimiter->create($userId);
$limiter = $user ? $this->userRdapRequestsLimiter->create($user->getUserIdentifier()) : $this->publicRdapRequestsLimiter->create($request->getClientIp());
$limit = $limiter->consume();
if (!$limit->isAccepted()) {

View File

@ -3,31 +3,31 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Plural-Forms: nplurals=2; plural=(n!=1);\n"
#: assets/App.tsx:161
#: assets/App.tsx:172
msgid "TOS"
msgstr ""
#: assets/App.tsx:162
#: assets/App.tsx:173
msgid "Privacy Policy"
msgstr ""
#: assets/App.tsx:163
#: assets/App.tsx:174
msgid "FAQ"
msgstr ""
#: assets/App.tsx:169
#: assets/App.tsx:180
msgid "Documentation"
msgstr ""
#: assets/App.tsx:177
#: assets/App.tsx:188
msgid "Source code"
msgstr ""
#: assets/App.tsx:185
#: assets/App.tsx:196
msgid "Submit an issue"
msgstr ""
#: assets/App.tsx:190
#: assets/App.tsx:201
#, javascript-format
msgid ""
"${ ProjectLink } is an open source project distributed under the ${ "
@ -87,7 +87,7 @@ msgid "Log in with SSO"
msgstr ""
#: assets/components/RegisterForm.tsx:54
#: assets/pages/LoginPage.tsx:60
#: assets/pages/LoginPage.tsx:51
msgid "Register"
msgstr ""
@ -168,60 +168,60 @@ msgstr ""
msgid "This domain name does not appear to be valid"
msgstr ""
#: assets/components/Sider.tsx:31
#: assets/components/Sider.tsx:35
msgid "Home"
msgstr ""
#: assets/components/Sider.tsx:37
#: assets/components/Sider.tsx:41
msgid "Search"
msgstr ""
#: assets/components/Sider.tsx:43
#: assets/components/Sider.tsx:47
#: assets/components/tracking/watchlist/TrackedDomainTable.tsx:179
msgid "Domain"
msgstr ""
#: assets/components/Sider.tsx:44
#: assets/components/Sider.tsx:48
msgid "Domain Finder"
msgstr ""
#: assets/components/Sider.tsx:63
#: assets/components/Sider.tsx:64
#: assets/components/Sider.tsx:67
#: assets/components/Sider.tsx:68
msgid "Infrastructure"
msgstr ""
#: assets/components/Sider.tsx:70
#: assets/components/Sider.tsx:74
#: assets/pages/StatisticsPage.tsx:112
#: assets/pages/infrastructure/TldPage.tsx:81
msgid "TLD"
msgstr ""
#: assets/components/Sider.tsx:71
#: assets/components/Sider.tsx:75
msgid "TLD list"
msgstr ""
#: assets/components/Sider.tsx:78
#: assets/components/Sider.tsx:79
#: assets/components/Sider.tsx:82
#: assets/components/Sider.tsx:83
msgid "ICANN list"
msgstr ""
#: assets/components/Sider.tsx:87
#: assets/components/Sider.tsx:91
msgid "Tracking"
msgstr ""
#: assets/components/Sider.tsx:93
#: assets/components/Sider.tsx:97
msgid "My Watchlists"
msgstr ""
#: assets/components/Sider.tsx:100
#: assets/components/Sider.tsx:104
msgid "Tracking table"
msgstr ""
#: assets/components/Sider.tsx:107
#: assets/components/Sider.tsx:111
msgid "My Connectors"
msgstr ""
#: assets/components/Sider.tsx:131
#: assets/components/Sider.tsx:135
#.
#. {
#. key: 'tools',
@ -240,18 +240,18 @@ msgstr ""
msgid "Statistics"
msgstr ""
#: assets/components/Sider.tsx:141
#: assets/components/Sider.tsx:145
#: assets/pages/UserPage.tsx:17
msgid "My Account"
msgstr ""
#: assets/components/Sider.tsx:146
#: assets/components/Sider.tsx:150
msgid "Log out"
msgstr ""
#: assets/components/Sider.tsx:154
#: assets/pages/LoginPage.tsx:46
#: assets/pages/LoginPage.tsx:60
#: assets/components/Sider.tsx:158
#: assets/pages/LoginPage.tsx:37
#: assets/pages/LoginPage.tsx:51
msgid "Log in"
msgstr ""
@ -690,7 +690,7 @@ msgstr ""
msgid "Sponsored Top-Level-Domains"
msgstr ""
#: assets/pages/LoginPage.tsx:46
#: assets/pages/LoginPage.tsx:37
msgid "Create an account"
msgstr ""
@ -698,35 +698,35 @@ msgstr ""
msgid "Sorry, the page you visited does not exist."
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:36
#: assets/pages/search/DomainSearchPage.tsx:37
msgid "Found !"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:53
#: assets/pages/search/DomainSearchPage.tsx:54
#, javascript-format
msgid "${ ldhName } added to ${ watchlist.name }"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:70
#: assets/pages/search/DomainSearchPage.tsx:71
msgid ""
"Although the domain exists in my database, it has been deleted from the "
"WHOIS by its registrar."
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:82
#: assets/pages/search/DomainSearchPage.tsx:83
msgid "Add to Watchlist"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:92
#: assets/pages/search/DomainSearchPage.tsx:93
#, javascript-format
msgid "Add ${ domainLdhName } to a Watchlist"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:95
#: assets/pages/search/DomainSearchPage.tsx:96
msgid "Cancel"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:96
#: assets/pages/search/DomainSearchPage.tsx:97
msgid "Add"
msgstr ""