chore: merge develop

This commit is contained in:
Maël Gangloff 2025-10-27 21:57:08 +01:00
commit aff37f7a81
No known key found for this signature in database
GPG Key ID: 11FDC81C24A7F629
161 changed files with 8743 additions and 3179 deletions

View File

@ -2,3 +2,24 @@
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/postgres?serverVersion=16&charset=utf8"
# FEATURES
LIMITED_FEATURES=true
LIMIT_MAX_WATCHLIST=10
LIMIT_MAX_WATCHLIST_DOMAINS=10
LIMIT_MAX_WATCHLIST_WEBHOOKS=10
# TEST
GANDI_PAT_TOKEN=
NAMECOM_USERNAME=
NAMECOM_PASSWORD=
NAMECHEAP_USERNAME=
NAMECHEAP_TOKEN=
# Typically your IP address, this envvar is required for
# some connectors that need to be provided with your host's
# outgoing IP address.
OUTGOING_IP=

191
.github/workflows/lint-and-tests.yml vendored Normal file
View File

@ -0,0 +1,191 @@
name: Lint and Tests
on:
push:
branches: [ "master", "develop" ]
pull_request:
branches: [ "master", "develop" ]
permissions:
contents: read
checks: write
pull-requests: write
jobs:
php-setup:
name: PHP Setup
runs-on: ubuntu-latest
outputs:
vendor-path: ${{ steps.upload.outputs.artifact-path }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, xml, intl, curl, iconv, pdo_pgsql, sodium, zip, http
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader
- name: Upload vendor folder
uses: actions/upload-artifact@v4
with:
name: php-vendor
path: vendor
id: upload
phpstan:
name: PHPStan
runs-on: ubuntu-latest
needs: php-setup
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download PHP vendor
uses: actions/download-artifact@v4
with:
name: php-vendor
path: vendor
- name: Set executable permissions
run: chmod +x vendor/bin/*
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Run PHPStan
run: vendor/bin/phpstan analyse
cs-fixer:
name: PHP-CS-Fixer
runs-on: ubuntu-latest
needs: [ php-setup, phpstan ]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download PHP vendor
uses: actions/download-artifact@v4
with:
name: php-vendor
path: vendor
- name: Set executable permissions
run: chmod +x vendor/bin/*
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Run PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
tests:
name: Tests
runs-on: ubuntu-latest
needs: [ php-setup, cs-fixer, phpstan ]
env:
GANDI_PAT_TOKEN: ${{ secrets.GANDI_PAT_TOKEN }}
NAMECOM_USERNAME: ${{ secrets.NAMECOM_USERNAME }}
NAMECOM_PASSWORD: ${{ secrets.NAMECOM_PASSWORD }}
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download PHP vendor
uses: actions/download-artifact@v4
with:
name: php-vendor
path: vendor
- name: Set executable permissions
run: chmod +x vendor/bin/*
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, xml, intl, curl, iconv, pdo_pgsql, sodium, zip, http
- name: Prepare database
run: bin/console --env=test doctrine:database:create && bin/console --env=test doctrine:migrations:migrate
- name: Create JWT keys
run: bin/console lexik:jwt:generate-keypair
- name: Add extra RDAP servers
run: ln -s custom_rdap_servers.example.yaml config/app/custom_rdap_servers.yaml
- name: Run PHPUnit
run: vendor/bin/phpunit --coverage-text --log-junit test-results.xml
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: (!cancelled())
with:
files: |
test-results.xml
eslint:
name: ESLint
runs-on: ubuntu-latest
needs: php-setup
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download PHP vendor
uses: actions/download-artifact@v4
with:
name: php-vendor
path: vendor
- name: Set executable permissions
run: chmod +x vendor/bin/*
- name: Cache Node modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install frontend dependencies
run: npm install --global yarn && yarn install
- name: Run ESLint
run: yarn run eslint

View File

@ -70,6 +70,8 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ github.repository }},name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Export digest
run: |

View File

@ -1,58 +0,0 @@
name: Symfony CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
symfony:
name: CI
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, intl, curl, iconv, pdo_pgsql, sodium, zip, http
- name: Install backend dependencies
run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader
- name: Run PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Run PHPStan
run: vendor/bin/phpstan analyse
- name: Run PHPUnit
run: vendor/bin/phpunit --coverage-text
- name: Cache Node modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install frontend dependencies
run: npm install --global yarn && yarn install
- name: Run ESLint
run: yarn run eslint

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.4
# Versions
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
@ -17,9 +17,7 @@ VOLUME /app/var/
# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
file \
gettext \
libicu-dev \
libzip-dev \
unzip \

View File

@ -14,7 +14,6 @@ import {
SafetyOutlined,
SearchOutlined,
TableOutlined,
TeamOutlined,
UserOutlined
} from '@ant-design/icons'
import {Menu} from 'antd'
@ -46,14 +45,6 @@ export function Sider({isAuthenticated}: { isAuthenticated: boolean }) {
disabled: !isAuthenticated,
onClick: () => navigate('/search/domain')
},
{
key: '/search/entity',
icon: <TeamOutlined/>,
label: t`Entity`,
title: t`Entity Finder`,
disabled: !isAuthenticated,
onClick: () => navigate('/search/entity')
},
/*
{
key: 'ns-finder',

View File

@ -1,11 +1,16 @@
import {Flex, List, Tag, Tooltip, Typography} from 'antd'
import {Flex, List, Tag, Tooltip} from 'antd'
import React from 'react'
import type {Domain} from '../../utils/api'
import {rdapRoleDetailTranslation, rdapRoleTranslation} from '../../utils/functions/rdapTranslation'
import {
icannAccreditationTranslation,
rdapRoleDetailTranslation,
rdapRoleTranslation
} from '../../utils/functions/rdapTranslation'
import {roleToAvatar} from '../../utils/functions/roleToAvatar'
import {rolesToColor} from '../../utils/functions/rolesToColor'
import {sortDomainEntities} from '../../utils/functions/sortDomainEntities'
import {extractDetailsFromJCard} from '../../utils/functions/extractDetailsFromJCard'
import {CheckCircleOutlined, CloseCircleOutlined, SettingOutlined} from "@ant-design/icons"
export function EntitiesList({domain}: { domain: Domain }) {
const rdapRoleTranslated = rdapRoleTranslation()
@ -27,11 +32,28 @@ export function EntitiesList({domain}: { domain: Domain }) {
dataSource={sortDomainEntities(domain)}
renderItem={(e) => {
const details = extractDetailsFromJCard(e)
const icannAccreditationTranslated = icannAccreditationTranslation()
const status = e.entity.icannAccreditation?.status as ('Terminated' | 'Accredited' | 'Reserved' | undefined)
return <List.Item>
<List.Item.Meta
avatar={roleToAvatar(e)}
title={<Typography.Text code>{e.entity.handle}</Typography.Text>}
title={<Flex gap='small'>
<Tag>{e.entity.handle}</Tag>
{
e.entity.icannAccreditation && status && <Tooltip
title={e.entity.icannAccreditation.registrarName + " (" + icannAccreditationTranslated[status] + ")"}>
<Tag icon={
status === 'Terminated' ? <CloseCircleOutlined /> :
status === 'Accredited' ? <CheckCircleOutlined/> : <SettingOutlined/>
} color={
status === 'Terminated' ? 'red' :
status === 'Accredited' ? 'green' : 'yellow'
}>{e.entity.icannAccreditation.id}</Tag>
</Tooltip>
}
</Flex>}
description={<>
{details.fn && <div>👤 {details.fn}</div>}
{details.organization && <div>🏢 {details.organization}</div>}

View File

@ -0,0 +1,59 @@
import {Button, Drawer, Form} from 'antd'
import {t} from 'ttag'
import {WatchlistForm} from './WatchlistForm'
import React, {useState} from 'react'
import type {Connector} from '../../../utils/api/connectors'
import useBreakpoint from "../../../hooks/useBreakpoint"
export function CreateWatchlistButton({onUpdateWatchlist, connectors}: {
onUpdateWatchlist: (values: {
domains: string[],
trackedEvents: string[],
trackedEppStatus: string[],
token: string
}) => Promise<void>
connectors: Array<Connector & { id: string }>
}) {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const sm = useBreakpoint('sm')
const showDrawer = () => setOpen(true)
const onClose = () => {
setOpen(false)
setLoading(false)
}
return (
<>
<Button type='default' block onClick={() => {
showDrawer()
}}>{t`Create a Watchlist`}</Button>
<Drawer
title={t`Create a Watchlist`}
width={sm ? '100%' : '80%'}
onClose={onClose}
open={open}
loading={loading}
styles={{
body: {
paddingBottom: 80
}
}}
extra={<Button onClick={onClose}>{t`Cancel`}</Button>}
>
<WatchlistForm
form={form}
onFinish={values => {
setLoading(true)
onUpdateWatchlist(values).then(onClose).catch(() => setLoading(false))
}}
connectors={connectors}
isCreation
/>
</Drawer>
</>
)
}

View File

@ -0,0 +1,33 @@
import {Popconfirm, theme, Typography} from 'antd'
import {t} from 'ttag'
import type { Watchlist} from '../../../utils/api'
import {patchWatchlist} from '../../../utils/api'
import {PauseCircleOutlined, PlayCircleOutlined} from '@ant-design/icons'
import React from 'react'
export function DisableWatchlistButton({watchlist, onChange, enabled}: {
watchlist: Watchlist,
onChange: () => void,
enabled: boolean
}) {
const {token} = theme.useToken()
return (
enabled ?
<Popconfirm
title={t`Disable the Watchlist`}
description={t`Are you sure to disable this Watchlist?`}
onConfirm={async () => await patchWatchlist(watchlist.token, {enabled: !enabled}).then(onChange)}
okText={t`Yes`}
cancelText={t`No`}
okButtonProps={{danger: true}}
>
<Typography.Link>
<PauseCircleOutlined style={{color: token.colorText}} title={t`Disable the Watchlist`}/>
</Typography.Link>
</Popconfirm> : <Typography.Link>
<PlayCircleOutlined style={{color: token.colorWarning}} title={t`Enable the Watchlist`}
onClick={async () => await patchWatchlist(watchlist.token, {enabled: !enabled}).then(onChange)}/>
</Typography.Link>
)
}

View File

@ -235,7 +235,7 @@ export function TrackedDomainTable() {
description={t`No tracked domain names were found, please create your first Watchlist`}
>
<Link to='/tracking/watchlist'>
<Button type='primary'>Create Now</Button>
<Button type='primary'>{t`Create now`}</Button>
</Link>
</Empty>
: <Skeleton loading={total === undefined}>

View File

@ -5,19 +5,19 @@ import React, {useState} from 'react'
import {EditOutlined} from '@ant-design/icons'
import type {Connector} from '../../../utils/api/connectors'
import type {Watchlist} from '../../../utils/api'
import useBreakpoint from "../../../hooks/useBreakpoint"
export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}: {
watchlist: Watchlist
onUpdateWatchlist: (values: { domains: string[], triggers: string[], token: string }) => Promise<void>
onUpdateWatchlist: (values: { domains: string[], trackedEvents: string[], trackedEppStatus: string[], token: string }) => Promise<void>
connectors: Array<Connector & { id: string }>
}) {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const sm = useBreakpoint('sm')
const showDrawer = () => {
setOpen(true)
}
const showDrawer = () => setOpen(true)
const onClose = () => {
setOpen(false)
@ -35,7 +35,8 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
{name: 'name', value: watchlist.name},
{name: 'connector', value: watchlist.connector?.id},
{name: 'domains', value: watchlist.domains.map(d => d.ldhName)},
{name: 'triggers', value: [...new Set(watchlist.triggers?.map(t => t.event))]},
{name: 'trackedEvents', value: watchlist.trackedEvents},
{name: 'trackedEppStatus', value: watchlist.trackedEppStatus},
{name: 'dsn', value: watchlist.dsn}
])
}}
@ -43,7 +44,7 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
</Typography.Link>
<Drawer
title={t`Update a Watchlist`}
width='80%'
width={sm ? '100%' : '80%'}
onClose={onClose}
open={open}
loading={loading}
@ -62,7 +63,7 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
}}
connectors={connectors}
isCreation={false}
watchList={watchlist}
watchlist={watchlist}
/>
</Drawer>
</>

View File

@ -7,25 +7,44 @@ import {DeleteWatchlistButton} from './DeleteWatchlistButton'
import React from 'react'
import type {Connector} from '../../../utils/api/connectors'
import {CalendarWatchlistButton} from './CalendarWatchlistButton'
import {rdapEventDetailTranslation, rdapEventNameTranslation} from '../../../utils/functions/rdapTranslation'
import {
rdapDomainStatusCodeDetailTranslation,
rdapEventDetailTranslation,
rdapEventNameTranslation
} from '../../../utils/functions/rdapTranslation'
import {actionToColor} from '../../../utils/functions/actionToColor'
import {DomainToTag} from '../../../utils/functions/DomainToTag'
import type {Watchlist} from '../../../utils/api'
import {eppStatusCodeToColor} from "../../../utils/functions/eppStatusCodeToColor"
import {DisableWatchlistButton} from "./DisableWatchlistButton"
export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelete}: {
export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onChange}: {
watchlist: Watchlist
onUpdateWatchlist: (values: { domains: string[], triggers: string[], token: string }) => Promise<void>
onUpdateWatchlist: (values: {
domains: string[],
trackedEvents: string[],
trackedEppStatus: string[],
token: string
}) => Promise<void>
connectors: Array<Connector & { id: string }>
onDelete: () => void
onChange: () => void
}) {
const rdapEventNameTranslated = rdapEventNameTranslation()
const rdapEventDetailTranslated = rdapEventDetailTranslation()
const rdapDomainStatusCodeDetailTranslated = rdapDomainStatusCodeDetailTranslation()
return (
<>
<Card
aria-disabled={true}
type='inner'
style={{
width: '100%',
opacity: watchlist.enabled ? 1 : 0.5,
filter: watchlist.enabled ? 'none' : 'grayscale(0.7)',
transition: 'all 0.3s ease',
}}
title={<>
{
(watchlist.connector != null)
@ -41,7 +60,6 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
</Tooltip>
</>}
size='small'
style={{width: '100%'}}
extra={
<Space size='middle'>
<ViewDiagramWatchlistButton token={watchlist.token}/>
@ -54,26 +72,65 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
connectors={connectors}
/>
<DeleteWatchlistButton watchlist={watchlist} onDelete={onDelete}/>
<DisableWatchlistButton watchlist={watchlist} onChange={onChange}
enabled={watchlist.enabled}/>
<DeleteWatchlistButton watchlist={watchlist} onDelete={onChange}/>
</Space>
}
>
<Card.Meta description={watchlist.token} style={{marginBottom: '1em'}}/>
<Row gutter={16}>
<Col span={16}>
{watchlist.domains.map(d => <DomainToTag key={d.ldhName} domain={d}/>)}
{watchlist.domains.map(d => (
<DomainToTag key={d.ldhName} domain={d}/>
))}
</Col>
<Col span={8}>
{watchlist.triggers?.filter(t => t.action === 'email')
.map(t => <Tooltip
key={t.event}
title={rdapEventDetailTranslated[t.event as keyof typeof rdapEventDetailTranslated] || undefined}
>
<Tag color={actionToColor(t.event)}>
{rdapEventNameTranslated[t.event as keyof typeof rdapEventNameTranslated]}
</Tag>
</Tooltip>
)}
<>
<div style={{
fontWeight: 500,
marginBottom: '0.5em',
color: '#555',
fontSize: '0.9em'
}}>
{t`Tracked events`}
</div>
<div style={{marginBottom: '1em'}}>
{watchlist.trackedEvents?.map(t => (
<Tooltip
key={t}
title={rdapEventDetailTranslated[t as keyof typeof rdapEventDetailTranslated]}
>
<Tag color={actionToColor(t)} style={{marginBottom: 4}}>
{rdapEventNameTranslated[t as keyof typeof rdapEventNameTranslated]}
</Tag>
</Tooltip>
))}
</div>
</>
<>
<div style={{
fontWeight: 500,
marginBottom: '0.5em',
color: '#555',
fontSize: '0.9em'
}}>
{t`Tracked EPP status`}
</div>
<div>
{watchlist.trackedEppStatus?.map(t => (
<Tooltip
key={t}
title={rdapDomainStatusCodeDetailTranslated[t as keyof typeof rdapDomainStatusCodeDetailTranslated]}
>
<Tag color={eppStatusCodeToColor(t)} style={{marginBottom: 4}}>
{t}
</Tag>
</Tooltip>
))}
</div>
</>
</Col>
</Row>
</Card>

View File

@ -2,14 +2,18 @@ import type { FormInstance, SelectProps} from 'antd'
import {Button, Form, Input, Select, Space, Tag, Tooltip, Typography} from 'antd'
import {t} from 'ttag'
import {ApiOutlined, MinusCircleOutlined, PlusOutlined} from '@ant-design/icons'
import React, {useState} from 'react'
import React from 'react'
import type {Connector} from '../../../utils/api/connectors'
import {rdapEventDetailTranslation, rdapEventNameTranslation} from '../../../utils/functions/rdapTranslation'
import {
rdapDomainStatusCodeDetailTranslation,
rdapEventDetailTranslation,
rdapEventNameTranslation
} from '../../../utils/functions/rdapTranslation'
import {actionToColor} from '../../../utils/functions/actionToColor'
import {actionToIcon} from '../../../utils/functions/actionToIcon'
import type {EventAction, Watchlist} from '../../../utils/api'
import { createWatchlistTrigger, deleteWatchlistTrigger} from '../../../utils/api'
import {formItemLayoutWithOutLabel} from "../../../utils/providers"
import {eppStatusCodeToColor} from "../../../utils/functions/eppStatusCodeToColor"
type TagRender = SelectProps['tagRender']
@ -24,17 +28,18 @@ const formItemLayout = {
}
}
export function WatchlistForm({form, connectors, onFinish, isCreation, watchList}: {
export function WatchlistForm({form, connectors, onFinish, isCreation}: {
form: FormInstance
connectors: Array<Connector & { id: string }>
onFinish: (values: { domains: string[], triggers: string[], token: string }) => void
onFinish: (values: { domains: string[], trackedEvents: string[], trackedEppStatus: string[], token: string }) => void
isCreation: boolean,
watchList?: Watchlist,
watchlist?: Watchlist,
}) {
const rdapEventNameTranslated = rdapEventNameTranslation()
const rdapEventDetailTranslated = rdapEventDetailTranslation()
const rdapDomainStatusCodeDetailTranslated = rdapDomainStatusCodeDetailTranslation()
const triggerTagRenderer: TagRender = ({value, closable, onClose}: {
const eventActionTagRenderer: TagRender = ({value, closable, onClose}: {
value: EventAction
closable: boolean
onClose: () => void
@ -61,40 +66,30 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
)
}
const [triggersLoading, setTriggersLoading] = useState(false)
const createTrigger = async (event: string) => {
if (isCreation) return
setTriggersLoading(true)
await createWatchlistTrigger(watchList!.token, {
watchList: watchList!['@id'],
event,
action: 'email',
})
await createWatchlistTrigger(watchList!.token, {
watchList: watchList!['@id'],
event,
action: 'chat',
})
setTriggersLoading(false)
}
const removeTrigger = async (event: string) => {
if (isCreation) return
setTriggersLoading(true)
await deleteWatchlistTrigger(watchList!.token, {
watchList: watchList!['@id'],
event,
action: 'email',
})
await deleteWatchlistTrigger(watchList!.token, {
watchList: watchList!['@id'],
event,
action: 'chat',
})
setTriggersLoading(false)
const domainStatusTagRenderer: TagRender = ({value, closable, onClose}: {
value: EventAction
closable: boolean
onClose: () => void
}) => {
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault()
event.stopPropagation()
}
return (
<Tooltip
title={rdapDomainStatusCodeDetailTranslated[value as keyof typeof rdapDomainStatusCodeDetailTranslated] || undefined}
>
<Tag
color={eppStatusCodeToColor(value)}
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginInlineEnd: 4}}
>
{value}
</Tag>
</Tooltip>
)
}
return (
@ -102,7 +97,10 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
{...formItemLayoutWithOutLabel}
form={form}
onFinish={onFinish}
initialValues={{triggers: ['last changed', 'transfer', 'expiration', 'deletion']}}
initialValues={{
trackedEvents: ['last changed', 'transfer', 'deletion'],
trackedEppStatus: ['auto renew period', 'redemption period', 'pending delete', 'client hold', 'server hold']
}}
>
<Form.Item name='token' hidden>
@ -191,8 +189,8 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
</Form.List>
<Form.Item
label={t`Tracked events`}
name='triggers'
rules={[{required: true, message: t`At least one trigger`, type: 'array'}]}
name='trackedEvents'
rules={[{required: true, message: t`At least one event`, type: 'array'}]}
labelCol={{
xs: {span: 24},
sm: {span: 4}
@ -205,11 +203,8 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
>
<Select
mode='multiple'
tagRender={triggerTagRenderer}
tagRender={eventActionTagRenderer}
style={{width: '100%'}}
onSelect={createTrigger}
onDeselect={removeTrigger}
loading={triggersLoading}
options={Object.keys(rdapEventNameTranslated).map(e => ({
value: e,
title: rdapEventDetailTranslated[e as keyof typeof rdapEventDetailTranslated] || undefined,
@ -218,6 +213,32 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
/>
</Form.Item>
<Form.Item
label={t`Tracked EPP status`}
name='trackedEppStatus'
rules={[{required: true, message: t`At least one EPP status`, type: 'array'}]}
labelCol={{
xs: {span: 24},
sm: {span: 4}
}}
wrapperCol={{
md: {span: 12},
sm: {span: 20}
}}
required
>
<Select
mode='multiple'
tagRender={domainStatusTagRenderer}
style={{width: '100%'}}
options={Object.keys(rdapDomainStatusCodeDetailTranslated).map(e => ({
value: e,
title: rdapDomainStatusCodeDetailTranslated[e as keyof typeof rdapDomainStatusCodeDetailTranslated] || undefined,
label: e
}))}
/>
</Form.Item>
<Form.Item
label={t`Connector`}
name='connector'

View File

@ -3,21 +3,21 @@ import type {Connector} from '../../../utils/api/connectors'
import {WatchlistCard} from './WatchlistCard'
import type {Watchlist} from '../../../utils/api'
export function WatchlistsList({watchlists, onDelete, onUpdateWatchlist, connectors}: {
export function WatchlistsList({watchlists, onChange, onUpdateWatchlist, connectors}: {
watchlists: Watchlist[]
onDelete: () => void
onUpdateWatchlist: (values: { domains: string[], triggers: string[], token: string }) => Promise<void>
onChange: () => void
onUpdateWatchlist: (values: { domains: string[], trackedEvents: string[], trackedEppStatus: string[], token: string }) => Promise<void>
connectors: Array<Connector & { id: string }>
}) {
return (
<>
{watchlists.map(watchlist =>
{[...watchlists.filter(w => w.enabled), ...watchlists.filter(w => !w.enabled)].map(watchlist =>
<WatchlistCard
key={watchlist.token}
watchlist={watchlist}
onUpdateWatchlist={onUpdateWatchlist}
connectors={connectors}
onDelete={onDelete}
onChange={onChange}
/>
)}
</>

View File

@ -7,10 +7,10 @@ import type {Edge} from '@xyflow/react'
export function domainEntitiesToEdges(d: Domain, withRegistrar = false): Edge[] {
const rdapRoleTranslated = rdapRoleTranslation()
const sponsor = d.entities.find(e => !e.deleted && e.roles.includes('sponsor'))
const sponsor = d.entities.find(e => e.deletedAt === undefined && e.roles.includes('sponsor'))
return d.entities
.filter(e =>
!e.deleted &&
e.deletedAt === undefined &&
(withRegistrar || !e.roles.includes('registrar')) &&
((sponsor == null) || !e.roles.includes('registrar') || e.roles.includes('sponsor'))
)

View File

@ -15,10 +15,10 @@ export const domainToNode = (d: Domain): Node => ({
})
export const domainEntitiesToNode = (d: Domain, withRegistrar = false): Node[] => {
const sponsor = d.entities.find(e => !e.deleted && e.roles.includes('sponsor'))
const sponsor = d.entities.find(e => e.deletedAt === undefined && e.roles.includes('sponsor'))
return d.entities
.filter(e =>
!e.deleted &&
e.deletedAt === undefined &&
(withRegistrar || !e.roles.includes('registrar')) &&
((sponsor == null) || !e.roles.includes('registrar') || e.roles.includes('sponsor'))
)

View File

@ -9,12 +9,12 @@ import {getIcannAccreditations} from "../../utils/api/icann-accreditations"
const {Text, Paragraph} = Typography
interface FiltersType {
'icannAccreditation.status': 'Accredited' | 'Reserved' | 'Terminated',
status: 'Accredited' | 'Reserved' | 'Terminated',
}
function RegistrarListTable(filters: FiltersType) {
interface TableRow {
key: string
key: number
handle: number
name: string
}
@ -26,9 +26,9 @@ function RegistrarListTable(filters: FiltersType) {
getIcannAccreditations(params).then((data) => {
setTotal(data['hydra:totalItems'])
setDataTable(data['hydra:member'].map((accreditation: IcannAccreditation) => ({
key: accreditation.handle,
handle: parseInt(accreditation.handle),
name: accreditation.icannAccreditation.registrarName
key: accreditation.id,
handle: accreditation.id,
name: accreditation.registrarName
})
).sort((a, b) => a.handle - b.handle))
})
@ -76,17 +76,17 @@ export default function IcannRegistrarPage() {
Accredited: <>
<Text>{t`An accredited number means that ICANN's contract with the registrar is ongoing.`}</Text>
<Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Accredited'}} />
<RegistrarListTable status='Accredited' />
</>,
Reserved: <>
<Text>{t`A reserved number can be used by TLD registries for specific operations.`}</Text>
<Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Reserved'}} />
<RegistrarListTable status='Reserved' />
</>,
Terminated: <>
<Text>{t`A terminated number means that ICANN's contract with the registrar has been terminated.`}</Text>
<Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Terminated'}} />
<RegistrarListTable status='Terminated' />
</>
}

View File

@ -1,54 +1,34 @@
import React, {useEffect, useState} from 'react'
import {Card, Divider, Flex, Form, message} from 'antd'
import type {Watchlist, WatchlistTrigger} from '../../utils/api'
import {Divider, Flex, Form, message} from 'antd'
import type {Watchlist} from '../../utils/api'
import {getWatchlists, postWatchlist, putWatchlist} from '../../utils/api'
import type {AxiosError} from 'axios'
import {t} from 'ttag'
import {WatchlistForm} from '../../components/tracking/watchlist/WatchlistForm'
import {WatchlistsList} from '../../components/tracking/watchlist/WatchlistsList'
import type {Connector} from '../../utils/api/connectors'
import { getConnectors} from '../../utils/api/connectors'
import {showErrorAPI} from '../../utils/functions/showErrorAPI'
import {CreateWatchlistButton} from "../../components/tracking/watchlist/CreateWatchlistButton"
interface FormValuesType {
name?: string
domains: string[]
triggers: string[]
trackedEvents: string[]
trackedEppStatus: string[]
connector?: string
dsn?: string[]
}
const getRequestDataFromFormCreation = (values: FormValuesType) => {
const domainsURI = values.domains.map(d => '/api/domains/' + d.toLowerCase())
let triggers: WatchlistTrigger[] = values.triggers.map(t => ({event: t, action: 'email'}))
if (values.dsn !== undefined) {
triggers = [...triggers, ...values.triggers.map((t): WatchlistTrigger => ({
event: t,
action: 'chat'
}))]
}
return {
name: values.name,
domains: domainsURI,
triggers,
const getRequestDataFromFormCreation = (values: FormValuesType) =>
({ name: values.name,
domains: values.domains.map(d => '/api/domains/' + d.toLowerCase()),
trackedEvents: values.trackedEvents,
trackedEppStatus: values.trackedEppStatus,
connector: values.connector !== undefined ? ('/api/connectors/' + values.connector) : undefined,
dsn: values.dsn
}
}
const getRequestDataFromFormUpdate = (values: FormValuesType) => {
const domainsURI = values.domains.map(d => '/api/domains/' + d.toLowerCase())
return {
name: values.name,
domains: domainsURI,
connector: values.connector !== undefined ? ('/api/connectors/' + values.connector) : undefined,
dsn: values.dsn
}
}
dsn: values.dsn,
enabled: true
})
export default function WatchlistPage() {
const [form] = Form.useForm()
@ -56,19 +36,17 @@ export default function WatchlistPage() {
const [watchlists, setWatchlists] = useState<Watchlist[]>()
const [connectors, setConnectors] = useState<Array<Connector & { id: string }>>()
const onCreateWatchlist = (values: FormValuesType) => {
postWatchlist(getRequestDataFromFormCreation(values)).then(() => {
const onCreateWatchlist = async (values: FormValuesType) => await postWatchlist(getRequestDataFromFormCreation(values)).then(() => {
form.resetFields()
refreshWatchlists()
messageApi.success(t`Watchlist created !`)
}).catch((e: AxiosError) => {
showErrorAPI(e, messageApi)
})
}
const onUpdateWatchlist = async (values: FormValuesType & { token: string }) => await putWatchlist({
token: values.token,
...getRequestDataFromFormUpdate(values)
...getRequestDataFromFormCreation(values)
}
).then(() => {
refreshWatchlists()
@ -96,18 +74,18 @@ export default function WatchlistPage() {
return (
<Flex gap='middle' align='center' justify='center' vertical>
{contextHolder}
<Card size='small' loading={connectors === undefined} title={t`Create a Watchlist`} style={{width: '100%'}}>
{(connectors != null) &&
<WatchlistForm form={form} onFinish={onCreateWatchlist} connectors={connectors} isCreation/>}
</Card>
<Divider/>
{(connectors != null) && (watchlists != null) && watchlists.length > 0 &&
<WatchlistsList
watchlists={watchlists}
onDelete={refreshWatchlists}
connectors={connectors}
onUpdateWatchlist={onUpdateWatchlist}
/>}
<>
<CreateWatchlistButton onUpdateWatchlist={onCreateWatchlist} connectors={connectors} />
<Divider/>
<WatchlistsList
watchlists={watchlists}
onChange={refreshWatchlists}
connectors={connectors}
onUpdateWatchlist={onUpdateWatchlist}
/>
</>}
</Flex>
)
}

View File

@ -8,7 +8,7 @@ interface IcannAccreditationList {
export async function getIcannAccreditations(params: object): Promise<IcannAccreditationList> {
return (await request<IcannAccreditationList>({
url: 'entities/icann-accreditations',
url: 'icann-accreditations',
params
})).data
}

View File

@ -16,8 +16,6 @@ export type EventAction =
| 'enum validation expiration'
| string
export type TriggerAction = 'email' | 'chat'
export interface Event {
action: EventAction
date: string
@ -32,6 +30,11 @@ export interface Entity {
string,
string | string[],
]>] | []
remarks?: {
type: string
description: string
}[]
icannAccreditation?: IcannAccreditation
}
export interface Nameserver {
@ -59,7 +62,7 @@ export interface Domain {
entity: Entity
events: Event[]
roles: string[]
deleted: boolean
deletedAt?: string
}>
nameservers: Nameserver[]
tld: Tld
@ -74,18 +77,14 @@ export interface User {
roles: string[]
}
export interface WatchlistTrigger {
event: EventAction
action: TriggerAction
watchList?: string
}
export interface WatchlistRequest {
name?: string
domains: string[]
triggers?: Array<WatchlistTrigger>
trackedEvents?: string[]
trackedEppStatus?: string[]
connector?: string
dsn?: string[]
enabled?: boolean
}
export interface Watchlist {
@ -93,7 +92,8 @@ export interface Watchlist {
name?: string
token: string
domains: Domain[]
triggers?: Array<WatchlistTrigger>
trackedEvents?: string[]
trackedEppStatus?: string[]
dsn?: string[]
connector?: {
id: string
@ -101,6 +101,7 @@ export interface Watchlist {
createdAt: string
}
createdAt: string
enabled: boolean
}
export interface InstanceConfig {
@ -125,13 +126,11 @@ export interface TrackedDomains {
}
export interface IcannAccreditation {
handle: string
icannAccreditation: {
registrarName: string
status: string
date?: string
updated?: string
}
id: number
registrarName: string
status: string
date?: string
updated?: string
}
export async function request<T = object, R = AxiosResponse<T>, D = object>(config: AxiosRequestConfig): Promise<R> {

View File

@ -1,4 +1,4 @@
import type {TrackedDomains, Watchlist, WatchlistRequest, WatchlistTrigger} from './index'
import type {TrackedDomains, Watchlist, WatchlistRequest} from './index'
import {request} from './index'
interface WatchlistList {
@ -32,6 +32,18 @@ export async function postWatchlist(watchlist: WatchlistRequest) {
return response.data
}
export async function patchWatchlist(token: string, watchlist: Partial<WatchlistRequest>) {
const response = await request<{ token: string }>({
method: 'PATCH',
url: 'watchlists/' + token,
data: watchlist,
headers: {
'Content-Type': 'application/merge-patch+json'
}
})
return response.data
}
export async function deleteWatchlist(token: string): Promise<void> {
await request({
method: 'DELETE',
@ -56,20 +68,3 @@ export async function getTrackedDomainList(params: { page: number, itemsPerPage:
})
return response.data
}
export async function createWatchlistTrigger(watchListToken: string, watchListTrigger: WatchlistTrigger): Promise<WatchlistTrigger> {
const response = await request<WatchlistTrigger>({
method: 'POST',
url: `watchlist-triggers`,
data: watchListTrigger,
})
return response.data
}
export async function deleteWatchlistTrigger(watchListToken: string, watchListTrigger: WatchlistTrigger): Promise<void> {
await request<void>({
method: 'DELETE',
url: `watchlists/${watchListToken}/triggers/${watchListTrigger.action}/${watchListTrigger.event}`,
data: watchListTrigger
})
}

View File

@ -1,8 +1,9 @@
export const eppStatusCodeToColor = (s: string) =>
['active', 'ok'].includes(s)
? 'green'
: ['pending delete', 'redemption period'].includes(s)
? 'red'
: s.startsWith('client')
? 'purple'
: s.startsWith('server') ? 'geekblue' : 'blue'
export const eppStatusCodeToColor = (s?: string) =>
s === undefined ? 'default' :
['active', 'ok'].includes(s)
? 'green'
: ['pending delete', 'redemption period'].includes(s)
? 'red'
: s.startsWith('client')
? 'purple'
: s.startsWith('server') ? 'geekblue' : 'blue'

View File

@ -68,23 +68,7 @@ export const rdapEventDetailTranslation = () => ({
'enum validation expiration': t`Association of phone number represented by this ENUM domain to registrant has expired or will expire at a predetermined date and time.`
})
/**
* @see https://www.iana.org/assignments/rdap-json-values/rdap-json-values.xhtml
* @see https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en
*/
export const rdapStatusCodeDetailTranslation = () => ({
validated: t`Signifies that the data of the object instance has been found to be accurate.`,
'renew prohibited': t`Renewal or reregistration of the object instance is forbidden.`,
'update prohibited': t`Updates to the object instance are forbidden.`,
'transfer prohibited': t`Transfers of the registration from one registrar to another are forbidden.`,
'delete prohibited': t`Deletion of the registration of the object instance is forbidden.`,
proxy: t`The registration of the object instance has been performed by a third party.`,
private: t`The information of the object instance is not designated for public consumption.`,
removed: t`Some of the information of the object instance has not been made available and has been removed.`,
obscured: t`Some of the information of the object instance has been altered for the purposes of not readily revealing the actual information of the object instance.`,
associated: t`The object instance is associated with other object instances in the registry.`,
locked: t`Changes to the object instance cannot be made, including the association of other object instances.`,
export const rdapDomainStatusCodeDetailTranslation = () => ({
active: t`This is the standard status for a domain, meaning it has no pending operations or prohibitions.`,
inactive: t`This status code indicates that delegation information (name servers) has not been associated with your domain. Your domain is not activated in the DNS and will not resolve.`,
'pending create': t`This status code indicates that a request to create your domain has been received and is being processed.`,
@ -110,6 +94,34 @@ export const rdapStatusCodeDetailTranslation = () => ({
'server hold': t`This status code is set by your domain's Registry Operator. Your domain is not activated in the DNS.`,
'transfer period': t`This grace period is provided after the successful transfer of a domain name from one registrar to another. If the new registrar deletes the domain name during this period, the registry provides a credit to the registrar for the cost of the transfer.`,
})
/**
* @see https://www.iana.org/assignments/rdap-json-values/rdap-json-values.xhtml
* @see https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en
*/
export const rdapStatusCodeDetailTranslation = () => ({
validated: t`Signifies that the data of the object instance has been found to be accurate.`,
'renew prohibited': t`Renewal or reregistration of the object instance is forbidden.`,
'update prohibited': t`Updates to the object instance are forbidden.`,
'transfer prohibited': t`Transfers of the registration from one registrar to another are forbidden.`,
'delete prohibited': t`Deletion of the registration of the object instance is forbidden.`,
proxy: t`The registration of the object instance has been performed by a third party.`,
private: t`The information of the object instance is not designated for public consumption.`,
removed: t`Some of the information of the object instance has not been made available and has been removed.`,
obscured: t`Some of the information of the object instance has been altered for the purposes of not readily revealing the actual information of the object instance.`,
associated: t`The object instance is associated with other object instances in the registry.`,
locked: t`Changes to the object instance cannot be made, including the association of other object instances.`,
...rdapDomainStatusCodeDetailTranslation(),
administrative: t`The object instance has been allocated administratively (i.e., not for use by the recipient in their own right in operational networks).`,
reserved: t`The object instance has been allocated to an IANA special-purpose address registry.`
})
export const icannAccreditationTranslation = () => ({
Terminated: t`Terminated`,
Accredited: t`Accredited`,
Reserved: t`Reserved`
})

View File

@ -1,7 +1,7 @@
import type {Domain} from '../api'
export const sortDomainEntities = (domain: Domain) => domain.entities
.filter(e => !e.deleted)
.filter(e => e.deletedAt === undefined)
.sort((e1, e2) => {
const p = (r: string[]) => r.includes('registrant')
? 5

View File

@ -20,7 +20,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-simplexml": "*",
@ -42,53 +42,53 @@
"protonlabs/vobject": "^4.31",
"psr/http-client": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/cache": "7.1.*",
"symfony/console": "7.1.*",
"symfony/discord-notifier": "7.1.*",
"symfony/doctrine-messenger": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/engagespot-notifier": "7.1.*",
"symfony/expression-language": "7.1.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/cache": "7.3.*",
"symfony/console": "7.3.*",
"symfony/discord-notifier": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/engagespot-notifier": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/google-chat-notifier": "7.1.*",
"symfony/http-client": "7.1.*",
"symfony/intl": "7.1.*",
"symfony/lock": "7.1.*",
"symfony/mailer": "7.1.*",
"symfony/mattermost-notifier": "7.1.*",
"symfony/microsoft-teams-notifier": "7.1.*",
"symfony/mime": "7.1.*",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/google-chat-notifier": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/lock": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mattermost-notifier": "7.3.*",
"symfony/microsoft-teams-notifier": "7.3.*",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.1.*",
"symfony/ntfy-notifier": "7.1.*",
"symfony/process": "7.1.*",
"symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*",
"symfony/pushover-notifier": "7.1.*",
"symfony/rate-limiter": "7.1.*",
"symfony/redis-messenger": "7.1.*",
"symfony/rocket-chat-notifier": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/scheduler": "7.1.*",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",
"symfony/slack-notifier": "7.1.*",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/pushover-notifier": "7.3.*",
"symfony/rate-limiter": "7.3.*",
"symfony/redis-messenger": "7.3.*",
"symfony/rocket-chat-notifier": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/slack-notifier": "7.3.*",
"symfony/stimulus-bundle": "^2.18",
"symfony/string": "7.1.*",
"symfony/telegram-notifier": "7.1.*",
"symfony/translation": "7.1.*",
"symfony/twig-bundle": "7.1.*",
"symfony/uid": "7.1.*",
"symfony/string": "7.3.*",
"symfony/telegram-notifier": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/ux-turbo": "^2.18",
"symfony/validator": "7.1.*",
"symfony/web-link": "7.1.*",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.1.*",
"symfony/zulip-notifier": "7.1.*",
"symfony/yaml": "7.3.*",
"symfony/zulip-notifier": "7.3.*",
"symfonycasts/verify-email-bundle": "*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
@ -140,20 +140,23 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.1.*",
"require": "7.3.*",
"docker": true
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.2",
"friendsofphp/php-cs-fixer": "^3.61",
"justinrainbow/json-schema": "^6.6",
"phpstan/phpstan": "^1.11",
"phpunit/phpunit": "^10",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/debug-bundle": "7.1.*",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^7.1",
"symfony/stopwatch": "7.1.*",
"symfony/web-profiler-bundle": "7.1.*"
"symfony/phpunit-bridge": "^7.3",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*",
"zenstruck/foundry": "^2.7"
}
}

2099
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,4 +19,6 @@ return [
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
];

View File

@ -27,4 +27,23 @@ api_platform:
JWT:
name: Authorization
type: header
exception_to_status:
# The 4 following handlers are registered by default, keep those lines to prevent unexpected side effects
Symfony\Component\Serializer\Exception\ExceptionInterface: 400 # Use a raw status code (recommended)
ApiPlatform\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST
ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface: 400
Doctrine\ORM\OptimisticLockException: 409
# Validation exception
ApiPlatform\Validator\Exception\ValidationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY
App\Exception\DomainNotFoundException: 404
App\Exception\MalformedDomainException: 400
App\Exception\TldNotSupportedException: 400
App\Exception\UnknownRdapServerException: 400
App\Exception\UnsupportedDsnScheme: 400
# Provider exception
App\Exception\Provider\UserNoExplicitConsentException: 451
App\Exception\Provider\AbstractProviderException: 400
Metaregistrar\EPP\eppException: 400

11
config/packages/csrf.yaml Normal file
View File

@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@ -10,7 +10,7 @@ doctrine:
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
enable_native_lazy_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware

View File

@ -23,7 +23,7 @@ framework:
Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\OrderDomain: async
App\Message\ProcessWatchListsTrigger: async
App\Message\ProcessWatchlistTrigger: async
App\Message\SendDomainEventNotif: async
App\Message\UpdateDomainsFromWatchlist: async
App\Message\UpdateRdapServers: async

View File

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@ -17,7 +17,7 @@ framework:
user_register:
policy: token_bucket
limit: 1
limit: 5
rate: { interval: '5 minutes' }
rdap_requests:

View File

@ -0,0 +1,16 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
persistence:
# Flush only once per call of `PersistentObjectFactory::create()`
flush_once: true
# If you use the `make:factory --test` command, you may need to uncomment the following.
# See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate
#services:
# App\Tests\Factory\:
# resource: '%kernel.project_dir%/tests/Factory/'
# autowire: true
# autoconfigure: true
when@test: *dev

View File

@ -46,3 +46,11 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
when@test:
parameters:
gandi_pat_token: '%env(string:GANDI_PAT_TOKEN)%'
namecom_username: '%env(string:NAMECOM_USERNAME)%'
namecom_password: '%env(string:NAMECOM_PASSWORD)%'
namecheap_username: '%env(string:NAMECHEAP_USERNAME)%'
namecheap_token: '%env(string:NAMECHEAP_TOKEN)%'

View File

@ -1,42 +1,47 @@
# Please see https://github.com/maelgangloff/domain-watchdog
services:
domainwatchdog:
image: maelgangloff/domain-watchdog:latest
container_name: domainwatchdog_app
restart: unless-stopped
env_file:
- .env.local
environment:
APP_ENV: prod
SERVER_NAME: ${SERVER_NAME:-:80}
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@${POSTGRES_HOST:-database}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
APP_SECRET: ${APP_SECRET:-ChangeMe}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-true}
REGISTRATION_VERIFY_EMAIL: ${REGISTRATION_VERIFY_EMAIL:-false}
LIMITED_FEATURES: ${LIMITED_FEATURES:-false}
LIMIT_MAX_WATCHLIST: ${LIMIT_MAX_WATCHLIST:-0}
LIMIT_MAX_WATCHLIST_DOMAINS: ${LIMIT_MAX_WATCHLIST_DOMAINS:-0}
LIMIT_MAX_WATCHLIST_WEBHOOKS: ${LIMIT_MAX_WATCHLIST_WEBHOOKS:-0}
MAILER_DSN: ${MAILER_DSN:-null://null}
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
MESSENGER_TRANSPORT_DSN: redis://valkey:6379/messages
volumes:
- caddy_data:/data
- caddy_config:/config
- ./public/content:/app/public/content
ports:
- "127.0.0.1:8080:80"
depends_on:
- database
php-worker:
image: maelgangloff/domain-watchdog:latest
container_name: domainwatchdog_worker
restart: always
command: php /app/bin/console messenger:consume --all --time-limit=3600 -vvv
env_file:
- .env.local
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@${POSTGRES_HOST:-database}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
APP_SECRET: ${APP_SECRET:-ChangeMe}
MAILER_DSN: ${MAILER_DSN:-null://null}
APP_ENV: prod
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
MESSENGER_TRANSPORT_DSN: redis://valkey:6379/messages
depends_on:
- database
healthcheck:
disable: true
test: [ ]
# volumes:
# - ./custom_rdap_servers.yaml:/app/config/app/custom_rdap_servers.yaml # Please see #41 issue
disable: true
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
container_name: domainwatchdog_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
@ -49,25 +54,23 @@ services:
volumes:
- database_data:/var/lib/postgresql/data:rw
# keydb:
# image: eqalpha/keydb:latest
# container_name: keydb
# restart: always
# ports:
# - "127.0.0.1:6379:6379"
valkey:
image: valkey/valkey
container_name: valkey
restart: always
# influxdb2:
# image: influxdb:2
# ports:
# - "127.0.0.1:8086:8086"
# environment:
# DOCKER_INFLUXDB_INIT_MODE: setup
# DOCKER_INFLUXDB_INIT_USERNAME: USERNAME # Please modify
# DOCKER_INFLUXDB_INIT_PASSWORD: PASSWORD # Please modify
# DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-super-secret-auth-token # Please modify
# DOCKER_INFLUXDB_INIT_RETENTION: 0
# DOCKER_INFLUXDB_INIT_ORG: domainwatchdog
# DOCKER_INFLUXDB_INIT_BUCKET: domainwatchdog
# influxdb2:
# image: influxdb:2
# ports:
# - "127.0.0.1:8086:8086"
# environment:
# DOCKER_INFLUXDB_INIT_MODE: setup
# DOCKER_INFLUXDB_INIT_USERNAME: USERNAME # Please modify
# DOCKER_INFLUXDB_INIT_PASSWORD: PASSWORD # Please modify
# DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-super-secret-auth-token # Please modify
# DOCKER_INFLUXDB_INIT_RETENTION: 0
# DOCKER_INFLUXDB_INIT_ORG: domainwatchdog
# DOCKER_INFLUXDB_INIT_BUCKET: domainwatchdog
volumes:
caddy_data:

View File

@ -55,9 +55,6 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
php bin/console lexik:jwt:generate-keypair || true
php bin/console app:update-rdap-servers
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
fi
exec docker-php-entrypoint "$@"

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250915192826 extends AbstractMigration
{
public function getDescription(): string
{
return 'Move ICANN accreditation to table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE icann_accreditation_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE icann_accreditation (id INT NOT NULL, registrar_name VARCHAR(255) DEFAULT NULL, rdap_base_url VARCHAR(255) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, updated DATE DEFAULT NULL, date DATE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN icann_accreditation.updated IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN icann_accreditation.date IS \'(DC2Type:date_immutable)\'');
$this->addSql('ALTER TABLE entity ADD icann_accreditation_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE entity DROP icann_registrar_name');
$this->addSql('ALTER TABLE entity DROP icann_rdap_base_url');
$this->addSql('ALTER TABLE entity DROP icann_status');
$this->addSql('ALTER TABLE entity DROP icann_updated');
$this->addSql('ALTER TABLE entity DROP icann_date');
$this->addSql('DELETE FROM domain_entity de USING entity e WHERE de.entity_uid = e.id AND e.tld_id IS NULL');
$this->addSql('DELETE FROM entity_event ee USING entity e WHERE ee.entity_uid = e.id AND e.tld_id IS NULL');
$this->addSql('DELETE FROM nameserver_entity ne USING entity e WHERE ne.entity_uid = e.id AND e.tld_id IS NULL');
$this->addSql('DELETE FROM entity e WHERE e.tld_id IS NULL;');
$this->addSql('ALTER TABLE entity ALTER tld_id SET NOT NULL');
$this->addSql('ALTER TABLE entity ADD CONSTRAINT FK_E284468D77C9FEB FOREIGN KEY (icann_accreditation_id) REFERENCES icann_accreditation (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_E284468D77C9FEB ON entity (icann_accreditation_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE entity DROP CONSTRAINT FK_E284468D77C9FEB');
$this->addSql('DROP SEQUENCE icann_accreditation_id_seq CASCADE');
$this->addSql('DROP TABLE icann_accreditation');
$this->addSql('DROP INDEX IDX_E284468D77C9FEB');
$this->addSql('ALTER TABLE entity ADD icann_registrar_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE entity ADD icann_rdap_base_url VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE entity ADD icann_status VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE entity ADD icann_updated DATE DEFAULT NULL');
$this->addSql('ALTER TABLE entity ADD icann_date DATE DEFAULT NULL');
$this->addSql('ALTER TABLE entity DROP icann_accreditation_id');
$this->addSql('ALTER TABLE entity ALTER tld_id DROP NOT NULL');
$this->addSql('COMMENT ON COLUMN entity.icann_updated IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN entity.icann_date IS \'(DC2Type:date_immutable)\'');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250915200844 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove auto increment on id';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP SEQUENCE icann_accreditation_id_seq CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE icann_accreditation_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250915213341 extends AbstractMigration
{
public function getDescription(): string
{
return 'deleted_at on domain_entity';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE domain_entity ADD deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('UPDATE domain_entity SET deleted_at = NOW() WHERE deleted IS TRUE');
$this->addSql('ALTER TABLE domain_entity DROP deleted');
$this->addSql('COMMENT ON COLUMN domain_entity.deleted_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE domain_entity ADD deleted BOOLEAN NOT NULL DEFAULT FALSE');
$this->addSql('ALTER TABLE domain_entity DROP deleted_at');
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251004101245 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// 1. Ajouter les nouvelles colonnes sans contrainte NOT NULL
$this->addSql('ALTER TABLE "user" ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE "user" ADD verified_at TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN "user".created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN "user".verified_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('UPDATE "user" SET created_at = TO_TIMESTAMP(0)');
$this->addSql('UPDATE "user" SET verified_at = TO_TIMESTAMP(0) WHERE is_verified = true');
$this->addSql('ALTER TABLE "user" ALTER COLUMN created_at SET NOT NULL');
$this->addSql('ALTER TABLE "user" DROP is_verified');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT TRUE');
$this->addSql('ALTER TABLE "user" DROP created_at');
$this->addSql('ALTER TABLE "user" DROP verified_at');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251008094821 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted_at column on tld table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE tld ADD deleted_at DATE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN tld.deleted_at IS \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE tld DROP deleted_at');
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251015165917 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove watchlist_trigger and add tracked_events';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE watch_list ADD tracked_events JSONB');
$this->addSql("
UPDATE watch_list wl
SET tracked_events = sub.events::jsonb
FROM (
SELECT watch_list_id, json_agg(event) AS events
FROM watch_list_trigger
WHERE action = 'email'
GROUP BY watch_list_id
) AS sub
WHERE wl.token = sub.watch_list_id
");
$this->addSql("UPDATE watch_list SET tracked_events = '[]' WHERE tracked_events IS NULL");
$this->addSql('ALTER TABLE watch_list ALTER tracked_events SET NOT NULL');
$this->addSql('ALTER TABLE watch_list_trigger DROP CONSTRAINT fk_cf857a4cc4508918');
$this->addSql('DROP TABLE watch_list_trigger');
}
public function down(Schema $schema): void
{
$this->addSql('CREATE TABLE watch_list_trigger (event VARCHAR(255) NOT NULL, action VARCHAR(255) NOT NULL, watch_list_id UUID NOT NULL, PRIMARY KEY(event, watch_list_id, action))');
$this->addSql('CREATE INDEX idx_cf857a4cc4508918 ON watch_list_trigger (watch_list_id)');
$this->addSql('COMMENT ON COLUMN watch_list_trigger.watch_list_id IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE watch_list_trigger ADD CONSTRAINT fk_cf857a4cc4508918 FOREIGN KEY (watch_list_id) REFERENCES watch_list (token) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_list DROP tracked_events');
}
}

View File

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251016193639 extends AbstractMigration
{
public function getDescription(): string
{
return 'Convert domain status to JSONB';
}
public function up(Schema $schema): void
{
// domain.status
$this->addSql("ALTER TABLE domain ADD status_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE domain
SET status_jsonb = to_jsonb(string_to_array(status, ','))
WHERE status IS NOT NULL AND status <> ''
");
$this->addSql('ALTER TABLE domain DROP COLUMN status');
$this->addSql('ALTER TABLE domain RENAME COLUMN status_jsonb TO status');
$this->addSql('COMMENT ON COLUMN domain.status IS NULL');
// domain_entity.roles
$this->addSql("ALTER TABLE domain_entity ADD roles_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE domain_entity
SET roles_jsonb = to_jsonb(string_to_array(roles, ','))
WHERE roles IS NOT NULL AND roles <> ''
");
$this->addSql('ALTER TABLE domain_entity DROP COLUMN roles');
$this->addSql('ALTER TABLE domain_entity RENAME COLUMN roles_jsonb TO roles');
$this->addSql('COMMENT ON COLUMN domain_entity.roles IS NULL');
// nameserver_entity.roles
$this->addSql("ALTER TABLE nameserver_entity ADD roles_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE nameserver_entity
SET roles_jsonb = to_jsonb(string_to_array(roles, ','))
WHERE roles IS NOT NULL AND roles <> ''
");
$this->addSql('ALTER TABLE nameserver_entity DROP COLUMN roles');
$this->addSql('ALTER TABLE nameserver_entity RENAME COLUMN roles_jsonb TO roles');
$this->addSql('COMMENT ON COLUMN nameserver_entity.roles IS NULL');
// nameserver_entity.status
$this->addSql("ALTER TABLE nameserver_entity ADD status_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE nameserver_entity
SET status_jsonb = to_jsonb(string_to_array(status, ','))
WHERE status IS NOT NULL AND status <> ''
");
$this->addSql('ALTER TABLE nameserver_entity DROP COLUMN status');
$this->addSql('ALTER TABLE nameserver_entity RENAME COLUMN status_jsonb TO status');
$this->addSql('COMMENT ON COLUMN nameserver_entity.status IS NULL');
// domain_status.add_status
$this->addSql("ALTER TABLE domain_status ADD add_status_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE domain_status
SET add_status_jsonb = to_jsonb(string_to_array(add_status, ','))
WHERE add_status IS NOT NULL AND add_status <> ''
");
$this->addSql('ALTER TABLE domain_status DROP COLUMN add_status');
$this->addSql('ALTER TABLE domain_status RENAME COLUMN add_status_jsonb TO add_status');
$this->addSql('COMMENT ON COLUMN domain_status.add_status IS NULL');
// domain_status.delete_status
$this->addSql("ALTER TABLE domain_status ADD delete_status_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE domain_status
SET delete_status_jsonb = to_jsonb(string_to_array(delete_status, ','))
WHERE delete_status IS NOT NULL AND delete_status <> ''
");
$this->addSql('ALTER TABLE domain_status DROP COLUMN delete_status');
$this->addSql('ALTER TABLE domain_status RENAME COLUMN delete_status_jsonb TO delete_status');
$this->addSql('COMMENT ON COLUMN domain_status.delete_status IS NULL');
// watch_list.webhook_dsn
$this->addSql("ALTER TABLE watch_list ADD webhook_dsn_jsonb JSONB DEFAULT '[]'::jsonb");
$this->addSql("
UPDATE watch_list
SET webhook_dsn_jsonb = to_jsonb(string_to_array(webhook_dsn, ','))
WHERE webhook_dsn IS NOT NULL AND webhook_dsn <> ''
");
$this->addSql('ALTER TABLE watch_list DROP COLUMN webhook_dsn');
$this->addSql('ALTER TABLE watch_list RENAME COLUMN webhook_dsn_jsonb TO webhook_dsn');
$this->addSql('COMMENT ON COLUMN watch_list.webhook_dsn IS NULL');
$this->addSql('ALTER TABLE domain ALTER status DROP DEFAULT');
$this->addSql('ALTER TABLE domain_entity ALTER roles DROP DEFAULT');
$this->addSql('ALTER TABLE domain_entity ALTER roles SET NOT NULL');
$this->addSql('ALTER TABLE domain_status ALTER add_status DROP DEFAULT');
$this->addSql('ALTER TABLE domain_status ALTER delete_status DROP DEFAULT');
$this->addSql('ALTER TABLE nameserver_entity ALTER roles DROP DEFAULT');
$this->addSql('ALTER TABLE nameserver_entity ALTER roles SET NOT NULL');
$this->addSql('ALTER TABLE nameserver_entity ALTER status DROP DEFAULT');
$this->addSql('ALTER TABLE nameserver_entity ALTER status SET NOT NULL');
$this->addSql('ALTER TABLE watch_list ALTER webhook_dsn DROP DEFAULT');
}
public function down(Schema $schema): void
{
// domain.status
$this->addSql('ALTER TABLE domain ADD status_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE domain
SET status_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(status)
), ',')
WHERE status IS NOT NULL
");
$this->addSql('ALTER TABLE domain DROP COLUMN status');
$this->addSql('ALTER TABLE domain RENAME COLUMN status_text TO status');
$this->addSql('COMMENT ON COLUMN domain.status IS NULL');
// domain_entity.roles
$this->addSql('ALTER TABLE domain_entity ADD roles_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE domain_entity
SET roles_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(roles)
), ',')
WHERE roles IS NOT NULL
");
$this->addSql('ALTER TABLE domain_entity DROP COLUMN roles');
$this->addSql('ALTER TABLE domain_entity RENAME COLUMN roles_text TO roles');
$this->addSql('COMMENT ON COLUMN domain_entity.roles IS NULL');
// nameserver_entity.roles
$this->addSql('ALTER TABLE nameserver_entity ADD roles_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE nameserver_entity
SET roles_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(roles)
), ',')
WHERE roles IS NOT NULL
");
$this->addSql('ALTER TABLE nameserver_entity DROP COLUMN roles');
$this->addSql('ALTER TABLE nameserver_entity RENAME COLUMN roles_text TO roles');
$this->addSql('COMMENT ON COLUMN nameserver_entity.roles IS NULL');
// nameserver_entity.status
$this->addSql('ALTER TABLE nameserver_entity ADD status_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE nameserver_entity
SET status_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(status)
), ',')
WHERE status IS NOT NULL
");
$this->addSql('ALTER TABLE nameserver_entity DROP COLUMN status');
$this->addSql('ALTER TABLE nameserver_entity RENAME COLUMN status_text TO status');
$this->addSql('COMMENT ON COLUMN nameserver_entity.status IS NULL');
// domain_status.add_status
$this->addSql('ALTER TABLE domain_status ADD add_status_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE domain_status
SET add_status_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(add_status)
), ',')
WHERE add_status IS NOT NULL
");
$this->addSql('ALTER TABLE domain_status DROP COLUMN add_status');
$this->addSql('ALTER TABLE domain_status RENAME COLUMN add_status_text TO add_status');
$this->addSql('COMMENT ON COLUMN domain_status.add_status IS NULL');
// domain_status.delete_status
$this->addSql('ALTER TABLE domain_status ADD delete_status_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE domain_status
SET delete_status_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(delete_status)
), ',')
WHERE delete_status IS NOT NULL
");
$this->addSql('ALTER TABLE domain_status DROP COLUMN delete_status');
$this->addSql('ALTER TABLE domain_status RENAME COLUMN delete_status_text TO delete_status');
$this->addSql('COMMENT ON COLUMN domain_status.delete_status IS NULL');
// watch_list.webhook_dsn
$this->addSql('ALTER TABLE watch_list ADD webhook_dsn_text TEXT DEFAULT NULL');
$this->addSql("
UPDATE watch_list
SET webhook_dsn_text = array_to_string(ARRAY(
SELECT jsonb_array_elements_text(webhook_dsn)
), ',')
WHERE webhook_dsn IS NOT NULL
");
$this->addSql('ALTER TABLE watch_list DROP COLUMN webhook_dsn');
$this->addSql('ALTER TABLE watch_list RENAME COLUMN webhook_dsn_text TO webhook_dsn');
$this->addSql('COMMENT ON COLUMN watch_list.webhook_dsn IS NULL');
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251019120358 extends AbstractMigration
{
public function getDescription(): string
{
return 'Lowercase on columns';
}
public function up(Schema $schema): void
{
$this->addSql("UPDATE domain
SET status = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(status) = 'array' THEN status
WHEN jsonb_typeof(status) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(status)))
ELSE '[]'::jsonb
END
) AS t(value)
)");
$this->addSql("UPDATE domain_status
SET add_status = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(add_status) = 'array' THEN add_status
WHEN jsonb_typeof(add_status) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(add_status)))
ELSE '[]'::jsonb
END
) AS t(value)
), delete_status = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(delete_status) = 'array' THEN delete_status
WHEN jsonb_typeof(delete_status) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(delete_status)))
ELSE '[]'::jsonb
END
) AS t(value)
)");
$this->addSql("UPDATE domain_entity
SET roles = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(roles) = 'array' THEN roles
WHEN jsonb_typeof(roles) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(roles)))
ELSE '[]'::jsonb
END
) AS t(value)
)");
$this->addSql("UPDATE nameserver_entity
SET roles = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(roles) = 'array' THEN roles
WHEN jsonb_typeof(roles) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(roles)))
ELSE '[]'::jsonb
END
) AS t(value)
), status = (
SELECT jsonb_agg(lower(value::text)::jsonb)
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(status) = 'array' THEN status
WHEN jsonb_typeof(status) = 'object' THEN to_jsonb(array(SELECT jsonb_array_elements_text(jsonb_agg(value)) FROM jsonb_each_text(status)))
ELSE '[]'::jsonb
END
) AS t(value)
)");
$this->addSql('UPDATE domain_event SET action = lower(action)');
$this->addSql('UPDATE entity_event SET action = lower(action)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251019211214 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tracked_epp_status on watchlist';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE watch_list ADD tracked_epp_status JSONB DEFAULT '[]'::jsonb");
$this->addSql('ALTER TABLE watch_list ALTER tracked_epp_status DROP DEFAULT ');
$this->addSql('ALTER TABLE watch_list ALTER tracked_epp_status SET NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE watch_list DROP tracked_epp_status');
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251025152900 extends AbstractMigration
{
public function getDescription(): string
{
return 'Rename WatchList to Watchlist';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE watch_lists_domains DROP CONSTRAINT fk_f693e1d0d52d7aa6');
$this->addSql('ALTER TABLE watch_list DROP CONSTRAINT fk_152b584b4d085745');
$this->addSql('ALTER TABLE watch_list DROP CONSTRAINT fk_152b584ba76ed395');
$this->addSql('ALTER TABLE watch_list RENAME TO watchlist');
$this->addSql('ALTER INDEX idx_152b584ba76ed395 RENAME TO IDX_340388D3A76ED395');
$this->addSql('ALTER INDEX idx_152b584b4d085745 RENAME TO IDX_340388D34D085745');
$this->addSql('ALTER TABLE watchlist ADD CONSTRAINT FK_340388D3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watchlist ADD CONSTRAINT FK_340388D34D085745 FOREIGN KEY (connector_id) REFERENCES connector (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_lists_domains ADD CONSTRAINT FK_F693E1D0D52D7AA6 FOREIGN KEY (watch_list_token) REFERENCES watchlist (token) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_lists_domains DROP CONSTRAINT fk_f693e1d0af923913');
$this->addSql('ALTER TABLE watch_lists_domains DROP CONSTRAINT fk_f693e1d0d52d7aa6');
$this->addSql('ALTER TABLE watch_lists_domains RENAME TO watchlist_domains');
$this->addSql('ALTER INDEX idx_f693e1d0af923913 RENAME TO IDX_196DE762AF923913');
$this->addSql('ALTER INDEX idx_f693e1d0d52d7aa6 RENAME TO IDX_196DE762F1E43AD7');
$this->addSql('ALTER TABLE watchlist_domains RENAME COLUMN watch_list_token TO watchlist_token');
$this->addSql('COMMENT ON COLUMN watchlist_domains.watchlist_token IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE watchlist_domains ADD CONSTRAINT FK_196DE762F1E43AD7 FOREIGN KEY (watchlist_token) REFERENCES watchlist (token) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watchlist_domains ADD CONSTRAINT FK_196DE762AF923913 FOREIGN KEY (domain_ldh_name) REFERENCES domain (ldh_name) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE watch_lists_domains DROP CONSTRAINT FK_F693E1D0D52D7AA6');
$this->addSql('ALTER TABLE watchlist DROP CONSTRAINT FK_340388D3A76ED395');
$this->addSql('ALTER TABLE watchlist DROP CONSTRAINT FK_340388D34D085745');
$this->addSql('ALTER TABLE watchlist RENAME TO watch_list');
$this->addSql('ALTER INDEX IDX_340388D3A76ED395 RENAME TO idx_152b584ba76ed395');
$this->addSql('ALTER INDEX IDX_340388D34D085745 RENAME TO idx_152b584b4d085745');
$this->addSql('ALTER TABLE watch_list ADD CONSTRAINT fk_152b584ba76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_list ADD CONSTRAINT fk_152b584b4d085745 FOREIGN KEY (connector_id) REFERENCES connector (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_lists_domains ADD CONSTRAINT fk_f693e1d0d52d7aa6 FOREIGN KEY (watch_list_token) REFERENCES watch_list (token) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watchlist_domains DROP CONSTRAINT FK_196DE762F1E43AD7');
$this->addSql('ALTER TABLE watchlist_domains DROP CONSTRAINT FK_196DE762AF923913');
$this->addSql('ALTER TABLE watchlist_domains RENAME COLUMN watchlist_token TO watch_list_token');
$this->addSql('ALTER TABLE watchlist_domains RENAME TO watch_lists_domains');
$this->addSql('ALTER INDEX IDX_196DE762AF923913 RENAME TO idx_f693e1d0af923913');
$this->addSql('ALTER INDEX IDX_196DE762F1E43AD7 RENAME TO idx_f693e1d0d52d7aa6');
$this->addSql('COMMENT ON COLUMN watch_lists_domains.watch_list_token IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE watch_lists_domains ADD CONSTRAINT fk_f693e1d0af923913 FOREIGN KEY (domain_ldh_name) REFERENCES domain (ldh_name) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE watch_lists_domains ADD CONSTRAINT fk_f693e1d0d52d7aa6 FOREIGN KEY (watch_list_token) REFERENCES watchlist (token) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251025160938 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add enabled flag on watchlist';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE watchlist ADD enabled BOOLEAN NOT NULL DEFAULT true');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE watchlist DROP enabled');
}
}

View File

@ -6,3 +6,4 @@ parameters:
- public/
- src/
- tests/
- migrations/

View File

@ -30,5 +30,6 @@
</source>
<extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension" />
</extensions>
</phpunit>

View File

@ -1,28 +0,0 @@
<?php
namespace App\Api\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Entity;
use Doctrine\ORM\QueryBuilder;
class NotNullAccreditationIcannExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (Entity::class !== $resourceClass) {
return;
}
if ($operation && 'icann_accreditations_collection' === $operation->getName()) {
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.icannAccreditation.status IS NOT NULL', $rootAlias));
}
}
}

