chore: merge develop

This commit is contained in:
Maël Gangloff
2025-10-27 21:57:08 +01:00
161 changed files with 8743 additions and 3179 deletions

View File

@@ -2,3 +2,24 @@
KERNEL_CLASS='App\Kernel' KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 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 }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ github.repository }},name-canonical=true,push=true outputs: type=image,name=${{ github.repository }},name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Export digest - name: Export digest
run: | 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 # syntax=docker/dockerfile:1.4
# Versions # 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 # 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 # 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 # persistent / runtime deps
# hadolint ignore=DL3008 # hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
file \ file \
gettext \
libicu-dev \ libicu-dev \
libzip-dev \ libzip-dev \
unzip \ unzip \

View File

@@ -14,7 +14,6 @@ import {
SafetyOutlined, SafetyOutlined,
SearchOutlined, SearchOutlined,
TableOutlined, TableOutlined,
TeamOutlined,
UserOutlined UserOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import {Menu} from 'antd' import {Menu} from 'antd'
@@ -46,14 +45,6 @@ export function Sider({isAuthenticated}: { isAuthenticated: boolean }) {
disabled: !isAuthenticated, disabled: !isAuthenticated,
onClick: () => navigate('/search/domain') 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', 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 React from 'react'
import type {Domain} from '../../utils/api' 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 {roleToAvatar} from '../../utils/functions/roleToAvatar'
import {rolesToColor} from '../../utils/functions/rolesToColor' import {rolesToColor} from '../../utils/functions/rolesToColor'
import {sortDomainEntities} from '../../utils/functions/sortDomainEntities' import {sortDomainEntities} from '../../utils/functions/sortDomainEntities'
import {extractDetailsFromJCard} from '../../utils/functions/extractDetailsFromJCard' import {extractDetailsFromJCard} from '../../utils/functions/extractDetailsFromJCard'
import {CheckCircleOutlined, CloseCircleOutlined, SettingOutlined} from "@ant-design/icons"
export function EntitiesList({domain}: { domain: Domain }) { export function EntitiesList({domain}: { domain: Domain }) {
const rdapRoleTranslated = rdapRoleTranslation() const rdapRoleTranslated = rdapRoleTranslation()
@@ -27,11 +32,28 @@ export function EntitiesList({domain}: { domain: Domain }) {
dataSource={sortDomainEntities(domain)} dataSource={sortDomainEntities(domain)}
renderItem={(e) => { renderItem={(e) => {
const details = extractDetailsFromJCard(e) const details = extractDetailsFromJCard(e)
const icannAccreditationTranslated = icannAccreditationTranslation()
const status = e.entity.icannAccreditation?.status as ('Terminated' | 'Accredited' | 'Reserved' | undefined)
return <List.Item> return <List.Item>
<List.Item.Meta <List.Item.Meta
avatar={roleToAvatar(e)} 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={<> description={<>
{details.fn && <div>👤 {details.fn}</div>} {details.fn && <div>👤 {details.fn}</div>}
{details.organization && <div>🏢 {details.organization}</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`} description={t`No tracked domain names were found, please create your first Watchlist`}
> >
<Link to='/tracking/watchlist'> <Link to='/tracking/watchlist'>
<Button type='primary'>Create Now</Button> <Button type='primary'>{t`Create now`}</Button>
</Link> </Link>
</Empty> </Empty>
: <Skeleton loading={total === undefined}> : <Skeleton loading={total === undefined}>

View File

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

View File

@@ -7,25 +7,44 @@ import {DeleteWatchlistButton} from './DeleteWatchlistButton'
import React from 'react' import React from 'react'
import type {Connector} from '../../../utils/api/connectors' import type {Connector} from '../../../utils/api/connectors'
import {CalendarWatchlistButton} from './CalendarWatchlistButton' 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 {actionToColor} from '../../../utils/functions/actionToColor'
import {DomainToTag} from '../../../utils/functions/DomainToTag' import {DomainToTag} from '../../../utils/functions/DomainToTag'
import type {Watchlist} from '../../../utils/api' 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 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 }> connectors: Array<Connector & { id: string }>
onDelete: () => void onChange: () => void
}) { }) {
const rdapEventNameTranslated = rdapEventNameTranslation() const rdapEventNameTranslated = rdapEventNameTranslation()
const rdapEventDetailTranslated = rdapEventDetailTranslation() const rdapEventDetailTranslated = rdapEventDetailTranslation()
const rdapDomainStatusCodeDetailTranslated = rdapDomainStatusCodeDetailTranslation()
return ( return (
<> <>
<Card <Card
aria-disabled={true}
type='inner' type='inner'
style={{
width: '100%',
opacity: watchlist.enabled ? 1 : 0.5,
filter: watchlist.enabled ? 'none' : 'grayscale(0.7)',
transition: 'all 0.3s ease',
}}
title={<> title={<>
{ {
(watchlist.connector != null) (watchlist.connector != null)
@@ -41,7 +60,6 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
</Tooltip> </Tooltip>
</>} </>}
size='small' size='small'
style={{width: '100%'}}
extra={ extra={
<Space size='middle'> <Space size='middle'>
<ViewDiagramWatchlistButton token={watchlist.token}/> <ViewDiagramWatchlistButton token={watchlist.token}/>
@@ -54,26 +72,65 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
connectors={connectors} connectors={connectors}
/> />
<DeleteWatchlistButton watchlist={watchlist} onDelete={onDelete}/> <DisableWatchlistButton watchlist={watchlist} onChange={onChange}
enabled={watchlist.enabled}/>
<DeleteWatchlistButton watchlist={watchlist} onDelete={onChange}/>
</Space> </Space>
} }
> >
<Card.Meta description={watchlist.token} style={{marginBottom: '1em'}}/> <Card.Meta description={watchlist.token} style={{marginBottom: '1em'}}/>
<Row gutter={16}> <Row gutter={16}>
<Col span={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>
<Col span={8}> <Col span={8}>
{watchlist.triggers?.filter(t => t.action === 'email') <>
.map(t => <Tooltip <div style={{
key={t.event} fontWeight: 500,
title={rdapEventDetailTranslated[t.event as keyof typeof rdapEventDetailTranslated] || undefined} marginBottom: '0.5em',
> color: '#555',
<Tag color={actionToColor(t.event)}> fontSize: '0.9em'
{rdapEventNameTranslated[t.event as keyof typeof rdapEventNameTranslated]} }}>
</Tag> {t`Tracked events`}
</Tooltip> </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> </Col>
</Row> </Row>
</Card> </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 {Button, Form, Input, Select, Space, Tag, Tooltip, Typography} from 'antd'
import {t} from 'ttag' import {t} from 'ttag'
import {ApiOutlined, MinusCircleOutlined, PlusOutlined} from '@ant-design/icons' 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 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 {actionToColor} from '../../../utils/functions/actionToColor'
import {actionToIcon} from '../../../utils/functions/actionToIcon' import {actionToIcon} from '../../../utils/functions/actionToIcon'
import type {EventAction, Watchlist} from '../../../utils/api' import type {EventAction, Watchlist} from '../../../utils/api'
import { createWatchlistTrigger, deleteWatchlistTrigger} from '../../../utils/api'
import {formItemLayoutWithOutLabel} from "../../../utils/providers" import {formItemLayoutWithOutLabel} from "../../../utils/providers"
import {eppStatusCodeToColor} from "../../../utils/functions/eppStatusCodeToColor"
type TagRender = SelectProps['tagRender'] 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 form: FormInstance
connectors: Array<Connector & { id: string }> 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, isCreation: boolean,
watchList?: Watchlist, watchlist?: Watchlist,
}) { }) {
const rdapEventNameTranslated = rdapEventNameTranslation() const rdapEventNameTranslated = rdapEventNameTranslation()
const rdapEventDetailTranslated = rdapEventDetailTranslation() const rdapEventDetailTranslated = rdapEventDetailTranslation()
const rdapDomainStatusCodeDetailTranslated = rdapDomainStatusCodeDetailTranslation()
const triggerTagRenderer: TagRender = ({value, closable, onClose}: { const eventActionTagRenderer: TagRender = ({value, closable, onClose}: {
value: EventAction value: EventAction
closable: boolean closable: boolean
onClose: () => void onClose: () => void
@@ -61,40 +66,30 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
) )
} }
const [triggersLoading, setTriggersLoading] = useState(false) const domainStatusTagRenderer: TagRender = ({value, closable, onClose}: {
value: EventAction
const createTrigger = async (event: string) => { closable: boolean
if (isCreation) return onClose: () => void
}) => {
setTriggersLoading(true) const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
await createWatchlistTrigger(watchList!.token, { event.preventDefault()
watchList: watchList!['@id'], event.stopPropagation()
event, }
action: 'email', return (
}) <Tooltip
await createWatchlistTrigger(watchList!.token, { title={rdapDomainStatusCodeDetailTranslated[value as keyof typeof rdapDomainStatusCodeDetailTranslated] || undefined}
watchList: watchList!['@id'], >
event, <Tag
action: 'chat', color={eppStatusCodeToColor(value)}
}) onMouseDown={onPreventMouseDown}
setTriggersLoading(false) closable={closable}
} onClose={onClose}
style={{marginInlineEnd: 4}}
const removeTrigger = async (event: string) => { >
if (isCreation) return {value}
</Tag>
setTriggersLoading(true) </Tooltip>
await deleteWatchlistTrigger(watchList!.token, { )
watchList: watchList!['@id'],
event,
action: 'email',
})
await deleteWatchlistTrigger(watchList!.token, {
watchList: watchList!['@id'],
event,
action: 'chat',
})
setTriggersLoading(false)
} }
return ( return (
@@ -102,7 +97,10 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
{...formItemLayoutWithOutLabel} {...formItemLayoutWithOutLabel}
form={form} form={form}
onFinish={onFinish} 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> <Form.Item name='token' hidden>
@@ -191,8 +189,8 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
</Form.List> </Form.List>
<Form.Item <Form.Item
label={t`Tracked events`} label={t`Tracked events`}
name='triggers' name='trackedEvents'
rules={[{required: true, message: t`At least one trigger`, type: 'array'}]} rules={[{required: true, message: t`At least one event`, type: 'array'}]}
labelCol={{ labelCol={{
xs: {span: 24}, xs: {span: 24},
sm: {span: 4} sm: {span: 4}
@@ -205,11 +203,8 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
> >
<Select <Select
mode='multiple' mode='multiple'
tagRender={triggerTagRenderer} tagRender={eventActionTagRenderer}
style={{width: '100%'}} style={{width: '100%'}}
onSelect={createTrigger}
onDeselect={removeTrigger}
loading={triggersLoading}
options={Object.keys(rdapEventNameTranslated).map(e => ({ options={Object.keys(rdapEventNameTranslated).map(e => ({
value: e, value: e,
title: rdapEventDetailTranslated[e as keyof typeof rdapEventDetailTranslated] || undefined, title: rdapEventDetailTranslated[e as keyof typeof rdapEventDetailTranslated] || undefined,
@@ -218,6 +213,32 @@ export function WatchlistForm({form, connectors, onFinish, isCreation, watchList
/> />
</Form.Item> </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 <Form.Item
label={t`Connector`} label={t`Connector`}
name='connector' name='connector'

View File

@@ -3,21 +3,21 @@ import type {Connector} from '../../../utils/api/connectors'
import {WatchlistCard} from './WatchlistCard' import {WatchlistCard} from './WatchlistCard'
import type {Watchlist} from '../../../utils/api' import type {Watchlist} from '../../../utils/api'
export function WatchlistsList({watchlists, onDelete, onUpdateWatchlist, connectors}: { export function WatchlistsList({watchlists, onChange, onUpdateWatchlist, connectors}: {
watchlists: Watchlist[] watchlists: Watchlist[]
onDelete: () => void onChange: () => void
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 }> connectors: Array<Connector & { id: string }>
}) { }) {
return ( return (
<> <>
{watchlists.map(watchlist => {[...watchlists.filter(w => w.enabled), ...watchlists.filter(w => !w.enabled)].map(watchlist =>
<WatchlistCard <WatchlistCard
key={watchlist.token} key={watchlist.token}
watchlist={watchlist} watchlist={watchlist}
onUpdateWatchlist={onUpdateWatchlist} onUpdateWatchlist={onUpdateWatchlist}
connectors={connectors} 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[] { export function domainEntitiesToEdges(d: Domain, withRegistrar = false): Edge[] {
const rdapRoleTranslated = rdapRoleTranslation() 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 return d.entities
.filter(e => .filter(e =>
!e.deleted && e.deletedAt === undefined &&
(withRegistrar || !e.roles.includes('registrar')) && (withRegistrar || !e.roles.includes('registrar')) &&
((sponsor == null) || !e.roles.includes('registrar') || e.roles.includes('sponsor')) ((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[] => { 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 return d.entities
.filter(e => .filter(e =>
!e.deleted && e.deletedAt === undefined &&
(withRegistrar || !e.roles.includes('registrar')) && (withRegistrar || !e.roles.includes('registrar')) &&
((sponsor == null) || !e.roles.includes('registrar') || e.roles.includes('sponsor')) ((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 const {Text, Paragraph} = Typography
interface FiltersType { interface FiltersType {
'icannAccreditation.status': 'Accredited' | 'Reserved' | 'Terminated', status: 'Accredited' | 'Reserved' | 'Terminated',
} }
function RegistrarListTable(filters: FiltersType) { function RegistrarListTable(filters: FiltersType) {
interface TableRow { interface TableRow {
key: string key: number
handle: number handle: number
name: string name: string
} }
@@ -26,9 +26,9 @@ function RegistrarListTable(filters: FiltersType) {
getIcannAccreditations(params).then((data) => { getIcannAccreditations(params).then((data) => {
setTotal(data['hydra:totalItems']) setTotal(data['hydra:totalItems'])
setDataTable(data['hydra:member'].map((accreditation: IcannAccreditation) => ({ setDataTable(data['hydra:member'].map((accreditation: IcannAccreditation) => ({
key: accreditation.handle, key: accreditation.id,
handle: parseInt(accreditation.handle), handle: accreditation.id,
name: accreditation.icannAccreditation.registrarName name: accreditation.registrarName
}) })
).sort((a, b) => a.handle - b.handle)) ).sort((a, b) => a.handle - b.handle))
}) })
@@ -76,17 +76,17 @@ export default function IcannRegistrarPage() {
Accredited: <> Accredited: <>
<Text>{t`An accredited number means that ICANN's contract with the registrar is ongoing.`}</Text> <Text>{t`An accredited number means that ICANN's contract with the registrar is ongoing.`}</Text>
<Divider/> <Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Accredited'}} /> <RegistrarListTable status='Accredited' />
</>, </>,
Reserved: <> Reserved: <>
<Text>{t`A reserved number can be used by TLD registries for specific operations.`}</Text> <Text>{t`A reserved number can be used by TLD registries for specific operations.`}</Text>
<Divider/> <Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Reserved'}} /> <RegistrarListTable status='Reserved' />
</>, </>,
Terminated: <> Terminated: <>
<Text>{t`A terminated number means that ICANN's contract with the registrar has been terminated.`}</Text> <Text>{t`A terminated number means that ICANN's contract with the registrar has been terminated.`}</Text>
<Divider/> <Divider/>
<RegistrarListTable {...{'icannAccreditation.status': 'Terminated'}} /> <RegistrarListTable status='Terminated' />
</> </>
} }

View File

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

View File

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

View File

@@ -16,8 +16,6 @@ export type EventAction =
| 'enum validation expiration' | 'enum validation expiration'
| string | string
export type TriggerAction = 'email' | 'chat'
export interface Event { export interface Event {
action: EventAction action: EventAction
date: string date: string
@@ -32,6 +30,11 @@ export interface Entity {
string, string,
string | string[], string | string[],
]>] | [] ]>] | []
remarks?: {
type: string
description: string
}[]
icannAccreditation?: IcannAccreditation
} }
export interface Nameserver { export interface Nameserver {
@@ -59,7 +62,7 @@ export interface Domain {
entity: Entity entity: Entity
events: Event[] events: Event[]
roles: string[] roles: string[]
deleted: boolean deletedAt?: string
}> }>
nameservers: Nameserver[] nameservers: Nameserver[]
tld: Tld tld: Tld
@@ -74,18 +77,14 @@ export interface User {
roles: string[] roles: string[]
} }
export interface WatchlistTrigger {
event: EventAction
action: TriggerAction
watchList?: string
}
export interface WatchlistRequest { export interface WatchlistRequest {
name?: string name?: string
domains: string[] domains: string[]
triggers?: Array<WatchlistTrigger> trackedEvents?: string[]
trackedEppStatus?: string[]
connector?: string connector?: string
dsn?: string[] dsn?: string[]
enabled?: boolean
} }
export interface Watchlist { export interface Watchlist {
@@ -93,7 +92,8 @@ export interface Watchlist {
name?: string name?: string
token: string token: string
domains: Domain[] domains: Domain[]
triggers?: Array<WatchlistTrigger> trackedEvents?: string[]
trackedEppStatus?: string[]
dsn?: string[] dsn?: string[]
connector?: { connector?: {
id: string id: string
@@ -101,6 +101,7 @@ export interface Watchlist {
createdAt: string createdAt: string
} }
createdAt: string createdAt: string
enabled: boolean
} }
export interface InstanceConfig { export interface InstanceConfig {
@@ -125,13 +126,11 @@ export interface TrackedDomains {
} }
export interface IcannAccreditation { export interface IcannAccreditation {
handle: string id: number
icannAccreditation: { registrarName: string
registrarName: string status: string
status: string date?: string
date?: string updated?: string
updated?: string
}
} }
export async function request<T = object, R = AxiosResponse<T>, D = object>(config: AxiosRequestConfig): Promise<R> { 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' import {request} from './index'
interface WatchlistList { interface WatchlistList {
@@ -32,6 +32,18 @@ export async function postWatchlist(watchlist: WatchlistRequest) {
return response.data 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> { export async function deleteWatchlist(token: string): Promise<void> {
await request({ await request({
method: 'DELETE', method: 'DELETE',
@@ -56,20 +68,3 @@ export async function getTrackedDomainList(params: { page: number, itemsPerPage:
}) })
return response.data 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) => export const eppStatusCodeToColor = (s?: string) =>
['active', 'ok'].includes(s) s === undefined ? 'default' :
? 'green' ['active', 'ok'].includes(s)
: ['pending delete', 'redemption period'].includes(s) ? 'green'
? 'red' : ['pending delete', 'redemption period'].includes(s)
: s.startsWith('client') ? 'red'
? 'purple' : s.startsWith('client')
: s.startsWith('server') ? 'geekblue' : 'blue' ? '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.` '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.`
}) })
/** export const rdapDomainStatusCodeDetailTranslation = () => ({
* @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.`,
active: t`This is the standard status for a domain, meaning it has no pending operations or prohibitions.`, 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.`, 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.`, '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.`, '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.`, '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).`, 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.` 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' import type {Domain} from '../api'
export const sortDomainEntities = (domain: Domain) => domain.entities export const sortDomainEntities = (domain: Domain) => domain.entities
.filter(e => !e.deleted) .filter(e => e.deletedAt === undefined)
.sort((e1, e2) => { .sort((e1, e2) => {
const p = (r: string[]) => r.includes('registrant') const p = (r: string[]) => r.includes('registrant')
? 5 ? 5

View File

@@ -20,7 +20,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
@@ -42,53 +42,53 @@
"protonlabs/vobject": "^4.31", "protonlabs/vobject": "^4.31",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.1.*", "symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.1.*", "symfony/asset-mapper": "7.3.*",
"symfony/cache": "7.1.*", "symfony/cache": "7.3.*",
"symfony/console": "7.1.*", "symfony/console": "7.3.*",
"symfony/discord-notifier": "7.1.*", "symfony/discord-notifier": "7.3.*",
"symfony/doctrine-messenger": "7.1.*", "symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.1.*", "symfony/dotenv": "7.3.*",
"symfony/engagespot-notifier": "7.1.*", "symfony/engagespot-notifier": "7.3.*",
"symfony/expression-language": "7.1.*", "symfony/expression-language": "7.3.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.1.*", "symfony/form": "7.3.*",
"symfony/framework-bundle": "7.1.*", "symfony/framework-bundle": "7.3.*",
"symfony/google-chat-notifier": "7.1.*", "symfony/google-chat-notifier": "7.3.*",
"symfony/http-client": "7.1.*", "symfony/http-client": "7.3.*",
"symfony/intl": "7.1.*", "symfony/intl": "7.3.*",
"symfony/lock": "7.1.*", "symfony/lock": "7.3.*",
"symfony/mailer": "7.1.*", "symfony/mailer": "7.3.*",
"symfony/mattermost-notifier": "7.1.*", "symfony/mattermost-notifier": "7.3.*",
"symfony/microsoft-teams-notifier": "7.1.*", "symfony/microsoft-teams-notifier": "7.3.*",
"symfony/mime": "7.1.*", "symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.1.*", "symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.1.*", "symfony/ntfy-notifier": "7.3.*",
"symfony/process": "7.1.*", "symfony/process": "7.3.*",
"symfony/property-access": "7.1.*", "symfony/property-access": "7.3.*",
"symfony/property-info": "7.1.*", "symfony/property-info": "7.3.*",
"symfony/pushover-notifier": "7.1.*", "symfony/pushover-notifier": "7.3.*",
"symfony/rate-limiter": "7.1.*", "symfony/rate-limiter": "7.3.*",
"symfony/redis-messenger": "7.1.*", "symfony/redis-messenger": "7.3.*",
"symfony/rocket-chat-notifier": "7.1.*", "symfony/rocket-chat-notifier": "7.3.*",
"symfony/runtime": "7.1.*", "symfony/runtime": "7.3.*",
"symfony/scheduler": "7.1.*", "symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.1.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.1.*", "symfony/serializer": "7.3.*",
"symfony/slack-notifier": "7.1.*", "symfony/slack-notifier": "7.3.*",
"symfony/stimulus-bundle": "^2.18", "symfony/stimulus-bundle": "^2.18",
"symfony/string": "7.1.*", "symfony/string": "7.3.*",
"symfony/telegram-notifier": "7.1.*", "symfony/telegram-notifier": "7.3.*",
"symfony/translation": "7.1.*", "symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.1.*", "symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.1.*", "symfony/uid": "7.3.*",
"symfony/ux-turbo": "^2.18", "symfony/ux-turbo": "^2.18",
"symfony/validator": "7.1.*", "symfony/validator": "7.3.*",
"symfony/web-link": "7.1.*", "symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.1.*", "symfony/yaml": "7.3.*",
"symfony/zulip-notifier": "7.1.*", "symfony/zulip-notifier": "7.3.*",
"symfonycasts/verify-email-bundle": "*", "symfonycasts/verify-email-bundle": "*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0"
@@ -140,20 +140,23 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.1.*", "require": "7.3.*",
"docker": true "docker": true
} }
}, },
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.2",
"friendsofphp/php-cs-fixer": "^3.61", "friendsofphp/php-cs-fixer": "^3.61",
"justinrainbow/json-schema": "^6.6",
"phpstan/phpstan": "^1.11", "phpstan/phpstan": "^1.11",
"phpunit/phpunit": "^10", "phpunit/phpunit": "^10",
"symfony/browser-kit": "7.1.*", "symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.1.*", "symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.1.*", "symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^7.1", "symfony/phpunit-bridge": "^7.3",
"symfony/stopwatch": "7.1.*", "symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.1.*" "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], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::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: JWT:
name: Authorization name: Authorization
type: header 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 use_savepoints: true
orm: orm:
auto_generate_proxy_classes: true auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true enable_native_lazy_objects: true
report_fields_where_declared: true report_fields_where_declared: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware

View File

@@ -23,7 +23,7 @@ framework:
Symfony\Component\Notifier\Message\SmsMessage: async Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\OrderDomain: async App\Message\OrderDomain: async
App\Message\ProcessWatchListsTrigger: async App\Message\ProcessWatchlistTrigger: async
App\Message\SendDomainEventNotif: async App\Message\SendDomainEventNotif: async
App\Message\UpdateDomainsFromWatchlist: async App\Message\UpdateDomainsFromWatchlist: async
App\Message\UpdateRdapServers: 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: user_register:
policy: token_bucket policy: token_bucket
limit: 1 limit: 5
rate: { interval: '5 minutes' } rate: { interval: '5 minutes' }
rdap_requests: 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 # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # 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 # Please see https://github.com/maelgangloff/domain-watchdog
services: services:
domainwatchdog: domainwatchdog:
image: maelgangloff/domain-watchdog:latest image: maelgangloff/domain-watchdog:latest
container_name: domainwatchdog_app
restart: unless-stopped restart: unless-stopped
env_file:
- .env.local
environment: environment:
APP_ENV: prod
SERVER_NAME: ${SERVER_NAME:-:80} 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} DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
APP_SECRET: ${APP_SECRET:-ChangeMe} MESSENGER_TRANSPORT_DSN: redis://valkey:6379/messages
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}
volumes: volumes:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
- ./public/content:/app/public/content - ./public/content:/app/public/content
ports: ports:
- "127.0.0.1:8080:80" - "127.0.0.1:8080:80"
depends_on:
- database
php-worker: php-worker:
image: maelgangloff/domain-watchdog:latest image: maelgangloff/domain-watchdog:latest
container_name: domainwatchdog_worker
restart: always restart: always
command: php /app/bin/console messenger:consume --all --time-limit=3600 -vvv command: php /app/bin/console messenger:consume --all --time-limit=3600 -vvv
env_file:
- .env.local
environment: 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_ENV: prod
APP_SECRET: ${APP_SECRET:-ChangeMe} DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
MAILER_DSN: ${MAILER_DSN:-null://null} MESSENGER_TRANSPORT_DSN: redis://valkey:6379/messages
depends_on:
- database
healthcheck: healthcheck:
disable: true
test: [ ] test: [ ]
# volumes: disable: true
# - ./custom_rdap_servers.yaml:/app/config/app/custom_rdap_servers.yaml # Please see #41 issue
database: database:
image: postgres:${POSTGRES_VERSION:-16}-alpine image: postgres:${POSTGRES_VERSION:-16}-alpine
container_name: domainwatchdog_db
restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-app} POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
@@ -49,25 +54,23 @@ services:
volumes: volumes:
- database_data:/var/lib/postgresql/data:rw - database_data:/var/lib/postgresql/data:rw
# keydb: valkey:
# image: eqalpha/keydb:latest image: valkey/valkey
# container_name: keydb container_name: valkey
# restart: always restart: always
# ports:
# - "127.0.0.1:6379:6379"
# influxdb2: # influxdb2:
# image: influxdb:2 # image: influxdb:2
# ports: # ports:
# - "127.0.0.1:8086:8086" # - "127.0.0.1:8086:8086"
# environment: # environment:
# DOCKER_INFLUXDB_INIT_MODE: setup # DOCKER_INFLUXDB_INIT_MODE: setup
# DOCKER_INFLUXDB_INIT_USERNAME: USERNAME # Please modify # DOCKER_INFLUXDB_INIT_USERNAME: USERNAME # Please modify
# DOCKER_INFLUXDB_INIT_PASSWORD: PASSWORD # Please modify # DOCKER_INFLUXDB_INIT_PASSWORD: PASSWORD # Please modify
# DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-super-secret-auth-token # Please modify # DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-super-secret-auth-token # Please modify
# DOCKER_INFLUXDB_INIT_RETENTION: 0 # DOCKER_INFLUXDB_INIT_RETENTION: 0
# DOCKER_INFLUXDB_INIT_ORG: domainwatchdog # DOCKER_INFLUXDB_INIT_ORG: domainwatchdog
# DOCKER_INFLUXDB_INIT_BUCKET: domainwatchdog # DOCKER_INFLUXDB_INIT_BUCKET: domainwatchdog
volumes: volumes:
caddy_data: 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 lexik:jwt:generate-keypair || true
php bin/console app:update-rdap-servers 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 fi
exec docker-php-entrypoint "$@" 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/ - public/
- src/ - src/
- tests/ - tests/
- migrations/

View File

@@ -30,5 +30,6 @@
</source> </source>
<extensions> <extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension" />
</extensions> </extensions>
</phpunit> </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; namespace App\Command;
use App\Message\ProcessWatchListsTrigger; use App\Message\ProcessWatchlistTrigger;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -12,10 +12,10 @@ use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand( #[AsCommand(
name: 'app:process-watchlists', name: 'app:process-watchlist',
description: 'Process watchlists and send emails if necessary', description: 'Process watchlist and send emails if necessary',
)] )]
class ProcessWatchlistsCommand extends Command class ProcessWatchlistCommand extends Command
{ {
public function __construct(private readonly MessageBusInterface $bus) public function __construct(private readonly MessageBusInterface $bus)
{ {
@@ -33,7 +33,7 @@ class ProcessWatchlistsCommand extends Command
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$this->bus->dispatch(new ProcessWatchListsTrigger()); $this->bus->dispatch(new ProcessWatchlistTrigger());
$io->success('Watchlist processing triggered!'); $io->success('Watchlist processing triggered!');

View File

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

View File

@@ -2,12 +2,12 @@
namespace App\Config; namespace App\Config;
use App\Service\Connector\AutodnsProvider; use App\Service\Provider\AutodnsProvider;
use App\Service\Connector\EppClientProvider; use App\Service\Provider\EppClientProvider;
use App\Service\Connector\GandiProvider; use App\Service\Provider\GandiProvider;
use App\Service\Connector\NamecheapProvider; use App\Service\Provider\NamecheapProvider;
use App\Service\Connector\NameComProvider; use App\Service\Provider\NameComProvider;
use App\Service\Connector\OvhProvider; use App\Service\Provider\OvhProvider;
enum ConnectorProvider: string 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 KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
class HomeController extends AbstractController 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')] #[Route(path: '/', name: 'index')]
@@ -25,7 +29,10 @@ class HomeController extends AbstractController
#[Route(path: '/login/oauth', name: 'oauth_connect')] #[Route(path: '/login/oauth', name: 'oauth_connect')]
public function connectAction(ClientRegistry $clientRegistry): Response 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')] #[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\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
{ {
@@ -33,6 +34,7 @@ class RegistrationController extends AbstractController
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly KernelInterface $kernel, private readonly KernelInterface $kernel,
private readonly ValidatorInterface $validator,
) { ) {
} }
@@ -44,7 +46,7 @@ class RegistrationController extends AbstractController
name: 'user_register', name: 'user_register',
defaults: [ defaults: [
'_api_resource_class' => User::class, '_api_resource_class' => User::class,
'_api_operation_name' => 'register', '_api_operation_name' => 'user_register',
], ],
methods: ['POST'] methods: ['POST']
)] )]
@@ -64,22 +66,21 @@ class RegistrationController extends AbstractController
} }
$user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']); $user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']);
if (null === $user->getEmail() || null === $user->getPassword()) { $violations = $this->validator->validate($user);
throw new BadRequestHttpException('Bad request');
if ($violations->count() > 0) {
throw new BadRequestHttpException($violations->get(0));
} }
$user->setPassword( $user->setPassword(
$userPasswordHasher->hashPassword( $userPasswordHasher->hashPassword(
$user, $user,
$user->getPassword() $user->getPlainPassword()
) )
); )->setCreatedAt(new \DateTimeImmutable());
$this->em->persist($user);
$this->em->flush();
if (false === (bool) $this->getParameter('registration_verify_email')) { if (false === (bool) $this->getParameter('registration_verify_email')) {
$user->setVerified(true); $user->setVerifiedAt($user->getCreatedAt());
} else { } else {
$email = $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user, $email = $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail()) (new TemplatedEmail())
@@ -91,13 +92,16 @@ class RegistrationController extends AbstractController
); );
$signedUrl = (string) $email->getContext()['signedUrl']; $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(), 'username' => $user->getUserIdentifier(),
'signedUrl' => $signedUrl, '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(), 'username' => $user->getUserIdentifier(),
]); ]);
@@ -121,7 +125,7 @@ class RegistrationController extends AbstractController
$this->emailVerifier->handleEmailConfirmation($request, $user); $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(), 'username' => $user->getUserIdentifier(),
]); ]);

View File

@@ -4,7 +4,7 @@ namespace App\Controller;
use App\Entity\Statistics; use App\Entity\Statistics;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\WatchListRepository; use App\Repository\WatchlistRepository;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,7 +15,7 @@ class StatisticsController extends AbstractController
public function __construct( public function __construct(
private readonly CacheItemPoolInterface $pool, private readonly CacheItemPoolInterface $pool,
private readonly DomainRepository $domainRepository, private readonly DomainRepository $domainRepository,
private readonly WatchListRepository $watchListRepository, private readonly WatchlistRepository $watchlistRepository,
private readonly KernelInterface $kernel, private readonly KernelInterface $kernel,
) { ) {
} }
@@ -34,22 +34,10 @@ class StatisticsController extends AbstractController
->setAlertSent($this->pool->getItem('stats.alert.sent')->get() ?? 0) ->setAlertSent($this->pool->getItem('stats.alert.sent')->get() ?? 0)
->setDomainTracked( ->setDomainTracked(
$this->getCachedItem('stats.domain.tracked', fn () => $this->watchListRepository->createQueryBuilder('w') $this->getCachedItem('stats.domain.tracked', fn () => $this->watchlistRepository->getTrackedDomainCount())
->join('w.domains', 'd')
->select('COUNT(DISTINCT d.ldhName)')
->where('d.deleted = FALSE')
->getQuery()->getSingleColumnResult()[0])
) )
->setDomainCount( ->setDomainCount(
$this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->createQueryBuilder('d') $this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->getActiveDomainCountByTld())
->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())
) )
->setDomainCountTotal( ->setDomainCountTotal(
$this->getCachedItem('stats.domain.total', fn () => $this->domainRepository->count(['deleted' => false]) $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\DomainEvent;
use App\Entity\DomainStatus; use App\Entity\DomainStatus;
use App\Entity\User; use App\Entity\User;
use App\Entity\WatchList; use App\Entity\Watchlist;
use App\Repository\WatchListRepository; use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository;
use App\Service\CalendarService;
use App\Service\RDAPService;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Eluceo\iCal\Domain\Entity\Calendar; use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Presentation\Component\Property; use Eluceo\iCal\Presentation\Component\Property;
@@ -23,10 +26,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class WatchListController extends AbstractController class WatchlistController extends AbstractController
{ {
public function __construct( 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', path: '/api/watchlists',
name: 'watchlist_get_all_mine', name: 'watchlist_get_all_mine',
defaults: [ defaults: [
'_api_resource_class' => WatchList::class, '_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_all_mine', '_api_operation_name' => 'get_all_mine',
], ],
methods: ['GET'] methods: ['GET']
)] )]
public function getWatchLists(): Collection public function getWatchlists(): Collection
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
return $user->getWatchLists(); return $user->getWatchlists();
} }
/** /**
@@ -57,26 +63,26 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/calendar', path: '/api/watchlists/{token}/calendar',
name: 'watchlist_calendar', name: 'watchlist_calendar',
defaults: [ defaults: [
'_api_resource_class' => WatchList::class, '_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'calendar', '_api_operation_name' => 'calendar',
] ]
)] )]
public function getWatchlistCalendar(string $token): Response public function getWatchlistCalendar(string $token): Response
{ {
/** @var WatchList $watchList */ /** @var Watchlist $watchlist */
$watchList = $this->watchListRepository->findOneBy(['token' => $token]); $watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$calendar = new Calendar(); $calendar = new Calendar();
/** @var Domain $domain */ /** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) { foreach ($watchlist->getDomains()->getIterator() as $domain) {
foreach ($domain->getDomainCalendarEvents() as $event) { foreach ($this->calendarService->getDomainCalendarEvents($domain) as $event) {
$calendar->addEvent($event); $calendar->addEvent($event);
} }
} }
$calendarResponse = (new CalendarFactory())->createCalendar($calendar); $calendarResponse = (new CalendarFactory())->createCalendar($calendar);
$calendarName = $watchList->getName(); $calendarName = $watchlist->getName();
if (null !== $calendarName) { if (null !== $calendarName) {
$calendarResponse->withProperty(new Property('X-WR-CALNAME', new TextValue($calendarName))); $calendarResponse->withProperty(new Property('X-WR-CALNAME', new TextValue($calendarName)));
} }
@@ -93,7 +99,7 @@ class WatchListController extends AbstractController
path: '/api/tracked', path: '/api/tracked',
name: 'watchlist_get_tracked_domains', name: 'watchlist_get_tracked_domains',
defaults: [ defaults: [
'_api_resource_class' => WatchList::class, '_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_tracked_domains', '_api_operation_name' => 'get_tracked_domains',
] ]
)] )]
@@ -102,18 +108,9 @@ class WatchListController extends AbstractController
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
$domains = []; $domains = $this->domainRepository->getMyTrackedDomains($user);
/** @var WatchList $watchList */ foreach ($domains as $domain) {
foreach ($user->getWatchLists()->getIterator() as $watchList) { $domain->setExpiresInDays($this->RDAPService->getExpiresInDays($domain));
/** @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;
}
}
} }
usort($domains, fn (Domain $d1, Domain $d2) => $d1->getExpiresInDays() - $d2->getExpiresInDays()); 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', path: '/api/watchlists/{token}/rss/events',
name: 'watchlist_rss_events', name: 'watchlist_rss_events',
defaults: [ defaults: [
'_api_resource_class' => WatchList::class, '_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_events', '_api_operation_name' => 'rss_events',
] ]
)] )]
public function getWatchlistRssEventsFeed(string $token, Request $request): Response public function getWatchlistRssEventsFeed(string $token, Request $request): Response
{ {
/** @var WatchList $watchlist */ /** @var Watchlist $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]); $watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed()) $feed = (new Feed())
->setLanguage('en') ->setLanguage('en')
@@ -166,14 +163,14 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/rss/status', path: '/api/watchlists/{token}/rss/status',
name: 'watchlist_rss_status', name: 'watchlist_rss_status',
defaults: [ defaults: [
'_api_resource_class' => WatchList::class, '_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_status', '_api_operation_name' => 'rss_status',
] ]
)] )]
public function getWatchlistRssStatusFeed(string $token, Request $request): Response public function getWatchlistRssStatusFeed(string $token, Request $request): Response
{ {
/** @var WatchList $watchlist */ /** @var Watchlist $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]); $watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed()) $feed = (new Feed())
->setLanguage('en') ->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( new Get(
normalizationContext: ['groups' => 'connector:list'], normalizationContext: ['groups' => 'connector:list'],
security: 'object.user == user' security: 'object.getUser() == user'
), ),
new Post( new Post(
normalizationContext: ['groups' => ['connector:create', 'connector:list']], normalizationContext: ['groups' => ['connector:create', 'connector:list']],
@@ -35,7 +35,7 @@ use Symfony\Component\Uid\Uuid;
processor: ConnectorCreateProcessor::class processor: ConnectorCreateProcessor::class
), ),
new Delete( new Delete(
security: 'object.user == user', security: 'object.getUser() == user',
processor: ConnectorDeleteProcessor::class processor: ConnectorDeleteProcessor::class
), ),
] ]
@@ -61,10 +61,10 @@ class Connector
private array $authData = []; private array $authData = [];
/** /**
* @var Collection<int, WatchList> * @var Collection<int, Watchlist>
*/ */
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'connector')] #[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'connector')]
private Collection $watchLists; private Collection $watchlists;
#[Groups(['connector:list', 'watchlist:list'])] #[Groups(['connector:list', 'watchlist:list'])]
#[ORM\Column] #[ORM\Column]
@@ -76,7 +76,7 @@ class Connector
public function __construct() public function __construct()
{ {
$this->id = Uuid::v4(); $this->id = Uuid::v4();
$this->watchLists = new ArrayCollection(); $this->watchlists = new ArrayCollection();
} }
public function getId(): ?string 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)) { if (!$this->watchlists->contains($watchlist)) {
$this->watchLists->add($watchList); $this->watchlists->add($watchlist);
$watchList->setConnector($this); $watchlist->setConnector($this);
} }
return $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) // set the owning side to null (unless already changed)
if ($watchList->getConnector() === $this) { if ($watchlist->getConnector() === $this) {
$watchList->setConnector(null); $watchlist->setConnector(null);
} }
} }
@@ -164,6 +164,6 @@ class Connector
public function getWatchlistCount(): ?int public function getWatchlistCount(): ?int
{ {
return $this->watchLists->count(); return $this->watchlists->count();
} }
} }

View File

@@ -2,29 +2,21 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\Config\EventAction; use App\Config\EventAction;
use App\Controller\DomainRefreshController; use App\Exception\MalformedDomainException;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Service\RDAPService; use App\Service\RDAPService;
use App\State\AutoRegisterDomainProvider; use App\State\AutoRegisterDomainProvider;
use App\State\FindDomainCollectionFromEntityProvider;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; 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\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; 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( new Get(
uriTemplate: '/domains/{ldhName}', // Do not delete this line, otherwise Symfony interprets the TLD of the domain name as a return type 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: [ normalizationContext: [
'groups' => [ 'groups' => [
'domain:item', 'domain:item',
@@ -54,7 +60,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
'ds:list', 'ds:list',
], ],
], ],
read: false
), ),
], ],
provider: AutoRegisterDomainProvider::class provider: AutoRegisterDomainProvider::class
@@ -75,25 +80,30 @@ class Domain
*/ */
#[ORM\OneToMany(targetEntity: DomainEvent::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: DomainEvent::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item', 'domain:list', 'watchlist:list'])] #[Groups(['domain:item', 'domain:list', 'watchlist:list'])]
#[ApiProperty(
openapiContext: [
'type' => 'array',
]
)]
private Collection $events; private Collection $events;
/** /**
* @var Collection<int, DomainEntity> * @var Collection<int, DomainEntity>
*/ */
#[ORM\OneToMany(targetEntity: DomainEntity::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: DomainEntity::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item'])] #[Groups(['domain:item', 'watchlist:item'])]
#[SerializedName('entities')] #[SerializedName('entities')]
private Collection $domainEntities; 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'])] #[Groups(['domain:item', 'domain:list', 'watchlist:item', 'watchlist:list'])]
private array $status = []; private array $status = [];
/** /**
* @var Collection<int, WatchList> * @var Collection<int, Watchlist>
*/ */
#[ORM\ManyToMany(targetEntity: WatchList::class, mappedBy: 'domains', cascade: ['persist'])] #[ORM\ManyToMany(targetEntity: Watchlist::class, mappedBy: 'domains', cascade: ['persist'])]
private Collection $watchLists; private Collection $watchlists;
/** /**
* @var Collection<int, Nameserver> * @var Collection<int, Nameserver>
@@ -103,7 +113,7 @@ class Domain
joinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name')], joinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'nameserver_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; private Collection $nameservers;
#[ORM\Column(type: Types::DATE_IMMUTABLE)] #[ORM\Column(type: Types::DATE_IMMUTABLE)]
@@ -135,7 +145,7 @@ class Domain
#[ORM\Column(nullable: false, options: ['default' => false])] #[ORM\Column(nullable: false, options: ['default' => false])]
#[Groups(['domain:item', 'domain:list'])] #[Groups(['domain:item', 'domain:list'])]
private ?bool $delegationSigned = null; private bool $delegationSigned = false;
/** /**
* @var Collection<int, DnsKey> * @var Collection<int, DnsKey>
@@ -144,6 +154,8 @@ class Domain
#[Groups(['domain:item'])] #[Groups(['domain:item'])]
private Collection $dnsKey; private Collection $dnsKey;
private ?int $expiresInDays;
private const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value]; private const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value];
private const IMPORTANT_STATUS = [ private const IMPORTANT_STATUS = [
'redemption period', 'redemption period',
@@ -160,7 +172,7 @@ class Domain
{ {
$this->events = new ArrayCollection(); $this->events = new ArrayCollection();
$this->domainEntities = new ArrayCollection(); $this->domainEntities = new ArrayCollection();
$this->watchLists = new ArrayCollection(); $this->watchlists = new ArrayCollection();
$this->nameservers = new ArrayCollection(); $this->nameservers = new ArrayCollection();
$this->updatedAt = new \DateTimeImmutable('now'); $this->updatedAt = new \DateTimeImmutable('now');
$this->createdAt = $this->updatedAt; $this->createdAt = $this->updatedAt;
@@ -174,6 +186,9 @@ class Domain
return $this->ldhName; return $this->ldhName;
} }
/**
* @throws MalformedDomainException
*/
public function setLdhName(string $ldhName): static public function setLdhName(string $ldhName): static
{ {
$this->ldhName = RDAPService::convertToIdn($ldhName); $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)) { if (!$this->watchlists->contains($watchlist)) {
$this->watchLists->add($watchList); $this->watchlists->add($watchlist);
$watchList->addDomain($this); $watchlist->addDomain($this);
} }
return $this; return $this;
} }
public function removeWatchList(WatchList $watchList): static public function removeWatchlists(Watchlist $watchlist): static
{ {
if ($this->watchLists->removeElement($watchList)) { if ($this->watchlists->removeElement($watchlist)) {
$watchList->removeDomain($this); $watchlist->removeDomain($this);
} }
return $this; return $this;
@@ -333,7 +348,7 @@ class Domain
return $this; return $this;
} }
private function setUpdatedAt(?\DateTimeImmutable $updatedAt): void public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{ {
$this->updatedAt = $updatedAt; $this->updatedAt = $updatedAt;
} }
@@ -378,7 +393,7 @@ class Domain
* *
* @throws \Exception * @throws \Exception
*/ */
protected function isToBeWatchClosely(): bool public function isToBeWatchClosely(): bool
{ {
$status = $this->getStatus(); $status = $this->getStatus();
if ((!empty($status) && count(array_intersect($status, self::IMPORTANT_STATUS))) || $this->getDeleted()) { 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); 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> * @return Collection<int, DomainStatus>
*/ */
@@ -500,122 +474,6 @@ class Domain
return in_array('pending delete', $this->getStatus()) && !in_array('redemption period', $this->getStatus()); 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> * @return Collection<int, DnsKey>
*/ */
@@ -646,69 +504,16 @@ class Domain
return $this; return $this;
} }
/** #[Groups(['domain:item', 'domain:list'])]
* @return Event[] public function getExpiresInDays(): ?int
*
* @throws ParseException
* @throws EofException
* @throws InvalidDataException
* @throws \Exception
*/
public function getDomainCalendarEvents(): array
{ {
$events = []; return $this->expiresInDays;
$attendees = []; }
/* @var DomainEntity $entity */ public function setExpiresInDays(?int $expiresInDays): static
foreach ($this->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeleted())->getIterator() as $domainEntity) { {
$jCard = $domainEntity->getEntity()->getJCard(); $this->expiresInDays = $expiresInDays;
if (empty($jCard)) { return $this;
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;
} }
} }

View File

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

View File

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

View File

@@ -2,15 +2,12 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\EntityRepository; use App\Repository\EntityRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embedded;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
@@ -20,31 +17,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
)] )]
#[ApiResource( #[ApiResource(
operations: [ 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( new GetCollection(
uriTemplate: '/entities', uriTemplate: '/entities',
@@ -67,12 +39,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
*/ */
] ]
)] )]
#[ApiFilter(
SearchFilter::class,
properties: [
'icannAccreditation.status' => 'exact',
]
)]
class Entity class Entity
{ {
#[ORM\Id] #[ORM\Id]
@@ -81,12 +47,12 @@ class Entity
private ?int $id = null; private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Tld::class, inversedBy: 'entities')] #[ORM\ManyToOne(targetEntity: Tld::class, inversedBy: 'entities')]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: true)] #[ORM\JoinColumn(referencedColumnName: 'tld', nullable: false)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])] #[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?Tld $tld = null; private ?Tld $tld = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])] #[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?string $handle = null; private ?string $handle = null;
/** /**
@@ -112,15 +78,21 @@ class Entity
#[Groups(['entity:item', 'domain:item'])] #[Groups(['entity:item', 'domain:item'])]
private Collection $events; private Collection $events;
#[ORM\Column] #[ORM\Column(type: 'json')]
#[Groups(['entity:item', 'domain:item'])] #[ApiProperty(
openapiContext: [
'type' => 'array',
'items' => ['type' => 'array'],
]
)]
#[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private array $jCard = []; private array $jCard = [];
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['entity:item', 'domain:item'])] #[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private ?array $remarks = null; private ?array $remarks = null;
#[Embedded(class: IcannAccreditation::class, columnPrefix: 'icann_')] #[ORM\ManyToOne(inversedBy: 'entities')]
#[Groups(['entity:list', 'entity:item', 'domain:item'])] #[Groups(['entity:list', 'entity:item', 'domain:item'])]
private ?IcannAccreditation $icannAccreditation = null; private ?IcannAccreditation $icannAccreditation = null;
@@ -129,7 +101,6 @@ class Entity
$this->domainEntities = new ArrayCollection(); $this->domainEntities = new ArrayCollection();
$this->nameserverEntities = new ArrayCollection(); $this->nameserverEntities = new ArrayCollection();
$this->events = new ArrayCollection(); $this->events = new ArrayCollection();
$this->icannAccreditation = new IcannAccreditation();
} }
public function getHandle(): ?string public function getHandle(): ?string
@@ -284,11 +255,13 @@ class Entity
public function getIcannAccreditation(): ?IcannAccreditation 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; $this->icannAccreditation = $icannAccreditation;
return $this;
} }
} }

View File

@@ -2,35 +2,92 @@
namespace App\Entity; 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\Config\RegistrarStatus;
use App\Repository\IcannAccreditationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Attribute\Groups; 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 class IcannAccreditation
{ {
#[ORM\Id]
#[ORM\Column]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])] #[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?string $registrarName = null; private ?string $registrarName = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'domain:item'])] #[Groups(['icann:item'])]
private ?string $rdapBaseUrl = null; private ?string $rdapBaseUrl = null;
#[ORM\Column(nullable: true, enumType: RegistrarStatus::class)] #[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; private ?RegistrarStatus $status = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] #[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; private ?\DateTimeImmutable $updated = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] #[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; 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 public function getRegistrarName(): ?string
{ {
return $this->registrarName; return $this->registrarName;
@@ -90,4 +147,46 @@ class IcannAccreditation
return $this; 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> * @var Collection<int, Domain>
*/ */
#[ORM\ManyToMany(targetEntity: Domain::class, mappedBy: 'nameservers')] #[ORM\ManyToMany(targetEntity: Domain::class, mappedBy: 'nameservers')]
#[Groups(['nameserver:item'])]
private Collection $domains; private Collection $domains;
public function __construct() public function __construct()