View File

@ -2,7 +2,7 @@
namespace App\Command;
use App\Message\ProcessWatchListsTrigger;
use App\Message\ProcessWatchlistTrigger;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@ -12,10 +12,10 @@ use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:process-watchlists',
description: 'Process watchlists and send emails if necessary',
name: 'app:process-watchlist',
description: 'Process watchlist and send emails if necessary',
)]
class ProcessWatchlistsCommand extends Command
class ProcessWatchlistCommand extends Command
{
public function __construct(private readonly MessageBusInterface $bus)
{
@ -33,7 +33,7 @@ class ProcessWatchlistsCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$this->bus->dispatch(new ProcessWatchListsTrigger());
$this->bus->dispatch(new ProcessWatchlistTrigger());
$io->success('Watchlist processing triggered!');

View File

@ -2,7 +2,7 @@
namespace App\Command;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
@ -48,7 +48,7 @@ class RegisterDomainCommand extends Command
try {
if (null !== $domain && !$force) {
if (!$domain->isToBeUpdated(true, true)) {
if (!$this->rdapService->isToBeUpdated($domain, true, true)) {
$io->warning('The domain name is already present in the database and does not need to be updated at this time.');
return Command::SUCCESS;
@ -60,11 +60,11 @@ class RegisterDomainCommand extends Command
if ($notif) {
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($domain->getWatchLists()->toArray());
$watchlists = $randomizer->shuffleArray($domain->getWatchlists()->toArray());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
/** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
}
}
} catch (\Throwable $e) {

View File

@ -2,12 +2,12 @@
namespace App\Config;
use App\Service\Connector\AutodnsProvider;
use App\Service\Connector\EppClientProvider;
use App\Service\Connector\GandiProvider;
use App\Service\Connector\NamecheapProvider;
use App\Service\Connector\NameComProvider;
use App\Service\Connector\OvhProvider;
use App\Service\Provider\AutodnsProvider;
use App\Service\Provider\EppClientProvider;
use App\Service\Provider\GandiProvider;
use App\Service\Provider\NamecheapProvider;
use App\Service\Provider\NameComProvider;
use App\Service\Provider\OvhProvider;
enum ConnectorProvider: string
{

View File

@ -1,9 +0,0 @@
<?php
namespace App\Config;
enum TriggerAction: string
{
case SendEmail = 'email';
case SendChat = 'chat';
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Random\Randomizer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class DomainRefreshController extends AbstractController
{
public function __construct(private readonly DomainRepository $domainRepository,
private readonly RDAPService $RDAPService,
private readonly RateLimiterFactory $rdapRequestsLimiter,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger,
private readonly KernelInterface $kernel,
) {
}
/**
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
* @throws ExceptionInterface
* @throws \Exception
* @throws HttpExceptionInterface
* @throws \Throwable
*/
public function __invoke(string $ldhName, Request $request): Domain
{
$idnDomain = RDAPService::convertToIdn($ldhName);
$userId = $this->getUser()->getUserIdentifier();
$this->logger->info('User {username} wants to update the domain name {idnDomain}.', [
'username' => $userId,
'idnDomain' => $idnDomain,
]);
/** @var ?Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]);
// If the domain name exists in the database, recently updated and not important, we return the stored Domain
if (null !== $domain
&& !$domain->getDeleted()
&& !$domain->isToBeUpdated(true, true)
&& !$this->kernel->isDebug()
&& true !== filter_var($request->get('forced', false), FILTER_VALIDATE_BOOLEAN)
) {
$this->logger->info('It is not necessary to update the information of the domain name {idnDomain} with the RDAP protocol.', [
'idnDomain' => $idnDomain,
]);
return $domain;
}
if (false === $this->kernel->isDebug() && true === $this->getParameter('limited_features')) {
$limiter = $this->rdapRequestsLimiter->create($userId);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
}
$updatedAt = null === $domain ? new \DateTimeImmutable('now') : $domain->getUpdatedAt();
$domain = $this->RDAPService->registerDomain($idnDomain);
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($domain->getWatchLists()->toArray());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
}
return $domain;
}
}

View File

@ -5,15 +5,19 @@ namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
class HomeController extends AbstractController
{
public function __construct(private readonly RouterInterface $router)
{
public function __construct(
private readonly RouterInterface $router,
private readonly ParameterBagInterface $parameterBag,
) {
}
#[Route(path: '/', name: 'index')]
@ -25,7 +29,10 @@ class HomeController extends AbstractController
#[Route(path: '/login/oauth', name: 'oauth_connect')]
public function connectAction(ClientRegistry $clientRegistry): Response
{
return $clientRegistry->getClient('oauth')->redirect([], []);
if ($this->parameterBag->get('oauth_enabled')) {
return $clientRegistry->getClient('oauth')->redirect([], []);
}
throw new NotFoundHttpException();
}
#[Route(path: '/logout', name: 'logout')]

View File

@ -21,6 +21,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class RegistrationController extends AbstractController
{
@ -33,6 +34,7 @@ class RegistrationController extends AbstractController
private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger,
private readonly KernelInterface $kernel,
private readonly ValidatorInterface $validator,
) {
}
@ -44,7 +46,7 @@ class RegistrationController extends AbstractController
name: 'user_register',
defaults: [
'_api_resource_class' => User::class,
'_api_operation_name' => 'register',
'_api_operation_name' => 'user_register',
],
methods: ['POST']
)]
@ -64,22 +66,21 @@ class RegistrationController extends AbstractController
}
$user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']);
if (null === $user->getEmail() || null === $user->getPassword()) {
throw new BadRequestHttpException('Bad request');
$violations = $this->validator->validate($user);
if ($violations->count() > 0) {
throw new BadRequestHttpException($violations->get(0));
}
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$user->getPassword()
$user->getPlainPassword()
)
);
$this->em->persist($user);
$this->em->flush();
)->setCreatedAt(new \DateTimeImmutable());
if (false === (bool) $this->getParameter('registration_verify_email')) {
$user->setVerified(true);
$user->setVerifiedAt($user->getCreatedAt());
} else {
$email = $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
@ -91,13 +92,16 @@ class RegistrationController extends AbstractController
);
$signedUrl = (string) $email->getContext()['signedUrl'];
$this->logger->notice('The validation link for user {username} is {signedUrl}', [
$this->logger->notice('The validation link for this user is generated', [
'username' => $user->getUserIdentifier(),
'signedUrl' => $signedUrl,
]);
}
$this->logger->info('A new user has registered ({username}).', [
$this->em->persist($user);
$this->em->flush();
$this->logger->info('New user has registered', [
'username' => $user->getUserIdentifier(),
]);
@ -121,7 +125,7 @@ class RegistrationController extends AbstractController
$this->emailVerifier->handleEmailConfirmation($request, $user);
$this->logger->info('User {username} has validated his email address.', [
$this->logger->info('User has validated his email address', [
'username' => $user->getUserIdentifier(),
]);

View File

@ -4,7 +4,7 @@ namespace App\Controller;
use App\Entity\Statistics;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Repository\WatchlistRepository;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -15,7 +15,7 @@ class StatisticsController extends AbstractController
public function __construct(
private readonly CacheItemPoolInterface $pool,
private readonly DomainRepository $domainRepository,
private readonly WatchListRepository $watchListRepository,
private readonly WatchlistRepository $watchlistRepository,
private readonly KernelInterface $kernel,
) {
}
@ -34,22 +34,10 @@ class StatisticsController extends AbstractController
->setAlertSent($this->pool->getItem('stats.alert.sent')->get() ?? 0)
->setDomainTracked(
$this->getCachedItem('stats.domain.tracked', fn () => $this->watchListRepository->createQueryBuilder('w')
->join('w.domains', 'd')
->select('COUNT(DISTINCT d.ldhName)')
->where('d.deleted = FALSE')
->getQuery()->getSingleColumnResult()[0])
$this->getCachedItem('stats.domain.tracked', fn () => $this->watchlistRepository->getTrackedDomainCount())
)
->setDomainCount(
$this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->createQueryBuilder('d')
->join('d.tld', 't')
->select('t.tld tld')
->addSelect('COUNT(d.ldhName) AS domain')
->addGroupBy('t.tld')
->where('d.deleted = FALSE')
->orderBy('domain', 'DESC')
->setMaxResults(5)
->getQuery()->getArrayResult())
$this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->getActiveDomainCountByTld())
)
->setDomainCountTotal(
$this->getCachedItem('stats.domain.total', fn () => $this->domainRepository->count(['deleted' => false])

View File

@ -6,8 +6,11 @@ use App\Entity\Domain;
use App\Entity\DomainEvent;
use App\Entity\DomainStatus;
use App\Entity\User;
use App\Entity\WatchList;
use App\Repository\WatchListRepository;
use App\Entity\Watchlist;
use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository;
use App\Service\CalendarService;
use App\Service\RDAPService;
use Doctrine\Common\Collections\Collection;
use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Presentation\Component\Property;
@ -23,10 +26,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WatchListController extends AbstractController
class WatchlistController extends AbstractController
{
public function __construct(
private readonly WatchListRepository $watchListRepository,
private readonly WatchlistRepository $watchlistRepository,
private readonly RDAPService $RDAPService,
private readonly CalendarService $calendarService,
private readonly DomainRepository $domainRepository,
) {
}
@ -34,17 +40,17 @@ class WatchListController extends AbstractController
path: '/api/watchlists',
name: 'watchlist_get_all_mine',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_all_mine',
],
methods: ['GET']
)]
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
/** @var User $user */
$user = $this->getUser();
return $user->getWatchLists();
return $user->getWatchlists();
}
/**
@ -57,26 +63,26 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/calendar',
name: 'watchlist_calendar',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'calendar',
]
)]
public function getWatchlistCalendar(string $token): Response
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$calendar = new Calendar();
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
foreach ($domain->getDomainCalendarEvents() as $event) {
foreach ($watchlist->getDomains()->getIterator() as $domain) {
foreach ($this->calendarService->getDomainCalendarEvents($domain) as $event) {
$calendar->addEvent($event);
}
}
$calendarResponse = (new CalendarFactory())->createCalendar($calendar);
$calendarName = $watchList->getName();
$calendarName = $watchlist->getName();
if (null !== $calendarName) {
$calendarResponse->withProperty(new Property('X-WR-CALNAME', new TextValue($calendarName)));
}
@ -93,7 +99,7 @@ class WatchListController extends AbstractController
path: '/api/tracked',
name: 'watchlist_get_tracked_domains',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_tracked_domains',
]
)]
@ -102,18 +108,9 @@ class WatchListController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$domains = [];
/** @var WatchList $watchList */
foreach ($user->getWatchLists()->getIterator() as $watchList) {
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
/** @var DomainEvent|null $exp */
$exp = $domain->getEvents()->findFirst(fn (int $key, DomainEvent $e) => !$e->getDeleted() && 'expiration' === $e->getAction());
if (!$domain->getDeleted() && null !== $exp && !in_array($domain, $domains)) {
$domains[] = $domain;
}
}
$domains = $this->domainRepository->getMyTrackedDomains($user);
foreach ($domains as $domain) {
$domain->setExpiresInDays($this->RDAPService->getExpiresInDays($domain));
}
usort($domains, fn (Domain $d1, Domain $d2) => $d1->getExpiresInDays() - $d2->getExpiresInDays());
@ -128,14 +125,14 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/rss/events',
name: 'watchlist_rss_events',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_events',
]
)]
public function getWatchlistRssEventsFeed(string $token, Request $request): Response
{
/** @var WatchList $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed())
->setLanguage('en')
@ -166,14 +163,14 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/rss/status',
name: 'watchlist_rss_status',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_status',
]
)]
public function getWatchlistRssStatusFeed(string $token, Request $request): Response
{
/** @var WatchList $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed())
->setLanguage('en')

View File

@ -0,0 +1,15 @@
<?php
namespace App\DataFixtures;
use App\Story\DefaultUsersStory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
DefaultUsersStory::load();
}
}

View File

@ -27,7 +27,7 @@ use Symfony\Component\Uid\Uuid;
),
new Get(
normalizationContext: ['groups' => 'connector:list'],
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Post(
normalizationContext: ['groups' => ['connector:create', 'connector:list']],
@ -35,7 +35,7 @@ use Symfony\Component\Uid\Uuid;
processor: ConnectorCreateProcessor::class
),
new Delete(
security: 'object.user == user',
security: 'object.getUser() == user',
processor: ConnectorDeleteProcessor::class
),
]
@ -61,10 +61,10 @@ class Connector
private array $authData = [];
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'connector')]
private Collection $watchLists;
#[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'connector')]
private Collection $watchlists;
#[Groups(['connector:list', 'watchlist:list'])]
#[ORM\Column]
@ -76,7 +76,7 @@ class Connector
public function __construct()
{
$this->id = Uuid::v4();
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
}
public function getId(): ?string
@ -121,29 +121,29 @@ class Connector
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlist(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->setConnector($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->setConnector($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlist(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
if ($this->watchlists->removeElement($watchlist)) {
// set the owning side to null (unless already changed)
if ($watchList->getConnector() === $this) {
$watchList->setConnector(null);
if ($watchlist->getConnector() === $this) {
$watchlist->setConnector(null);
}
}
@ -164,6 +164,6 @@ class Connector
public function getWatchlistCount(): ?int
{
return $this->watchLists->count();
return $this->watchlists->count();
}
}

View File

@ -2,29 +2,21 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\Config\EventAction;
use App\Controller\DomainRefreshController;
use App\Exception\MalformedDomainException;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use App\State\AutoRegisterDomainProvider;
use App\State\FindDomainCollectionFromEntityProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Eluceo\iCal\Domain\Entity\Attendee;
use Eluceo\iCal\Domain\Entity\Event;
use Eluceo\iCal\Domain\Enum\EventStatus;
use Eluceo\iCal\Domain\ValueObject\Category;
use Eluceo\iCal\Domain\ValueObject\Date;
use Eluceo\iCal\Domain\ValueObject\EmailAddress;
use Eluceo\iCal\Domain\ValueObject\SingleDay;
use Eluceo\iCal\Domain\ValueObject\Timestamp;
use Sabre\VObject\EofException;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
@ -40,9 +32,23 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
]
),
*/
new GetCollection(
uriTemplate: '/domains',
normalizationContext: [
'groups' => [
'domain:list',
'tld:list',
'event:list',
'event:list',
],
],
provider: FindDomainCollectionFromEntityProvider::class,
parameters: [
'registrant' => new QueryParameter(description: 'The exact name of the registrant (case insensitive)', required: true),
]
),
new Get(
uriTemplate: '/domains/{ldhName}', // Do not delete this line, otherwise Symfony interprets the TLD of the domain name as a return type
controller: DomainRefreshController::class,
normalizationContext: [
'groups' => [
'domain:item',
@ -54,7 +60,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
'ds:list',
],
],
read: false
),
],
provider: AutoRegisterDomainProvider::class
@ -75,25 +80,30 @@ class Domain
*/
#[ORM\OneToMany(targetEntity: DomainEvent::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item', 'domain:list', 'watchlist:list'])]
#[ApiProperty(
openapiContext: [
'type' => 'array',
]
)]
private Collection $events;
/**
* @var Collection<int, DomainEntity>
*/
#[ORM\OneToMany(targetEntity: DomainEntity::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'watchlist:item'])]
#[SerializedName('entities')]
private Collection $domainEntities;
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item', 'domain:list', 'watchlist:item', 'watchlist:list'])]
private array $status = [];
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\ManyToMany(targetEntity: WatchList::class, mappedBy: 'domains', cascade: ['persist'])]
private Collection $watchLists;
#[ORM\ManyToMany(targetEntity: Watchlist::class, mappedBy: 'domains', cascade: ['persist'])]
private Collection $watchlists;
/**
* @var Collection<int, Nameserver>
@ -103,7 +113,7 @@ class Domain
joinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'nameserver_ldh_name', referencedColumnName: 'ldh_name')]
)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'watchlist:item'])]
private Collection $nameservers;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
@ -135,7 +145,7 @@ class Domain
#[ORM\Column(nullable: false, options: ['default' => false])]
#[Groups(['domain:item', 'domain:list'])]
private ?bool $delegationSigned = null;
private bool $delegationSigned = false;
/**
* @var Collection<int, DnsKey>
@ -144,6 +154,8 @@ class Domain
#[Groups(['domain:item'])]
private Collection $dnsKey;
private ?int $expiresInDays;
private const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value];
private const IMPORTANT_STATUS = [
'redemption period',
@ -160,7 +172,7 @@ class Domain
{
$this->events = new ArrayCollection();
$this->domainEntities = new ArrayCollection();
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
$this->nameservers = new ArrayCollection();
$this->updatedAt = new \DateTimeImmutable('now');
$this->createdAt = $this->updatedAt;
@ -174,6 +186,9 @@ class Domain
return $this->ldhName;
}
/**
* @throws MalformedDomainException
*/
public function setLdhName(string $ldhName): static
{
$this->ldhName = RDAPService::convertToIdn($ldhName);
@ -266,27 +281,27 @@ class Domain
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlists(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->addDomain($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->addDomain($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlists(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
$watchList->removeDomain($this);
if ($this->watchlists->removeElement($watchlist)) {
$watchlist->removeDomain($this);
}
return $this;
@ -333,7 +348,7 @@ class Domain
return $this;
}
private function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
@ -378,7 +393,7 @@ class Domain
*
* @throws \Exception
*/
protected function isToBeWatchClosely(): bool
public function isToBeWatchClosely(): bool
{
$status = $this->getStatus();
if ((!empty($status) && count(array_intersect($status, self::IMPORTANT_STATUS))) || $this->getDeleted()) {
@ -395,47 +410,6 @@ class Domain
return !empty($events) && in_array($events[0]->getAction(), self::IMPORTANT_EVENTS);
}
/**
* Returns true if one or more of these conditions are met:
* - It has been more than 7 days since the domain name was last updated
* - It has been more than 12 minutes and the domain name has statuses that suggest it is not stable
* - It has been more than 1 day and the domain name is blocked in DNS
*
* @throws \Exception
*/
public function isToBeUpdated(bool $fromUser = true, bool $intensifyLastDay = false): bool
{
$updatedAtDiff = $this->getUpdatedAt()->diff(new \DateTimeImmutable());
if ($updatedAtDiff->days >= 7) {
return true;
}
if ($this->getDeleted()) {
return $fromUser;
}
$expiresIn = $this->getExpiresInDays();
if ($intensifyLastDay && (0 === $expiresIn || 1 === $expiresIn)) {
return true;
}
$minutesDiff = $updatedAtDiff->h * 60 + $updatedAtDiff->i;
if (($minutesDiff >= 12 || $fromUser) && $this->isToBeWatchClosely()) {
return true;
}
if (
count(array_intersect($this->getStatus(), ['auto renew period', 'client hold', 'server hold'])) > 0
&& $updatedAtDiff->days >= 1
) {
return true;
}
return false;
}
/**
* @return Collection<int, DomainStatus>
*/
@ -500,122 +474,6 @@ class Domain
return in_array('pending delete', $this->getStatus()) && !in_array('redemption period', $this->getStatus());
}
/**
* @throws \DateMalformedIntervalStringException
*/
private function calculateDaysFromStatus(\DateTimeImmutable $now): ?int
{
$lastStatus = $this->getDomainStatuses()->last();
if (false === $lastStatus) {
return null;
}
if ($this->isPendingDelete() && (
in_array('pending delete', $lastStatus->getAddStatus())
|| in_array('redemption period', $lastStatus->getDeleteStatus()))
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'. 5 .'D')));
}
if ($this->isRedemptionPeriod()
&& in_array('redemption period', $lastStatus->getAddStatus())
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'.(30 + 5).'D')));
}
return null;
}
/*
private function calculateDaysFromEvents(\DateTimeImmutable $now): ?int
{
$lastChangedEvent = $this->getEvents()->findFirst(fn (int $i, DomainEvent $e) => !$e->getDeleted() && EventAction::LastChanged->value === $e->getAction());
if (null === $lastChangedEvent) {
return null;
}
if ($this->isRedemptionPeriod()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'.(30 + 5).'D')));
}
if ($this->isPendingDelete()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'. 5 .'D')));
}
return null;
}
*/
private static function daysBetween(\DateTimeImmutable $start, \DateTimeImmutable $end): int
{
$interval = $start->setTime(0, 0)->diff($end->setTime(0, 0));
return $interval->invert ? -$interval->days : $interval->days;
}
private static function returnExpiresIn(array $guesses): ?int
{
$filteredGuesses = array_filter($guesses, function ($value) {
return null !== $value;
});
if (empty($filteredGuesses)) {
return null;
}
return max(min($filteredGuesses), 0);
}
/**
* @throws \Exception
*/
private function getRelevantDates(): array
{
$expiredAt = $deletedAt = null;
foreach ($this->getEvents()->getIterator() as $event) {
if (!$event->getDeleted()) {
if ('expiration' === $event->getAction()) {
$expiredAt = $event->getDate();
} elseif ('deletion' === $event->getAction()) {
$deletedAt = $event->getDate();
}
}
}
return [$expiredAt, $deletedAt];
}
/**
* @throws \Exception
*/
#[Groups(['domain:item', 'domain:list'])]
public function getExpiresInDays(): ?int
{
if ($this->getDeleted()) {
return null;
}
$now = new \DateTimeImmutable();
[$expiredAt, $deletedAt] = $this->getRelevantDates();
if ($expiredAt) {
$guess = self::daysBetween($now, $expiredAt->add(new \DateInterval('P'.(45 + 30 + 5).'D')));
}
if ($deletedAt) {
// It has been observed that AFNIC, on the last day, adds a "deleted" event and removes the redemption period status.
if (0 === self::daysBetween($now, $deletedAt) && $this->isPendingDelete()) {
return 0;
}
$guess = self::daysBetween($now, $deletedAt->add(new \DateInterval('P'. 30 .'D')));
}
return self::returnExpiresIn([
$guess ?? null,
$this->calculateDaysFromStatus($now),
]);
}
/**
* @return Collection<int, DnsKey>
*/
@ -646,69 +504,16 @@ class Domain
return $this;
}
/**
* @return Event[]
*
* @throws ParseException
* @throws EofException
* @throws InvalidDataException
* @throws \Exception
*/
public function getDomainCalendarEvents(): array
#[Groups(['domain:item', 'domain:list'])]
public function getExpiresInDays(): ?int
{
$events = [];
$attendees = [];
return $this->expiresInDays;
}
/* @var DomainEntity $entity */
foreach ($this->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeleted())->getIterator() as $domainEntity) {
$jCard = $domainEntity->getEntity()->getJCard();
public function setExpiresInDays(?int $expiresInDays): static
{
$this->expiresInDays = $expiresInDays;
if (empty($jCard)) {
continue;
}
$vCardData = Reader::readJson($jCard);
if (empty($vCardData->EMAIL) || empty($vCardData->FN)) {
continue;
}
$email = (string) $vCardData->EMAIL;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$attendees[] = (new Attendee(new EmailAddress($email)))->setDisplayName((string) $vCardData->FN);
}
/** @var DomainEvent $event */
foreach ($this->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) {
$events[] = (new Event())
->setLastModified(new Timestamp($this->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($this->getLdhName().': '.$event->getAction())
->addCategory(new Category($event->getAction()))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date($event->getDate()))
);
}
$expiresInDays = $this->getExpiresInDays();
if (null !== $expiresInDays) {
$events[] = (new Event())
->setLastModified(new Timestamp($this->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($this->getLdhName().': estimated WHOIS release date')
->addCategory(new Category('release'))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date(
(new \DateTimeImmutable())->setTime(0, 0)->add(new \DateInterval('P'.$expiresInDays.'D'))
))
);
}
return $events;
return $this;
}
}

View File

@ -23,18 +23,13 @@ class DomainEntity
#[Groups(['domain-entity:entity'])]
private ?Entity $entity = null;
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private array $roles = [];
#[ORM\Column]
#[ORM\Column(nullable: true)]
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private ?bool $deleted;
public function __construct()
{
$this->deleted = false;
}
private ?\DateTimeImmutable $deletedAt = null;
public function getDomain(): ?Domain
{
@ -75,14 +70,14 @@ class DomainEntity
return $this;
}
public function getDeleted(): ?bool
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deleted;
return $this->deletedAt;
}
public function setDeleted(?bool $deleted): static
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deleted = $deleted;
$this->deletedAt = $deletedAt;
return $this;
}

View File

@ -27,11 +27,11 @@ class DomainStatus
#[Groups(['domain:item'])]
private \DateTimeImmutable $date;
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item'])]
private array $addStatus = [];
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item'])]
private array $deleteStatus = [];

View File

@ -2,15 +2,12 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\EntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embedded;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
@ -20,31 +17,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
)]
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/entities/icann-accreditations',
openapiContext: [
'parameters' => [
[
'name' => 'icannAccreditation.status',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['Accredited', 'Terminated', 'Reserved'],
],
],
'style' => 'form',
'explode' => true,
'description' => 'Filter by ICANN accreditation status',
],
],
],
description: 'ICANN Registrar IDs list',
normalizationContext: ['groups' => ['entity:list']],
name: 'icann_accreditations_collection'
),
/*
new GetCollection(
uriTemplate: '/entities',
@ -67,12 +39,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
*/
]
)]
#[ApiFilter(
SearchFilter::class,
properties: [
'icannAccreditation.status' => 'exact',
]
)]
class Entity
{
#[ORM\Id]
@ -81,12 +47,12 @@ class Entity
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Tld::class, inversedBy: 'entities')]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: true)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: false)]
#[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?Tld $tld = null;
#[ORM\Column(length: 255)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
#[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?string $handle = null;
/**
@ -112,15 +78,21 @@ class Entity
#[Groups(['entity:item', 'domain:item'])]
private Collection $events;
#[ORM\Column]
#[Groups(['entity:item', 'domain:item'])]
#[ORM\Column(type: 'json')]
#[ApiProperty(
openapiContext: [
'type' => 'array',
'items' => ['type' => 'array'],
]
)]
#[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private array $jCard = [];
#[ORM\Column(nullable: true)]
#[Groups(['entity:item', 'domain:item'])]
#[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private ?array $remarks = null;
#[Embedded(class: IcannAccreditation::class, columnPrefix: 'icann_')]
#[ORM\ManyToOne(inversedBy: 'entities')]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
private ?IcannAccreditation $icannAccreditation = null;
@ -129,7 +101,6 @@ class Entity
$this->domainEntities = new ArrayCollection();
$this->nameserverEntities = new ArrayCollection();
$this->events = new ArrayCollection();
$this->icannAccreditation = new IcannAccreditation();
}
public function getHandle(): ?string
@ -284,11 +255,13 @@ class Entity
public function getIcannAccreditation(): ?IcannAccreditation
{
return null === $this->icannAccreditation->getStatus() ? null : $this->icannAccreditation;
return $this->icannAccreditation;
}
public function setIcannAccreditation(?IcannAccreditation $icannAccreditation): void
public function setIcannAccreditation(?IcannAccreditation $icannAccreditation): static
{
$this->icannAccreditation = $icannAccreditation;
return $this;
}
}

View File

@ -2,35 +2,92 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Config\RegistrarStatus;
use App\Repository\IcannAccreditationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Attribute\Groups;
#[Embeddable]
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/icann-accreditations',
openapiContext: [
'parameters' => [
[
'name' => 'status',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['Accredited', 'Terminated', 'Reserved'],
],
],
'style' => 'form',
'explode' => true,
'description' => 'Filter by ICANN accreditation status',
],
],
],
shortName: 'ICANN Accreditation',
description: 'ICANN Registrar IDs list',
normalizationContext: ['groups' => ['icann:list']]
),
]
)]
#[ApiFilter(
SearchFilter::class,
properties: [
'status' => 'exact',
]
)]
#[ORM\Entity(repositoryClass: IcannAccreditationRepository::class)]
class IcannAccreditation
{
#[ORM\Id]
#[ORM\Column]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?string $registrarName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'domain:item'])]
#[Groups(['icann:item'])]
private ?string $rdapBaseUrl = null;
#[ORM\Column(nullable: true, enumType: RegistrarStatus::class)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?RegistrarStatus $status = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?\DateTimeImmutable $updated = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?\DateTimeImmutable $date = null;
/**
* @var Collection<int, Entity>
*/
#[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'icannAccreditation')]
private Collection $entities;
public function __construct()
{
$this->entities = new ArrayCollection();
}
public function getRegistrarName(): ?string
{
return $this->registrarName;
@ -90,4 +147,46 @@ class IcannAccreditation
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): static
{
$this->id = $id;
return $this;
}
/**
* @return Collection<int, Entity>
*/
public function getEntities(): Collection
{
return $this->entities;
}
public function addEntity(Entity $entity): static
{
if (!$this->entities->contains($entity)) {
$this->entities->add($entity);
$entity->setIcannAccreditation($this);
}
return $this;
}
public function removeEntity(Entity $entity): static
{
if ($this->entities->removeElement($entity)) {
// set the owning side to null (unless already changed)
if ($entity->getIcannAccreditation() === $this) {
$entity->setIcannAccreditation(null);
}
}
return $this;
}
}