View File

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

View File

@@ -77,6 +77,9 @@ class Tld
#[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'tld')] #[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'tld')]
private Collection $entities; private Collection $entities;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct() public function __construct()
{ {
$this->rdapServers = new ArrayCollection(); $this->rdapServers = new ArrayCollection();
@@ -241,4 +244,16 @@ class Tld
return $this; 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\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups; 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\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
@@ -46,6 +48,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Groups(['user:list', 'user:register'])] #[Groups(['user:list', 'user:register'])]
#[Assert\Email]
#[Assert\NotBlank]
private ?string $email = null; private ?string $email = null;
/** /**
@@ -59,14 +63,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var string|null The hashed password * @var string|null The hashed password
*/ */
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['user:register'])]
private ?string $password = null; private ?string $password = null;
/** /**
* @var Collection<int, WatchList> * @var Collection<int, Watchlist>
*/ */
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'user', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $watchLists; private Collection $watchlists;
/** /**
* @var Collection<int, Connector> * @var Collection<int, Connector>
@@ -75,11 +78,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private Collection $connectors; private Collection $connectors;
#[ORM\Column] #[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() public function __construct()
{ {
$this->watchLists = new ArrayCollection(); $this->watchlists = new ArrayCollection();
$this->connectors = new ArrayCollection(); $this->connectors = new ArrayCollection();
} }
@@ -152,33 +164,33 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
// If you store any temporary, sensitive data on the user, clear it here // 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)) { if (!$this->watchlists->contains($watchlist)) {
$this->watchLists->add($watchList); $this->watchlists->add($watchlist);
$watchList->setUser($this); $watchlist->setUser($this);
} }
return $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) // set the owning side to null (unless already changed)
if ($watchList->getUser() === $this) { if ($watchlist->getUser() === $this) {
$watchList->setUser(null); $watchlist->setUser(null);
} }
} }
@@ -215,14 +227,38 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; 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; 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\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Repository\WatchListRepository; use App\Repository\WatchlistRepository;
use App\State\WatchListUpdateProcessor; use App\State\WatchlistUpdateProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; 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\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: WatchListRepository::class)] #[ORM\Entity(repositoryClass: WatchlistRepository::class)]
#[ApiResource( #[ApiResource(
shortName: 'Watchlist', shortName: 'Watchlist',
operations: [ operations: [
@@ -41,7 +43,6 @@ use Symfony\Component\Uid\Uuid;
'domain:list', 'domain:list',
'tld:list', 'tld:list',
'event:list', 'event:list',
'domain:list',
'event:list', 'event:list',
], ],
], ],
@@ -51,7 +52,7 @@ use Symfony\Component\Uid\Uuid;
normalizationContext: [ normalizationContext: [
'groups' => [ 'groups' => [
'watchlist:item', 'watchlist:item',
'domain:item', 'domain:list',
'event:list', 'event:list',
'domain-entity:entity', 'domain-entity:entity',
'nameserver-entity:nameserver', 'nameserver-entity:nameserver',
@@ -59,7 +60,7 @@ use Symfony\Component\Uid\Uuid;
'tld:item', 'tld:item',
], ],
], ],
security: 'object.user == user' security: 'object.getUser() == user'
), ),
new Get( new Get(
routeName: 'watchlist_calendar', routeName: 'watchlist_calendar',
@@ -86,16 +87,22 @@ use Symfony\Component\Uid\Uuid;
new Post( new Post(
normalizationContext: ['groups' => 'watchlist:list'], normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => 'watchlist:create'], denormalizationContext: ['groups' => 'watchlist:create'],
processor: WatchListUpdateProcessor::class, processor: WatchlistUpdateProcessor::class,
), ),
new Put( new Put(
normalizationContext: ['groups' => 'watchlist:item'], normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:create', 'watchlist:token']], denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.user == user', security: 'object.getUser() == user',
processor: WatchListUpdateProcessor::class, processor: WatchlistUpdateProcessor::class,
),
new Patch(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
), ),
new Delete( new Delete(
security: 'object.user == user' security: 'object.getUser() == user'
), ),
new Get( new Get(
routeName: 'watchlist_rss_status', 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')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public ?User $user = null; public ?User $user = null;
@@ -159,27 +166,19 @@ class WatchList
/** /**
* @var Collection<int, Domain> * @var Collection<int, Domain>
*/ */
#[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchLists')] #[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchlists')]
#[ORM\JoinTable(name: 'watch_lists_domains', #[ORM\JoinTable(name: 'watchlist_domains',
joinColumns: [new ORM\JoinColumn(name: 'watch_list_token', referencedColumnName: 'token', onDelete: 'CASCADE')], joinColumns: [new ORM\JoinColumn(name: 'watchlist_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name', 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; private Collection $domains;
/** #[ORM\ManyToOne(inversedBy: 'watchlists')]
* @var Collection<int, WatchListTrigger> #[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
*/
#[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'])]
private ?Connector $connector = null; private ?Connector $connector = null;
#[ORM\Column(length: 255, nullable: true)] #[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; private ?string $name = null;
#[ORM\Column] #[ORM\Column]
@@ -187,15 +186,42 @@ class WatchList
private ?\DateTimeImmutable $createdAt = null; private ?\DateTimeImmutable $createdAt = null;
#[SerializedName('dsn')] #[SerializedName('dsn')]
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])] #[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private ?array $webhookDsn = null; 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() public function __construct()
{ {
$this->token = Uuid::v4(); $this->token = Uuid::v4();
$this->domains = new ArrayCollection(); $this->domains = new ArrayCollection();
$this->watchListTriggers = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable('now'); $this->createdAt = new \DateTimeImmutable('now');
} }
@@ -245,36 +271,6 @@ class WatchList
return $this; 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 public function getConnector(): ?Connector
{ {
return $this->connector; return $this->connector;
@@ -322,4 +318,40 @@ class WatchList
return $this; 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 final class OrderDomain
{ {
public function __construct( public function __construct(
public string $watchListToken, public string $watchlistToken,
public string $ldhName, public string $ldhName,
) { ) {
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,20 +2,20 @@
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Entity\WatchList; use App\Entity\Watchlist;
use App\Message\ProcessWatchListsTrigger; use App\Message\ProcessWatchlistTrigger;
use App\Message\UpdateDomainsFromWatchlist; use App\Message\UpdateDomainsFromWatchlist;
use App\Repository\WatchListRepository; use App\Repository\WatchlistRepository;
use Random\Randomizer; use Random\Randomizer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface; use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler] #[AsMessageHandler]
final readonly class ProcessWatchListsTriggerHandler final readonly class ProcessWatchlistTriggerHandler
{ {
public function __construct( public function __construct(
private WatchListRepository $watchListRepository, private WatchlistRepository $watchlistRepository,
private MessageBusInterface $bus, private MessageBusInterface $bus,
) { ) {
} }
@@ -23,7 +23,7 @@ final readonly class ProcessWatchListsTriggerHandler
/** /**
* @throws ExceptionInterface * @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. * 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(); $randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($this->watchListRepository->findAll()); $watchlists = $randomizer->shuffleArray($this->watchlistRepository->getEnabledWatchlist());
/** @var WatchList $watchList */ /** @var Watchlist $watchlist */
foreach ($watchLists as $watchList) { foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchList->getToken())); $this->bus->dispatch(new UpdateDomainsFromWatchlist($watchlist->getToken()));
} }
} }
} }