View File

@ -56,7 +56,6 @@ class Nameserver
* @var Collection<int, Domain>
*/
#[ORM\ManyToMany(targetEntity: Domain::class, mappedBy: 'nameservers')]
#[Groups(['nameserver:item'])]
private Collection $domains;
public function __construct()

View File

@ -23,11 +23,11 @@ class NameserverEntity
#[Groups(['nameserver-entity:entity'])]
private ?Entity $entity = null;
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['nameserver-entity:entity', 'nameserver-entity:nameserver'])]
private array $roles = [];
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['nameserver-entity:entity', 'nameserver-entity:nameserver'])]
private array $status = [];

View File

@ -77,6 +77,9 @@ class Tld
#[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'tld')]
private Collection $entities;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->rdapServers = new ArrayCollection();
@ -241,4 +244,16 @@ class Tld
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}

View File

@ -15,6 +15,8 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
@ -46,6 +48,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 180)]
#[Groups(['user:list', 'user:register'])]
#[Assert\Email]
#[Assert\NotBlank]
private ?string $email = null;
/**
@ -59,14 +63,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var string|null The hashed password
*/
#[ORM\Column(nullable: true)]
#[Groups(['user:register'])]
private ?string $password = null;
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $watchLists;
#[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $watchlists;
/**
* @var Collection<int, Connector>
@ -75,11 +78,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private Collection $connectors;
#[ORM\Column]
private bool $isVerified = false;
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $verifiedAt = null;
#[Assert\PasswordStrength]
#[Assert\NotBlank]
#[SerializedName('password')]
#[Groups(['user:register'])]
private ?string $plainPassword = null;
public function __construct()
{
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
$this->connectors = new ArrayCollection();
}
@ -152,33 +164,33 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
$this->plainPassword = null;
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlist(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->setUser($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->setUser($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlist(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
if ($this->watchlists->removeElement($watchlist)) {
// set the owning side to null (unless already changed)
if ($watchList->getUser() === $this) {
$watchList->setUser(null);
if ($watchlist->getUser() === $this) {
$watchlist->setUser(null);
}
}
@ -215,14 +227,38 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isVerified(): bool
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->isVerified;
return $this->createdAt;
}
public function setVerified(bool $isVerified): static
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->isVerified = $isVerified;
$this->createdAt = $createdAt;
return $this;
}
public function getVerifiedAt(): ?\DateTimeImmutable
{
return $this->verifiedAt;
}
public function setVerifiedAt(\DateTimeImmutable $verifiedAt): static
{
$this->verifiedAt = $verifiedAt;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): self
{
$this->plainPassword = $plainPassword;
return $this;
}

View File

@ -1,94 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Config\TriggerAction;
use App\Repository\EventTriggerRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: EventTriggerRepository::class)]
#[ApiResource(
uriTemplate: '/watchlists/{watchListId}/triggers/{action}/{event}',
shortName: 'Watchlist Trigger',
operations: [
new Get(),
new GetCollection(
uriTemplate: '/watchlists/{watchListId}/triggers',
uriVariables: [
'watchListId' => new Link(fromProperty: 'token', toProperty: 'watchList', fromClass: WatchList::class),
],
),
new Post(
uriTemplate: '/watchlist-triggers',
uriVariables: [],
security: 'true'
),
new Delete(),
],
uriVariables: [
'watchListId' => new Link(fromProperty: 'token', toProperty: 'watchList', fromClass: WatchList::class),
'action' => 'action',
'event' => 'event',
],
security: 'object.getWatchList().user == user',
)]
class WatchListTrigger
{
#[ORM\Id]
#[ORM\Column(length: 255, nullable: false)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?string $event;
#[ORM\Id]
#[ORM\ManyToOne(targetEntity: WatchList::class, inversedBy: 'watchListTriggers')]
#[ORM\JoinColumn(referencedColumnName: 'token', nullable: false, onDelete: 'CASCADE')]
private ?WatchList $watchList;
#[ORM\Id]
#[ORM\Column(nullable: false, enumType: TriggerAction::class)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?TriggerAction $action;
public function getEvent(): ?string
{
return $this->event;
}
public function setEvent(string $event): static
{
$this->event = $event;
return $this;
}
public function getWatchList(): ?WatchList
{
return $this->watchList;
}
public function setWatchList(?WatchList $watchList): static
{
$this->watchList = $watchList;
return $this;
}
public function getAction(): ?TriggerAction
{
return $this->action;
}
public function setAction(TriggerAction $action): static
{
$this->action = $action;
return $this;
}
}

View File

@ -6,10 +6,11 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\WatchListRepository;
use App\State\WatchListUpdateProcessor;
use App\Repository\WatchlistRepository;
use App\State\WatchlistUpdateProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@ -17,8 +18,9 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: WatchListRepository::class)]
#[ORM\Entity(repositoryClass: WatchlistRepository::class)]
#[ApiResource(
shortName: 'Watchlist',
operations: [
@ -41,7 +43,6 @@ use Symfony\Component\Uid\Uuid;
'domain:list',
'tld:list',
'event:list',
'domain:list',
'event:list',
],
],
@ -51,7 +52,7 @@ use Symfony\Component\Uid\Uuid;
normalizationContext: [
'groups' => [
'watchlist:item',
'domain:item',
'domain:list',
'event:list',
'domain-entity:entity',
'nameserver-entity:nameserver',
@ -59,7 +60,7 @@ use Symfony\Component\Uid\Uuid;
'tld:item',
],
],
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Get(
routeName: 'watchlist_calendar',
@ -86,16 +87,22 @@ use Symfony\Component\Uid\Uuid;
new Post(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => 'watchlist:create'],
processor: WatchListUpdateProcessor::class,
processor: WatchlistUpdateProcessor::class,
),
new Put(
normalizationContext: ['groups' => 'watchlist:item'],
denormalizationContext: ['groups' => ['watchlist:create', 'watchlist:token']],
security: 'object.user == user',
processor: WatchListUpdateProcessor::class,
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
),
new Patch(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
),
new Delete(
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Get(
routeName: 'watchlist_rss_status',
@ -145,9 +152,9 @@ use Symfony\Component\Uid\Uuid;
),
],
)]
class WatchList
class Watchlist
{
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'watchLists')]
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'watchlists')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public ?User $user = null;
@ -159,27 +166,19 @@ class WatchList
/**
* @var Collection<int, Domain>
*/
#[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchLists')]
#[ORM\JoinTable(name: 'watch_lists_domains',
joinColumns: [new ORM\JoinColumn(name: 'watch_list_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
#[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchlists')]
#[ORM\JoinTable(name: 'watchlist_domains',
joinColumns: [new ORM\JoinColumn(name: 'watchlist_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name', onDelete: 'CASCADE')])]
#[Groups(['watchlist:create', 'watchlist:list', 'watchlist:item'])]
#[Groups(['watchlist:create', 'watchlist:list', 'watchlist:item', 'watchlist:update'])]
private Collection $domains;
/**
* @var Collection<int, WatchListTrigger>
*/
#[ORM\OneToMany(targetEntity: WatchListTrigger::class, mappedBy: 'watchList', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[SerializedName('triggers')]
private Collection $watchListTriggers;
#[ORM\ManyToOne(inversedBy: 'watchLists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[ORM\ManyToOne(inversedBy: 'watchlists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?Connector $connector = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?string $name = null;
#[ORM\Column]
@ -187,15 +186,42 @@ class WatchList
private ?\DateTimeImmutable $createdAt = null;
#[SerializedName('dsn')]
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private ?array $webhookDsn = null;
#[ORM\Column(type: Types::JSON)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\NotBlank]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private array $trackedEvents = [];
#[ORM\Column(type: Types::JSON)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private array $trackedEppStatus = [];
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?bool $enabled = null;
public function __construct()
{
$this->token = Uuid::v4();
$this->domains = new ArrayCollection();
$this->watchListTriggers = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable('now');
}
@ -245,36 +271,6 @@ class WatchList
return $this;
}
/**
* @return Collection<int, WatchListTrigger>
*/
public function getWatchListTriggers(): Collection
{
return $this->watchListTriggers;
}
public function addWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if (!$this->watchListTriggers->contains($watchListTrigger)) {
$this->watchListTriggers->add($watchListTrigger);
$watchListTrigger->setWatchList($this);
}
return $this;
}
public function removeWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if ($this->watchListTriggers->removeElement($watchListTrigger)) {
// set the owning side to null (unless already changed)
if ($watchListTrigger->getWatchList() === $this) {
$watchListTrigger->setWatchList(null);
}
}
return $this;
}
public function getConnector(): ?Connector
{
return $this->connector;
@ -322,4 +318,40 @@ class WatchList
return $this;
}
public function getTrackedEvents(): array
{
return $this->trackedEvents;
}
public function setTrackedEvents(array $trackedEvents): static
{
$this->trackedEvents = $trackedEvents;
return $this;
}
public function getTrackedEppStatus(): array
{
return $this->trackedEppStatus;
}
public function setTrackedEppStatus(array $trackedEppStatus): static
{
$this->trackedEppStatus = $trackedEppStatus;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class DomainNotFoundException extends \Exception
{
public static function fromDomain(string $ldhName): self
{
return new self("The domain name $ldhName is not present in the WHOIS database");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class MalformedDomainException extends \Exception
{
public static function fromDomain(string $ldhName): self
{
return new self("Malformed domain name ($ldhName)");
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exception\Provider;
abstract class AbstractProviderException extends \Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exception\Provider;
class DomainOrderFailedExeption extends AbstractProviderException
{
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class EppContactIsAvailableException extends AbstractProviderException
{
public static function fromContact(string $handle): self
{
return new self("At least one of the entered contacts cannot be used because it is indicated as available ($handle)");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class ExpiredLoginException extends AbstractProviderException
{
public static function fromIdentifier(string $identifier): self
{
return new self("Expired login for identifier $identifier");
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Exception\Provider;
class InvalidLoginException extends AbstractProviderException
{
public function __construct(string $message = '')
{
parent::__construct('' === $message ? 'The status of these credentials is not valid' : $message);
}
public static function fromIdentifier(string $identifier): self
{
return new self("Invalid login for identifier $identifier");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class InvalidLoginStatusException extends AbstractProviderException
{
public static function fromStatus(string $status): self
{
return new self("The status of these credentials is not valid ($status)");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class NamecheapRequiresAddressException extends AbstractProviderException
{
public function __construct()
{
parent::__construct('Namecheap account requires at least one address to purchase a domain');
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class PermissionErrorException extends AbstractProviderException
{
public static function fromIdentifier(string $identifier): self
{
return new self("Not enough permissions for identifier $identifier");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class ProviderGenericErrorException extends AbstractProviderException
{
public function __construct(string $message)
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class UserNoExplicitConsentException extends AbstractProviderException
{
public function __construct()
{
parent::__construct('The user has not given explicit consent');
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class TldNotSupportedException extends \Exception
{
public static function fromTld(string $tld): self
{
return new self("The requested TLD $tld is not yet supported, please try again with another one");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class UnknownRdapServerException extends \Exception
{
public static function fromTld(string $tld): self
{
return new self("TLD $tld: Unable to determine which RDAP server to contact");
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class UnsupportedDsnSchemeException extends \Exception
{
public static function fromScheme(string $scheme): UnsupportedDsnSchemeException
{
return new UnsupportedDsnSchemeException("The DSN scheme ($scheme) is not supported");
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Factory;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* @extends PersistentObjectFactory<User>
*/
final class UserFactory extends PersistentObjectFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {
}
#[\Override]
public static function class(): string
{
return User::class;
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
#[\Override]
protected function defaults(): array|callable
{
$createdAt = \DateTimeImmutable::createFromMutable(self::faker()->dateTime());
$plainPassword = self::faker()->password(16, 20);
return [
'createdAt' => $createdAt,
'verifiedAt' => $createdAt,
'email' => self::faker()->unique()->safeEmail(),
'plainPassword' => $plainPassword,
'roles' => ['ROLE_USER'],
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
#[\Override]
protected function initialize(): static
{
return $this->afterInstantiate(function (User $user): void {
if ($user->getPlainPassword()) {
$user->setPassword(
$this->passwordHasher->hashPassword($user, $user->getPlainPassword())
);
}
});
}
}

View File

@ -5,7 +5,7 @@ namespace App\Message;
final class OrderDomain
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
public string $ldhName,
) {
}

View File

@ -2,7 +2,7 @@
namespace App\Message;
final class ProcessWatchListsTrigger
final class ProcessWatchlistTrigger
{
/*
* Add whatever properties and methods you need

View File

@ -5,7 +5,7 @@ namespace App\Message;
final class SendDomainEventNotif
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
public string $ldhName,
public \DateTimeImmutable $updatedAt,
) {

View File

@ -5,7 +5,7 @@ namespace App\Message;
final readonly class UpdateDomainsFromWatchlist
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
) {
}
}

View File

@ -3,15 +3,15 @@
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Message\OrderDomain;
use App\Notifier\DomainOrderErrorNotification;
use App\Notifier\DomainOrderNotification;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\InfluxdbService;
use App\Service\Provider\AbstractProvider;
use App\Service\StatService;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -31,7 +31,7 @@ final readonly class OrderDomainHandler
public function __construct(
string $mailerSenderEmail,
string $mailerSenderName,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private DomainRepository $domainRepository,
private KernelInterface $kernel,
private MailerInterface $mailer,
@ -54,12 +54,12 @@ final readonly class OrderDomainHandler
*/
public function __invoke(OrderDomain $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$connector = $watchList->getConnector();
$connector = $watchlist->getConnector();
/*
* We make sure that the domain name is marked absent from WHOIS in the database before continuing.
@ -71,8 +71,8 @@ final readonly class OrderDomainHandler
return;
}
$this->logger->notice('Watchlist {watchlist} is linked to connector {connector}. A purchase attempt will be made for domain name {ldhName} with provider {provider}.', [
'watchlist' => $message->watchListToken,
$this->logger->notice('Watchlist is linked to a connector : a purchase attempt will be made for this domain name', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
@ -93,14 +93,15 @@ final readonly class OrderDomainHandler
* The user is authenticated to ensure that the credentials are still valid.
* If no errors occur, the purchase is attempted.
*/
$connectorProvider->authenticate($connector->getAuthData());
$connectorProvider->orderDomain($domain, $this->kernel->isDebug());
/*
* If the purchase was successful, the statistics are updated and a success message is sent to the user.
*/
$this->logger->notice('Watchlist {watchlist} is linked to connector {connector}. A purchase was successfully made for domain {ldhName} with provider {provider}.', [
'watchlist' => $message->watchListToken,
$this->logger->notice('Watchlist is linked to connector : a purchase was successfully made for this domain name', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
@ -111,15 +112,18 @@ final readonly class OrderDomainHandler
$this->influxdbService->addDomainOrderPoint($connector, $domain, true);
}
$notification = (new DomainOrderNotification($this->sender, $domain, $connector));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
} catch (\Throwable $exception) {
/*
* The purchase was not successful (for several possible reasons that we have not determined).
* The user is informed and the exception is raised, which may allow you to try again.
*/
$this->logger->warning('Unable to complete purchase. An error message is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
$this->logger->warning('Unable to complete purchase : an error message is sent to the user', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
]);
$this->statService->incrementStat('stats.domain.purchase.failed');
@ -127,8 +131,8 @@ final readonly class OrderDomainHandler
$this->influxdbService->addDomainOrderPoint($connector, $domain, false);
}
$notification = (new DomainOrderErrorNotification($this->sender, $domain));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
throw $exception;
}

View File

@ -2,20 +2,20 @@
namespace App\MessageHandler;
use App\Entity\WatchList;
use App\Message\ProcessWatchListsTrigger;
use App\Entity\Watchlist;
use App\Message\ProcessWatchlistTrigger;
use App\Message\UpdateDomainsFromWatchlist;
use App\Repository\WatchListRepository;
use App\Repository\WatchlistRepository;
use Random\Randomizer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class ProcessWatchListsTriggerHandler
final readonly class ProcessWatchlistTriggerHandler
{
public function __construct(
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private MessageBusInterface $bus,
) {
}
@ -23,7 +23,7 @@ final readonly class ProcessWatchListsTriggerHandler
/**
* @throws ExceptionInterface
*/
public function __invoke(ProcessWatchListsTrigger $message): void
public function __invoke(ProcessWatchlistTrigger $message): void
{
/*
* We shuffle the watch lists to process them in an order that we consider random.
@ -31,11 +31,11 @@ final readonly class ProcessWatchListsTriggerHandler
*/
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($this->watchListRepository->findAll());
$watchlists = $randomizer->shuffleArray($this->watchlistRepository->getEnabledWatchlist());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchList->getToken()));
/** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchlist->getToken()));
}
}
}

View File

@ -2,15 +2,17 @@
namespace App\MessageHandler;
use App\Config\TriggerAction;
use App\Entity\Domain;
use App\Entity\DomainEvent;
use App\Entity\WatchList;
use App\Entity\WatchListTrigger;
use App\Entity\DomainStatus;
use App\Entity\Watchlist;
use App\Message\SendDomainEventNotif;
use App\Notifier\DomainStatusUpdateNotification;
use App\Notifier\DomainUpdateNotification;
use App\Repository\DomainEventRepository;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Repository\DomainStatusRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\InfluxdbService;
use App\Service\StatService;
@ -19,7 +21,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Recipient\Recipient;
@ -35,11 +36,13 @@ final readonly class SendDomainEventNotifHandler
private MailerInterface $mailer,
private StatService $statService,
private DomainRepository $domainRepository,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private ChatNotificationService $chatNotificationService,
#[Autowire(param: 'influxdb_enabled')]
private bool $influxdbEnabled,
private InfluxdbService $influxdbService,
private DomainEventRepository $domainEventRepository,
private DomainStatusRepository $domainStatusRepository,
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
@ -47,56 +50,99 @@ final readonly class SendDomainEventNotifHandler
/**
* @throws TransportExceptionInterface
* @throws \Exception
* @throws ExceptionInterface
*/
public function __invoke(SendDomainEventNotif $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$recipient = new Recipient($watchlist->getUser()->getEmail());
/*
* For each new event whose date is after the domain name update date (before the current domain name update)
*/
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(
fn ($event) => $message->updatedAt < $event->getDate() && $event->getDate() < new \DateTimeImmutable()) as $event
) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn ($trigger) => $trigger->getEvent() === $event->getAction());
/** @var DomainEvent[] $newEvents */
$newEvents = $this->domainEventRepository->findNewDomainEvents($domain, $message->updatedAt);
/*
* For each trigger, we perform the appropriate action: send email or send push notification (for now)
*/
foreach ($newEvents as $event) {
if (!in_array($event->getAction(), $watchlist->getTrackedEvents())) {
continue;
}
/** @var WatchListTrigger $watchListTrigger */
foreach ($watchListTriggers->getIterator() as $watchListTrigger) {
$this->logger->info('Action {event} has been detected on the domain name {ldhName}. A notification is sent to user {username}.', [
$notification = new DomainUpdateNotification($this->sender, $event);
$this->logger->info('New action has been detected on this domain name : an email is sent to user', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'email', true);
}
$webhookDsn = $watchlist->getWebhookDsn();
if (null !== $webhookDsn && 0 !== count($webhookDsn)) {
$this->logger->info('New action has been detected on this domain name : a notification is sent to user', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchList->getUser()->getUserIdentifier(),
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$recipient = new Recipient($watchList->getUser()->getEmail());
$notification = new DomainUpdateNotification($this->sender, $event);
if (TriggerAction::SendEmail == $watchListTrigger->getAction()) {
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
} elseif (TriggerAction::SendChat == $watchListTrigger->getAction()) {
$webhookDsn = $watchList->getWebhookDsn();
if (null === $webhookDsn || 0 === count($webhookDsn)) {
continue;
}
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'chat', true);
}
}
$this->statService->incrementStat('stats.alert.sent');
}
/** @var DomainStatus $domainStatus */
$domainStatus = $this->domainStatusRepository->findNewDomainStatus($domain, $message->updatedAt);
if (null !== $domainStatus && count(array_intersect(
$watchlist->getTrackedEppStatus(),
[...$domainStatus->getAddStatus(), ...$domainStatus->getDeleteStatus()]
))) {
$notification = new DomainStatusUpdateNotification($this->sender, $domain, $domainStatus);
$this->logger->info('New domain status has been detected on this domain name : an email is sent to user', [
'addStatus' => $domainStatus->getAddStatus(),
'deleteStatus' => $domainStatus->getDeleteStatus(),
'status' => $domain->getStatus(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'email', true);
}
$webhookDsn = $watchlist->getWebhookDsn();
if (null !== $webhookDsn && 0 !== count($webhookDsn)) {
$this->logger->info('New domain status has been detected on this domain name : a notification is sent to user', [
'addStatus' => $domainStatus->getAddStatus(),
'deleteStatus' => $domainStatus->getDeleteStatus(),
'status' => $domain->getStatus(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, TriggerAction::SendChat, true);
$this->influxdbService->addDomainNotificationPoint($domain, 'chat', true);
}
$this->statService->incrementStat('stats.alert.sent');
}
$this->statService->incrementStat('stats.alert.sent');
}
}
}

View File

@ -3,21 +3,23 @@
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Exception\DomainNotFoundException;
use App\Exception\TldNotSupportedException;
use App\Exception\UnknownRdapServerException;
use App\Message\OrderDomain;
use App\Message\SendDomainEventNotif;
use App\Message\UpdateDomainsFromWatchlist;
use App\Notifier\DomainDeletedNotification;
use App\Notifier\DomainUpdateErrorNotification;
use App\Repository\WatchListRepository;
use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\Connector\CheckDomainProviderInterface;
use App\Service\Provider\AbstractProvider;
use App\Service\Provider\CheckDomainProviderInterface;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
@ -36,10 +38,11 @@ final readonly class UpdateDomainsFromWatchlistHandler
string $mailerSenderEmail,
string $mailerSenderName,
private MessageBusInterface $bus,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private LoggerInterface $logger,
#[Autowire(service: 'service_container')]
private ContainerInterface $locator,
private DomainRepository $domainRepository,
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
@ -49,36 +52,37 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/
public function __invoke(UpdateDomainsFromWatchlist $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
$this->logger->info('Domain names from Watchlist {token} will be processed.', [
'token' => $message->watchListToken,
$this->logger->debug('Domain names listed in the Watchlist will be updated', [
'watchlist' => $message->watchlistToken,
]);
/** @var AbstractProvider $connectorProvider */
$connectorProvider = $this->getConnectorProvider($watchList);
$connectorProvider = $this->getConnectorProvider($watchlist);
if ($connectorProvider instanceof CheckDomainProviderInterface) {
$this->logger->notice('Watchlist {watchlist} linked to connector {connector}.', [
'watchlist' => $watchList->getToken(),
'connector' => $watchList->getConnector()->getId(),
$this->logger->debug('Watchlist is linked to a connector', [
'watchlist' => $watchlist->getToken(),
'connector' => $watchlist->getConnector()->getId(),
]);
$domainList = array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchlist->getDomains()->toArray()));
try {
$checkedDomains = $connectorProvider->checkDomains(
...array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchList->getDomains()->toArray()))
);
$checkedDomains = $connectorProvider->checkDomains(...$domainList);
} catch (\Throwable $exception) {
$this->logger->warning('Unable to check domain names availability with connector {connector}.', [
'connector' => $watchList->getConnector()->getId(),
$this->logger->warning('Unable to check domain names availability with this connector', [
'connector' => $watchlist->getConnector()->getId(),
'ldhName' => $domainList,
]);
throw $exception;
}
foreach ($checkedDomains as $domain) {
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain));
$this->bus->dispatch(new OrderDomain($watchlist->getToken(), $domain));
}
return;
@ -92,9 +96,10 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/
/** @var Domain $domain */
foreach ($watchList->getDomains()->filter(fn ($domain) => $domain->isToBeUpdated(false, null !== $watchList->getConnector())) as $domain
foreach ($watchlist->getDomains()->filter(fn ($domain) => $this->RDAPService->isToBeUpdated($domain, false, null !== $watchlist->getConnector())) as $domain
) {
$updatedAt = $domain->getUpdatedAt();
$deleted = $domain->getDeleted();
try {
/*
@ -102,42 +107,34 @@ final readonly class UpdateDomainsFromWatchlistHandler
* We send messages that correspond to the sending of notifications that will not be processed here.
*/
$this->RDAPService->registerDomain($domain->getLdhName());
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
} catch (NotFoundHttpException) {
if (!$domain->getDeleted()) {
$this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
} catch (DomainNotFoundException) {
$newDomain = $this->domainRepository->findOneBy(['ldhName' => $domain->getLdhName()]);
if (!$deleted && null !== $newDomain && $newDomain->getDeleted()) {
$notification = new DomainDeletedNotification($this->sender, $domain);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
}
if ($watchList->getConnector()) {
if ($watchlist->getConnector()) {
/*
* If the domain name no longer appears in the WHOIS AND a connector is associated with this Watchlist,
* this connector is used to purchase the domain name.
*/
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain->getLdhName()));
$this->bus->dispatch(new OrderDomain($watchlist->getToken(), $domain->getLdhName()));
}
} catch (\Throwable $e) {
} catch (TldNotSupportedException|UnknownRdapServerException) {
/*
* In case of another unknown error,
* the owner of the Watchlist is informed that an error occurred in updating the domain name.
* In this case, the domain name can no longer be updated. Unfortunately, there is nothing more that can be done.
*/
$this->logger->error('An update error email is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
'error' => $e,
]);
$email = (new DomainUpdateErrorNotification($this->sender, $domain))
->asEmailMessage(new Recipient($watchList->getUser()->getEmail()));
$this->mailer->send($email->getMessage());
throw $e;
}
}
}
private function getConnectorProvider(WatchList $watchList): ?object
private function getConnectorProvider(Watchlist $watchlist): ?object
{
$connector = $watchList->getConnector();
$connector = $watchlist->getConnector();
if (null === $connector || null === $connector->getProvider()) {
return null;
}

View File

@ -3,7 +3,8 @@
namespace App\MessageHandler;
use App\Message\UpdateRdapServers;
use App\Service\RDAPService;
use App\Repository\DomainRepository;
use App\Service\OfficialDataService;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
@ -16,8 +17,8 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
final readonly class UpdateRdapServersHandler
{
public function __construct(
private RDAPService $RDAPService,
private ParameterBagInterface $bag,
private OfficialDataService $officialDataService,
private ParameterBagInterface $bag, private DomainRepository $domainRepository,
) {
}
@ -39,8 +40,9 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateTldListIANA();
$this->RDAPService->updateGTldListICANN();
$this->officialDataService->updateTldListIANA();
$this->officialDataService->updateGTldListICANN();
$this->domainRepository->setDomainDeletedIfTldIsDeleted();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
@ -50,7 +52,7 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateRDAPServersFromIANA();
$this->officialDataService->updateRDAPServersFromIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
@ -60,13 +62,13 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateRDAPServersFromFile($this->bag->get('custom_rdap_servers_file'));
$this->officialDataService->updateRDAPServersFromFile($this->bag->get('custom_rdap_servers_file'));
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
try {
$this->RDAPService->updateRegistrarListIANA();
$this->officialDataService->updateRegistrarListIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}

View File

@ -5,7 +5,7 @@ namespace App\MessageHandler;
use App\Message\ValidateConnectorCredentials;
use App\Notifier\ValidateConnectorCredentialsErrorNotification;
use App\Repository\ConnectorRepository;
use App\Service\Connector\AbstractProvider;
use App\Service\Provider\AbstractProvider;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;

View File

@ -0,0 +1,69 @@
<?php
namespace App\Notifier;
use App\Entity\Domain;
use App\Entity\DomainStatus;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class DomainStatusUpdateNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly Domain $domain,
private readonly DomainStatus $domainStatus,
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Domain EPP status changed $ldhName")
->content("Domain name $ldhName EPP status has been updated.")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Domain EPP status changed $ldhName")
->content("Domain name $ldhName EPP status has been updated.")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
$ldhName = $this->domain->getLdhName();
$email = (new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->priority(Email::PRIORITY_HIGH)
->subject("Domain EPP status changed $ldhName")
->htmlTemplate('emails/success/domain_status_updated.html.twig')
->locale('en')
->context([
'domain' => $this->domain,
'domainStatus' => $this->domainStatus,
]);
$email->getHeaders()
->addTextHeader('In-Reply-To', "<$ldhName+updated@domain-watchdog>")
->addTextHeader('References', "<$ldhName+updated@domain-watchdog>");
return new EmailMessage($email);
}
}

Some files were not shown because too many files have changed in this diff Show More