View File

@@ -2,15 +2,17 @@
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Config\TriggerAction;
use App\Entity\Domain; use App\Entity\Domain;
use App\Entity\DomainEvent; use App\Entity\DomainEvent;
use App\Entity\WatchList; use App\Entity\DomainStatus;
use App\Entity\WatchListTrigger; use App\Entity\Watchlist;
use App\Message\SendDomainEventNotif; use App\Message\SendDomainEventNotif;
use App\Notifier\DomainStatusUpdateNotification;
use App\Notifier\DomainUpdateNotification; use App\Notifier\DomainUpdateNotification;
use App\Repository\DomainEventRepository;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\WatchListRepository; use App\Repository\DomainStatusRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService; use App\Service\ChatNotificationService;
use App\Service\InfluxdbService; use App\Service\InfluxdbService;
use App\Service\StatService; use App\Service\StatService;
@@ -19,7 +21,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Recipient\Recipient; use Symfony\Component\Notifier\Recipient\Recipient;
@@ -35,11 +36,13 @@ final readonly class SendDomainEventNotifHandler
private MailerInterface $mailer, private MailerInterface $mailer,
private StatService $statService, private StatService $statService,
private DomainRepository $domainRepository, private DomainRepository $domainRepository,
private WatchListRepository $watchListRepository, private WatchlistRepository $watchlistRepository,
private ChatNotificationService $chatNotificationService, private ChatNotificationService $chatNotificationService,
#[Autowire(param: 'influxdb_enabled')] #[Autowire(param: 'influxdb_enabled')]
private bool $influxdbEnabled, private bool $influxdbEnabled,
private InfluxdbService $influxdbService, private InfluxdbService $influxdbService,
private DomainEventRepository $domainEventRepository,
private DomainStatusRepository $domainStatusRepository,
) { ) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName); $this->sender = new Address($mailerSenderEmail, $mailerSenderName);
} }
@@ -47,56 +50,99 @@ final readonly class SendDomainEventNotifHandler
/** /**
* @throws TransportExceptionInterface * @throws TransportExceptionInterface
* @throws \Exception * @throws \Exception
* @throws ExceptionInterface
*/ */
public function __invoke(SendDomainEventNotif $message): void public function __invoke(SendDomainEventNotif $message): void
{ {
/** @var WatchList $watchList */ /** @var Watchlist $watchlist */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]); $watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
/** @var Domain $domain */ /** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]); $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) * For each new event whose date is after the domain name update date (before the current domain name update)
*/ */
/** @var DomainEvent $event */ /** @var DomainEvent[] $newEvents */
foreach ($domain->getEvents()->filter( $newEvents = $this->domainEventRepository->findNewDomainEvents($domain, $message->updatedAt);
fn ($event) => $message->updatedAt < $event->getDate() && $event->getDate() < new \DateTimeImmutable()) as $event
) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn ($trigger) => $trigger->getEvent() === $event->getAction());
/* foreach ($newEvents as $event) {
* For each trigger, we perform the appropriate action: send email or send push notification (for now) if (!in_array($event->getAction(), $watchlist->getTrackedEvents())) {
*/ continue;
}
/** @var WatchListTrigger $watchListTrigger */ $notification = new DomainUpdateNotification($this->sender, $event);
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}.', [ $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(), 'event' => $event->getAction(),
'ldhName' => $message->ldhName, 'ldhName' => $message->ldhName,
'username' => $watchList->getUser()->getUserIdentifier(), 'username' => $watchlist->getUser()->getUserIdentifier(),
]); ]);
$recipient = new Recipient($watchList->getUser()->getEmail()); $this->chatNotificationService->sendChatNotification($watchlist, $notification);
$notification = new DomainUpdateNotification($this->sender, $event); if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'chat', true);
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->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) { 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; namespace App\MessageHandler;
use App\Entity\Domain; 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\OrderDomain;
use App\Message\SendDomainEventNotif; use App\Message\SendDomainEventNotif;
use App\Message\UpdateDomainsFromWatchlist; use App\Message\UpdateDomainsFromWatchlist;
use App\Notifier\DomainDeletedNotification; use App\Notifier\DomainDeletedNotification;
use App\Notifier\DomainUpdateErrorNotification; use App\Repository\DomainRepository;
use App\Repository\WatchListRepository; use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService; use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider; use App\Service\Provider\AbstractProvider;
use App\Service\Connector\CheckDomainProviderInterface; use App\Service\Provider\CheckDomainProviderInterface;
use App\Service\RDAPService; use App\Service\RDAPService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -36,10 +38,11 @@ final readonly class UpdateDomainsFromWatchlistHandler
string $mailerSenderEmail, string $mailerSenderEmail,
string $mailerSenderName, string $mailerSenderName,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private WatchListRepository $watchListRepository, private WatchlistRepository $watchlistRepository,
private LoggerInterface $logger, private LoggerInterface $logger,
#[Autowire(service: 'service_container')] #[Autowire(service: 'service_container')]
private ContainerInterface $locator, private ContainerInterface $locator,
private DomainRepository $domainRepository,
) { ) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName); $this->sender = new Address($mailerSenderEmail, $mailerSenderName);
} }
@@ -49,36 +52,37 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/ */
public function __invoke(UpdateDomainsFromWatchlist $message): void public function __invoke(UpdateDomainsFromWatchlist $message): void
{ {
/** @var WatchList $watchList */ /** @var Watchlist $watchlist */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]); $watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
$this->logger->info('Domain names from Watchlist {token} will be processed.', [ $this->logger->debug('Domain names listed in the Watchlist will be updated', [
'token' => $message->watchListToken, 'watchlist' => $message->watchlistToken,
]); ]);
/** @var AbstractProvider $connectorProvider */ /** @var AbstractProvider $connectorProvider */
$connectorProvider = $this->getConnectorProvider($watchList); $connectorProvider = $this->getConnectorProvider($watchlist);
if ($connectorProvider instanceof CheckDomainProviderInterface) { if ($connectorProvider instanceof CheckDomainProviderInterface) {
$this->logger->notice('Watchlist {watchlist} linked to connector {connector}.', [ $this->logger->debug('Watchlist is linked to a connector', [
'watchlist' => $watchList->getToken(), 'watchlist' => $watchlist->getToken(),
'connector' => $watchList->getConnector()->getId(), 'connector' => $watchlist->getConnector()->getId(),
]); ]);
$domainList = array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchlist->getDomains()->toArray()));
try { try {
$checkedDomains = $connectorProvider->checkDomains( $checkedDomains = $connectorProvider->checkDomains(...$domainList);
...array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchList->getDomains()->toArray()))
);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$this->logger->warning('Unable to check domain names availability with connector {connector}.', [ $this->logger->warning('Unable to check domain names availability with this connector', [
'connector' => $watchList->getConnector()->getId(), 'connector' => $watchlist->getConnector()->getId(),
'ldhName' => $domainList,
]); ]);
throw $exception; throw $exception;
} }
foreach ($checkedDomains as $domain) { foreach ($checkedDomains as $domain) {
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain)); $this->bus->dispatch(new OrderDomain($watchlist->getToken(), $domain));
} }
return; return;
@@ -92,9 +96,10 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/ */
/** @var Domain $domain */ /** @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(); $updatedAt = $domain->getUpdatedAt();
$deleted = $domain->getDeleted();
try { 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. * We send messages that correspond to the sending of notifications that will not be processed here.
*/ */
$this->RDAPService->registerDomain($domain->getLdhName()); $this->RDAPService->registerDomain($domain->getLdhName());
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt)); $this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
} catch (NotFoundHttpException) { } catch (DomainNotFoundException) {
if (!$domain->getDeleted()) { $newDomain = $this->domainRepository->findOneBy(['ldhName' => $domain->getLdhName()]);
if (!$deleted && null !== $newDomain && $newDomain->getDeleted()) {
$notification = new DomainDeletedNotification($this->sender, $domain); $notification = new DomainDeletedNotification($this->sender, $domain);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage()); $this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification); $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, * 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 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, * In this case, the domain name can no longer be updated. Unfortunately, there is nothing more that can be done.
* the owner of the Watchlist is informed that an error occurred in updating the domain name.
*/ */
$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()) { if (null === $connector || null === $connector->getProvider()) {
return null; return null;
} }

View File

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

View File

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