Pull request 1907: 951-blocked-services-schedule-api

Updates #951.

Squashed commit of the following:

commit 6b840fd516f5a87fde0420e3aceb9c239b22c974
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Aug 29 19:53:03 2023 +0300

    client: imp docs more

commit 7fc8f0363fbe4c4266cb0f67428fe4d18c351d2d
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Aug 29 19:40:00 2023 +0300

    client: imp docs

commit 00bc14d5760614f2797714cdc2c4c19b1a94b86e
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 18:43:49 2023 +0300

    try to fix lock file

commit d749df74b576091e0b58928d86ea8b3b49f919da
Merge: c69f9230b e1f6229e5
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Aug 28 18:14:02 2023 +0300

    Merge branch 'master' into 951-blocked-services-schedule-api

commit c69f9230b12f7c983db06b74324b3df77d74b32b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 17:16:20 2023 +0300

    revert eslintrc

commit b37916c2dff0ddea5293d87570bb58e3443d2d21
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 12:02:39 2023 +0300

    fix translations

commit f5bb67d81506c687d0abd580049a3eee0af808e0
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 11:43:57 2023 +0300

    fix helpers

commit 13ec6a8b3a0acfb62762ae7e46c6e98eb7c82212
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 11:24:57 2023 +0300

    remove todo

commit 23724ec2fd683ed17b9f1cee841ad9aaf4c9d04f
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 28 09:56:56 2023 +0300

    add clients schedule form

commit 84d29e558a329068e64e7a95ee183946aa4515b5
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 25 17:44:40 2023 +0300

    fix schedule form

commit 83e4017688082e9eb670091d5a24d98157050502
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 18 12:58:16 2023 +0300

    remove unused

commit ef2b68e138da382e3cf42586ae604e12d9493504
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 18 12:57:37 2023 +0300

    client: fix translation string

commit 32ea80c968f52f18adbc811b2f06874644cdfe20
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 18 12:26:26 2023 +0300

    wip schedule

commit 9b770873859186c9424c8d108812e32ddff33bad
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 21 14:29:50 2023 +0300

    all: imp naming

commit ea4e9514ea3b264bcce7f2a301db817de4e87059
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Jul 19 18:09:27 2023 +0300

    all: imp code

commit 98a705bdaa5c1e79394c73e5d75af2416fe9f297
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Jul 18 18:23:26 2023 +0300

    all: imp naming

commit 4f84b55c7bfc9f7b680feac0ec45f5ea9189299a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 14 15:01:17 2023 +0300

    all: add global schedule api

commit 87cf1646869ee9138964b47a27b7493674c8854a
Merge: cabb80ac1 2adc8624c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 14 12:09:29 2023 +0300

    Merge branch 'master' into 951-blocked-services-schedule-api

commit cabb80ac16de437a8118bb0166479574379c97a3
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 13 13:37:23 2023 +0300

    openapi: fix typo

commit 2279b03acbcfc3d76216f8aaf30ae1c7894127bc
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 13 12:26:19 2023 +0300

    all: imp docs

... and 3 more commits
This commit is contained in:
Stanislav Chzhen 2023-08-29 20:03:40 +03:00
parent e1f6229e56
commit aac36a2d2f
54 changed files with 1506 additions and 209 deletions

15
client/.eslintrc.json vendored
View File

@ -81,6 +81,19 @@
} }
], ],
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
"no-alert": "off" "no-alert": "off",
"arrow-body-style": "off",
"max-len": [
"error",
120,
2,
{
"ignoreUrls": true,
"ignoreComments": false,
"ignoreRegExpLiterals": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}
]
} }
} }

2
client/dev.eslintrc vendored
View File

@ -1,6 +1,6 @@
{ {
"extends": ".eslintrc", "extends": ".eslintrc",
"rules": { "rules": {
"no-debugger":"warn", "no-debugger":"warn"
} }
} }

5
client/package-lock.json generated vendored
View File

@ -15094,6 +15094,11 @@
"setimmediate": "^1.0.4" "setimmediate": "^1.0.4"
} }
}, },
"timezones-list": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/timezones-list/-/timezones-list-3.0.2.tgz",
"integrity": "sha512-I698hm6Jp/xxkwyTSOr39pZkYKETL8LDJeSIhjxXBfPUAHM5oZNuQ4o9UK3PSkDBOkjATecSOBb3pR1IkIBUsg=="
},
"tiny-invariant": { "tiny-invariant": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",

1
client/package.json vendored
View File

@ -43,6 +43,7 @@
"redux-form": "^8.3.5", "redux-form": "^8.3.5",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"string-length": "^5.0.1", "string-length": "^5.0.1",
"timezones-list": "^3.0.2",
"url-polyfill": "^1.1.9" "url-polyfill": "^1.1.9"
}, },
"devDependencies": { "devDependencies": {

View File

@ -680,5 +680,37 @@
"protection_section_label": "Protection", "protection_section_label": "Protection",
"log_and_stats_section_label": "Query log and statistics", "log_and_stats_section_label": "Query log and statistics",
"ignore_query_log": "Ignore this client in query log", "ignore_query_log": "Ignore this client in query log",
"ignore_statistics": "Ignore this client in statistics" "ignore_statistics": "Ignore this client in statistics",
"schedule_services": "Pause service blocking",
"schedule_services_desc": "Configure the pause schedule of the service-blocking filter",
"schedule_services_desc_client": "Configure the pause schedule of the service-blocking filter for this client",
"schedule_desc": "Set inactivity periods for blocked services",
"schedule_invalid_select": "Start time must be before end time",
"schedule_select_days": "Select days",
"schedule_timezone": "Select a time zone",
"schedule_current_timezone": "Current time zone: {{value}}",
"schedule_time_all_day": "All day",
"schedule_modal_description": "This schedule will replace any existing schedules for the same day of the week. Each day of the week can have only one inactivity period.",
"schedule_modal_time_off": "No service blocking:",
"schedule_new": "New schedule",
"schedule_edit": "Edit schedule",
"schedule_save": "Save schedule",
"schedule_add": "Add schedule",
"schedule_remove": "Remove schedule",
"schedule_from": "From",
"schedule_to": "To",
"sunday": "Sunday",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday_short": "Sun",
"monday_short": "Mon",
"tuesday_short": "Tue",
"wednesday_short": "Wed",
"thursday_short": "Thu",
"friday_short": "Fri",
"saturday_short": "Sat"
} }

View File

@ -32,19 +32,19 @@ export const getAllBlockedServices = () => async (dispatch) => {
} }
}; };
export const setBlockedServicesRequest = createAction('SET_BLOCKED_SERVICES_REQUEST'); export const updateBlockedServicesRequest = createAction('UPDATE_BLOCKED_SERVICES_REQUEST');
export const setBlockedServicesFailure = createAction('SET_BLOCKED_SERVICES_FAILURE'); export const updateBlockedServicesFailure = createAction('UPDATE_BLOCKED_SERVICES_FAILURE');
export const setBlockedServicesSuccess = createAction('SET_BLOCKED_SERVICES_SUCCESS'); export const updateBlockedServicesSuccess = createAction('UPDATE_BLOCKED_SERVICES_SUCCESS');
export const setBlockedServices = (values) => async (dispatch) => { export const updateBlockedServices = (values) => async (dispatch) => {
dispatch(setBlockedServicesRequest()); dispatch(updateBlockedServicesRequest());
try { try {
await apiClient.setBlockedServices(values); await apiClient.updateBlockedServices(values);
dispatch(setBlockedServicesSuccess()); dispatch(updateBlockedServicesSuccess());
dispatch(getBlockedServices()); dispatch(getBlockedServices());
dispatch(addSuccessToast('blocked_services_saved')); dispatch(addSuccessToast('blocked_services_saved'));
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(setBlockedServicesFailure()); dispatch(updateBlockedServicesFailure());
} }
}; };

View File

@ -489,9 +489,9 @@ class Api {
} }
// Blocked services // Blocked services
BLOCKED_SERVICES_LIST = { path: 'blocked_services/list', method: 'GET' }; BLOCKED_SERVICES_GET = { path: 'blocked_services/get', method: 'GET' };
BLOCKED_SERVICES_SET = { path: 'blocked_services/set', method: 'POST' }; BLOCKED_SERVICES_UPDATE = { path: 'blocked_services/update', method: 'PUT' };
BLOCKED_SERVICES_ALL = { path: 'blocked_services/all', method: 'GET' }; BLOCKED_SERVICES_ALL = { path: 'blocked_services/all', method: 'GET' };
@ -501,12 +501,12 @@ class Api {
} }
getBlockedServices() { getBlockedServices() {
const { path, method } = this.BLOCKED_SERVICES_LIST; const { path, method } = this.BLOCKED_SERVICES_GET;
return this.makeRequest(path, method); return this.makeRequest(path, method);
} }
setBlockedServices(config) { updateBlockedServices(config) {
const { path, method } = this.BLOCKED_SERVICES_SET; const { path, method } = this.BLOCKED_SERVICES_UPDATE;
const parameters = { const parameters = {
data: config, data: config,
}; };

View File

@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { Timezone } from './Timezone';
import { TimeSelect } from './TimeSelect';
import { TimePeriod } from './TimePeriod';
import { getFullDayName, getShortDayName } from './helpers';
import { LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants';
export const DAYS_OF_WEEK = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const INITIAL_START_TIME_MS = 0;
const INITIAL_END_TIME_MS = 86340000;
export const Modal = ({
isOpen,
currentDay,
schedule,
onClose,
onSubmit,
}) => {
const [t] = useTranslation();
const intialTimezone = schedule.time_zone === LOCAL_TIMEZONE_VALUE
? Intl.DateTimeFormat().resolvedOptions().timeZone
: schedule.time_zone;
const [timezone, setTimezone] = useState(intialTimezone);
const [days, setDays] = useState(new Set());
const [startTime, setStartTime] = useState(INITIAL_START_TIME_MS);
const [endTime, setEndTime] = useState(INITIAL_END_TIME_MS);
const [wrongPeriod, setWrongPeriod] = useState(true);
useEffect(() => {
if (currentDay) {
const newDays = new Set([currentDay]);
setDays(newDays);
setStartTime(schedule[currentDay].start);
setEndTime(schedule[currentDay].end);
}
}, [currentDay]);
useEffect(() => {
if (startTime >= endTime) {
setWrongPeriod(true);
} else {
setWrongPeriod(false);
}
}, [startTime, endTime]);
const addDays = (day) => {
const newDays = new Set(days);
if (newDays.has(day)) {
newDays.delete(day);
} else {
newDays.add(day);
}
setDays(newDays);
};
const activeDay = (day) => {
return days.has(day);
};
const onFormSubmit = (e) => {
e.preventDefault();
if (currentDay) {
const newSchedule = schedule;
Array.from(days).forEach((day) => {
newSchedule[day] = {
start: startTime,
end: endTime,
};
});
onSubmit(newSchedule);
} else {
const newSchedule = {
time_zone: timezone,
};
Array.from(days).forEach((day) => {
newSchedule[day] = {
start: startTime,
end: endTime,
};
});
onSubmit(newSchedule);
}
};
return (
<ReactModal
className="Modal__Bootstrap modal-dialog modal-dialog-centered modal-dialog--schedule"
closeTimeoutMS={0}
isOpen={isOpen}
onRequestClose={onClose}
>
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">
{currentDay ? t('schedule_edit') : t('schedule_new')}
</h4>
<button type="button" className="close" onClick={onClose}>
<span className="sr-only">Close</span>
</button>
</div>
<form onSubmit={onFormSubmit}>
<div className="modal-body">
<Timezone
timezone={timezone}
setTimezone={setTimezone}
/>
<div className="schedule__days">
{DAYS_OF_WEEK.map((day) => (
<button
type="button"
key={day}
className="btn schedule__button-day"
data-active={activeDay(day)}
onClick={() => addDays(day)}
>
{getShortDayName(t, day)}
</button>
)) }
</div>
<div className="schedule__time-wrap">
<div className="schedule__time-row">
<TimeSelect
value={startTime}
onChange={(v) => setStartTime(v)}
/>
<TimeSelect
value={endTime}
onChange={(v) => setEndTime(v)}
/>
</div>
{wrongPeriod && (
<div className="schedule__error">
{t('schedule_invalid_select')}
</div>
)}
</div>
<div className="schedule__info">
<div className="schedule__info-title">
{t('schedule_modal_time_off')}
</div>
<div className="schedule__info-row">
<svg className="icons schedule__info-icon">
<use xlinkHref="#calendar" />
</svg>
{days.size ? (
Array.from(days).map((day) => getFullDayName(t, day)).join(', ')
) : (
<span>
</span>
)}
</div>
<div className="schedule__info-row">
<svg className="icons schedule__info-icon">
<use xlinkHref="#watch" />
</svg>
{wrongPeriod ? (
<span>
</span>
) : (
<TimePeriod
startTimeMs={startTime}
endTimeMs={endTime}
/>
)}
</div>
</div>
<div className="schedule__notice">
{t('schedule_modal_description')}
</div>
</div>
<div className="modal-footer">
<div className="btn-list">
<button
type="button"
className="btn btn-success btn-standard"
disabled={days.size === 0 || wrongPeriod}
onClick={onFormSubmit}
>
{currentDay ? t('schedule_save') : t('schedule_add')}
</button>
</div>
</div>
</form>
</div>
</ReactModal>
);
};
Modal.propTypes = {
schedule: PropTypes.object.isRequired,
currentDay: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getTimeFromMs } from './helpers';
export const TimePeriod = ({
startTimeMs,
endTimeMs,
}) => {
const startTime = getTimeFromMs(startTimeMs);
const endTime = getTimeFromMs(endTimeMs);
return (
<div className="schedule__time">
<time>{startTime.hours}:{startTime.minutes}</time>
&nbsp;&nbsp;
<time>{endTime.hours}:{endTime.minutes}</time>
</div>
);
};
TimePeriod.propTypes = {
startTimeMs: PropTypes.number.isRequired,
endTimeMs: PropTypes.number.isRequired,
};

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { getTimeFromMs, convertTimeToMs } from './helpers';
export const TimeSelect = ({
value,
onChange,
}) => {
const { hours: initialHours, minutes: initialMinutes } = getTimeFromMs(value);
const [hours, setHours] = useState(initialHours);
const [minutes, setMinutes] = useState(initialMinutes);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const onHourChange = (event) => {
setHours(event.target.value);
onChange(convertTimeToMs(event.target.value, minutes));
};
const onMinuteChange = (event) => {
setMinutes(event.target.value);
onChange(convertTimeToMs(hours, event.target.value));
};
return (
<div className="schedule__time-select">
<select
value={hours}
onChange={onHourChange}
className="form-control custom-select"
>
{hourOptions.map((hour) => (
<option key={hour} value={hour}>
{hour}
</option>
))}
</select>
&nbsp;:&nbsp;
<select
value={minutes}
onChange={onMinuteChange}
className="form-control custom-select"
>
{minuteOptions.map((minute) => (
<option key={minute} value={minute}>
{minute}
</option>
))}
</select>
</div>
);
};
TimeSelect.propTypes = {
value: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,46 @@
import React from 'react';
import timezones from 'timezones-list';
import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import { LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants';
export const Timezone = ({
timezone,
setTimezone,
}) => {
const [t] = useTranslation();
const onTimeZoneChange = (event) => {
setTimezone(event.target.value);
};
return (
<div className="schedule__timezone">
<label className="form__label form__label--with-desc mb-2">
{t('schedule_timezone')}
</label>
<select
className="form-control custom-select"
value={timezone}
onChange={onTimeZoneChange}
>
<option value={LOCAL_TIMEZONE_VALUE}>
{t('schedule_timezone')}
</option>
{/* TODO: get timezones from backend method when the method is ready */}
{timezones.map((zone) => (
<option key={zone.name} value={zone.tzCode}>
{zone.label}
</option>
))}
</select>
</div>
);
};
Timezone.propTypes = {
timezone: PropTypes.string.isRequired,
setTimezone: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,46 @@
export const getFullDayName = (t, abbreviation) => {
const dayMap = {
sun: t('sunday'),
mon: t('monday'),
tue: t('tuesday'),
wed: t('wednesday'),
thu: t('thursday'),
fri: t('friday'),
sat: t('saturday'),
};
return dayMap[abbreviation] || '';
};
export const getShortDayName = (t, abbreviation) => {
const dayMap = {
sun: t('sunday_short'),
mon: t('monday_short'),
tue: t('tuesday_short'),
wed: t('wednesday_short'),
thu: t('thursday_short'),
fri: t('friday_short'),
sat: t('saturday_short'),
};
return dayMap[abbreviation] || '';
};
export const getTimeFromMs = (value) => {
const selectedTime = new Date(value);
const hours = selectedTime.getUTCHours();
const minutes = selectedTime.getUTCMinutes();
return {
hours: hours.toString().padStart(2, '0'),
minutes: minutes.toString().padStart(2, '0'),
};
};
export const convertTimeToMs = (hours, minutes) => {
const selectedTime = new Date(0);
selectedTime.setUTCHours(parseInt(hours, 10));
selectedTime.setUTCMinutes(parseInt(minutes, 10));
return selectedTime.getTime();
};

View File

@ -0,0 +1,140 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Modal } from './Modal';
import { getFullDayName, getShortDayName } from './helpers';
import { LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants';
import { TimePeriod } from './TimePeriod';
import './styles.css';
export const ScheduleForm = ({
schedule,
onScheduleSubmit,
clientForm,
}) => {
const [t] = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
const [currentDay, setCurrentDay] = useState();
const onModalOpen = () => setModalOpen(true);
const onModalClose = () => setModalOpen(false);
const filteredScheduleKeys = useMemo(() => (
schedule ? Object.keys(schedule).filter((v) => v !== 'time_zone') : []
), [schedule]);
const scheduleMap = new Map();
filteredScheduleKeys.forEach((day) => scheduleMap.set(day, schedule[day]));
const onSubmit = (values) => {
onScheduleSubmit(values);
onModalClose();
};
const onDelete = (day) => {
scheduleMap.delete(day);
const scheduleWeek = Object.fromEntries(Array.from(scheduleMap.entries()));
onScheduleSubmit({
time_zone: schedule.time_zone,
...scheduleWeek,
});
};
const onEdit = (day) => {
setCurrentDay(day);
onModalOpen();
};
const onAdd = () => {
setCurrentDay(undefined);
onModalOpen();
};
return (
<div>
<div className="schedule__current-timezone">
{t('schedule_current_timezone', { value: schedule?.time_zone || LOCAL_TIMEZONE_VALUE })}
</div>
<div className="schedule__rows">
{filteredScheduleKeys.map((day) => {
const data = schedule[day];
if (!data) {
return undefined;
}
return (
<div key={day} className="schedule__row">
<div className="schedule__day">
{getFullDayName(t, day)}
</div>
<div className="schedule__day schedule__day--mobile">
{getShortDayName(t, day)}
</div>
<TimePeriod
startTimeMs={data.start}
endTimeMs={data.end}
/>
<div className="schedule__actions">
<button
type="button"
className="btn btn-icon btn-outline-primary btn-sm schedule__button"
title={t('edit_table_action')}
onClick={() => onEdit(day)}
>
<svg className="icons icon12">
<use xlinkHref="#edit" />
</svg>
</button>
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm schedule__button"
title={t('delete_table_action')}
onClick={() => onDelete(day)}
>
<svg className="icons">
<use xlinkHref="#delete" />
</svg>
</button>
</div>
</div>
);
})}
</div>
<button
type="button"
className={cn(
'btn',
{ 'btn-outline-success btn-sm': clientForm },
{ 'btn-success btn-standard': !clientForm },
)}
onClick={onAdd}
>
{t('schedule_new')}
</button>
{modalOpen && (
<Modal
isOpen={modalOpen}
onClose={onModalClose}
onSubmit={onSubmit}
schedule={schedule}
currentDay={currentDay}
/>
)}
</div>
);
};
ScheduleForm.propTypes = {
schedule: PropTypes.object,
onScheduleSubmit: PropTypes.func.isRequired,
clientForm: PropTypes.bool,
};

View File

@ -0,0 +1,134 @@
.schedule__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.schedule__row:last-child {
margin-bottom: 0;
}
.schedule__rows {
margin-bottom: 24px;
}
.schedule__day {
display: none;
min-width: 110px;
}
.schedule__day--mobile {
display: block;
min-width: 50px;
}
@media screen and (min-width: 767px) {
.schedule__row {
justify-content: flex-start;
}
.schedule__day {
display: block;
}
.schedule__day--mobile {
display: none;
}
.schedule__actions {
margin-left: 32px;
white-space: nowrap;
}
}
.schedule__time {
min-width: 110px;
}
.schedule__button {
border: 0;
}
.schedule__days {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.schedule__button-day {
display: flex;
justify-content: center;
align-items: center;
min-width: 60px;
height: 32px;
font-size: 14px;
line-height: 14px;
color: #495057;
background-color: transparent;
border: 1px solid var(--card-border-color);
border-radius: 4px;
cursor: pointer;
outline: 0;
}
.schedule__button-day[data-active="true"] {
color: var(--btn-success-bgcolor);
border-color: var(--btn-success-bgcolor);
}
.schedule__time-wrap {
margin-bottom: 24px;
}
.schedule__time-row {
display: flex;
align-items: center;
gap: 16px;
}
.schedule__time-select {
display: flex;
align-items: center;
}
.schedule__error {
margin-top: 4px;
font-size: 13px;
color: #cd201f;
}
.schedule__timezone {
margin-bottom: 24px;
}
.schedule__current-timezone {
margin-bottom: 16px;
}
.schedule__info {
margin-bottom: 24px;
}
.schedule__notice {
font-size: 13px;
}
.schedule__info-title {
margin-bottom: 8px;
}
.schedule__info-row {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.schedule__info-icon {
width: 24px;
height: 24px;
margin-right: 8px;
color: #495057;
flex-shrink: 0;
}

View File

@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Form from './Form'; import Form from './Form';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import { getBlockedServices, getAllBlockedServices, setBlockedServices } from '../../../actions/services'; import { getBlockedServices, getAllBlockedServices, updateBlockedServices } from '../../../actions/services';
import PageTitle from '../../ui/PageTitle'; import PageTitle from '../../ui/PageTitle';
import { ScheduleForm } from './ScheduleForm';
const getInitialDataForServices = (initial) => (initial ? initial.reduce( const getInitialDataForServices = (initial) => (initial ? initial.reduce(
(acc, service) => { (acc, service) => {
acc.blocked_services[service] = true; acc.blocked_services[service] = true;
@ -33,10 +35,24 @@ const Services = () => {
.keys(values.blocked_services) .keys(values.blocked_services)
.filter((service) => values.blocked_services[service]); .filter((service) => values.blocked_services[service]);
dispatch(setBlockedServices(blocked_services)); dispatch(updateBlockedServices({
ids: blocked_services,
schedule: services.list.schedule,
}));
}; };
const initialValues = getInitialDataForServices(services.list); const handleScheduleSubmit = (values) => {
dispatch(updateBlockedServices({
ids: services.list.ids,
schedule: values,
}));
};
const initialValues = getInitialDataForServices(services.list.ids);
if (!initialValues) {
return null;
}
return ( return (
<> <>
@ -57,6 +73,17 @@ const Services = () => {
/> />
</div> </div>
</Card> </Card>
<Card
title={t('schedule_services')}
subtitle={t('schedule_services_desc')}
bodyType="card-body box-body--settings"
>
<ScheduleForm
schedule={services.list.schedule}
onScheduleSubmit={handleScheduleSubmit}
/>
</Card>
</> </>
); );
}; };

View File

@ -6,7 +6,7 @@ import { Trans, useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import ReactTable from 'react-table'; import ReactTable from 'react-table';
import { getAllBlockedServices } from '../../../../actions/services'; import { getAllBlockedServices, getBlockedServices } from '../../../../actions/services';
import { initSettings } from '../../../../actions'; import { initSettings } from '../../../../actions';
import { import {
splitByNewLine, splitByNewLine,
@ -14,7 +14,7 @@ import {
sortIp, sortIp,
getService, getService,
} from '../../../../helpers/helpers'; } from '../../../../helpers/helpers';
import { MODAL_TYPE } from '../../../../helpers/constants'; import { MODAL_TYPE, LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants';
import Card from '../../../ui/Card'; import Card from '../../../ui/Card';
import CellWrap from '../../../ui/CellWrap'; import CellWrap from '../../../ui/CellWrap';
import LogsSearchLink from '../../../ui/LogsSearchLink'; import LogsSearchLink from '../../../ui/LogsSearchLink';
@ -45,6 +45,7 @@ const ClientsTable = ({
useEffect(() => { useEffect(() => {
dispatch(getAllBlockedServices()); dispatch(getAllBlockedServices());
dispatch(getBlockedServices());
dispatch(initSettings()); dispatch(initSettings());
}, []); }, []);
@ -112,6 +113,9 @@ const ClientsTable = ({
tags: [], tags: [],
use_global_settings: true, use_global_settings: true,
use_global_blocked_services: true, use_global_blocked_services: true,
blocked_services_schedule: {
time_zone: LOCAL_TIMEZONE_VALUE,
},
safe_search: { ...(safesearch || {}) }, safe_search: { ...(safesearch || {}) },
}; };
}; };

View File

@ -11,6 +11,7 @@ import Select from 'react-select';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import Tabs from '../../ui/Tabs'; import Tabs from '../../ui/Tabs';
import Examples from '../Dns/Upstream/Examples'; import Examples from '../Dns/Upstream/Examples';
import { ScheduleForm } from '../../Filters/Services/ScheduleForm';
import { toggleAllServices, trimLinesAndRemoveEmpty, captitalizeWords } from '../../../helpers/helpers'; import { toggleAllServices, trimLinesAndRemoveEmpty, captitalizeWords } from '../../../helpers/helpers';
import { import {
renderInputField, renderInputField,
@ -137,10 +138,10 @@ let Form = (props) => {
handleSubmit, handleSubmit,
reset, reset,
change, change,
pristine,
submitting, submitting,
useGlobalSettings, useGlobalSettings,
useGlobalServices, useGlobalServices,
blockedServicesSchedule,
toggleClientModal, toggleClientModal,
processingAdding, processingAdding,
processingUpdating, processingUpdating,
@ -155,6 +156,10 @@ let Form = (props) => {
const [activeTabLabel, setActiveTabLabel] = useState('settings'); const [activeTabLabel, setActiveTabLabel] = useState('settings');
const handleScheduleSubmit = (values) => {
change('blocked_services_schedule', values);
};
const tabs = { const tabs = {
settings: { settings: {
title: 'settings', title: 'settings',
@ -269,6 +274,21 @@ let Form = (props) => {
</div> </div>
</div>, </div>,
}, },
schedule_services: {
title: 'schedule_services',
component: (
<>
<div className="form__desc mb-4">
<Trans>schedule_services_desc_client</Trans>
</div>
<ScheduleForm
schedule={blockedServicesSchedule}
onScheduleSubmit={handleScheduleSubmit}
clientForm
/>
</>
),
},
upstream_dns: { upstream_dns: {
title: 'upstream_dns', title: 'upstream_dns',
component: <div label="upstream" title={props.t('upstream_dns')}> component: <div label="upstream" title={props.t('upstream_dns')}>
@ -355,8 +375,12 @@ let Form = (props) => {
</div> </div>
</div> </div>
<Tabs controlClass="form" tabs={tabs} activeTabLabel={activeTabLabel} <Tabs
setActiveTabLabel={setActiveTabLabel}> controlClass="form"
tabs={tabs}
activeTabLabel={activeTabLabel}
setActiveTabLabel={setActiveTabLabel}
>
{activeTab} {activeTab}
</Tabs> </Tabs>
</div> </div>
@ -380,7 +404,6 @@ let Form = (props) => {
disabled={ disabled={
submitting submitting
|| invalid || invalid
|| pristine
|| processingAdding || processingAdding
|| processingUpdating || processingUpdating
} }
@ -402,6 +425,7 @@ Form.propTypes = {
toggleClientModal: PropTypes.func.isRequired, toggleClientModal: PropTypes.func.isRequired,
useGlobalSettings: PropTypes.bool, useGlobalSettings: PropTypes.bool,
useGlobalServices: PropTypes.bool, useGlobalServices: PropTypes.bool,
blockedServicesSchedule: PropTypes.object,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
processingAdding: PropTypes.bool.isRequired, processingAdding: PropTypes.bool.isRequired,
processingUpdating: PropTypes.bool.isRequired, processingUpdating: PropTypes.bool.isRequired,
@ -415,9 +439,11 @@ const selector = formValueSelector(FORM_NAME.CLIENT);
Form = connect((state) => { Form = connect((state) => {
const useGlobalSettings = selector(state, 'use_global_settings'); const useGlobalSettings = selector(state, 'use_global_settings');
const useGlobalServices = selector(state, 'use_global_blocked_services'); const useGlobalServices = selector(state, 'use_global_blocked_services');
const blockedServicesSchedule = selector(state, 'blocked_services_schedule');
return { return {
useGlobalSettings, useGlobalSettings,
useGlobalServices, useGlobalServices,
blockedServicesSchedule,
}; };
})(Form); })(Form);

View File

@ -225,6 +225,20 @@ const Icons = () => (
<path stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" d="M8.036 10.93l3.93 4.07 4.068-3.93" /> <path stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" d="M8.036 10.93l3.93 4.07 4.068-3.93" />
</g> </g>
</symbol> </symbol>
<symbol id="calendar" fill="none" height="24" viewBox="0 0 24 24" width="24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5.5" width="16" height="14" rx="3" />
<path d="M12 4V7" />
<path d="M8 4L8 7" />
<path d="M16 4V7" />
<path d="M9.7397 15.5V11L8 13" />
<path d="M14.7397 15.5V11L13 13" />
</symbol>
<symbol id="watch" fill="none" height="24" viewBox="0 0 24 24" width="24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M16.1215 12.1213H11.8789V7.87866" />
</symbol>
</svg> </svg>
); );

View File

@ -41,7 +41,8 @@
} }
@media (min-width: 576px) { @media (min-width: 576px) {
.modal-dialog--clients { .modal-dialog--clients,
.modal-dialog--schedule {
max-width: 650px; max-width: 650px;
} }
} }

View File

@ -470,6 +470,10 @@ hr {
border-top: 1px solid rgba(0, 40, 100, 0.12); border-top: 1px solid rgba(0, 40, 100, 0.12);
} }
[data-theme=dark] hr {
border-color: var(--card-border-color);
}
small, small,
.small { .small {
font-size: 87.5%; font-size: 87.5%;

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { initSettings, toggleSetting } from '../actions'; import { initSettings, toggleSetting } from '../actions';
import { getBlockedServices, setBlockedServices } from '../actions/services'; import { getBlockedServices, updateBlockedServices } from '../actions/services';
import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats'; import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats';
import { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs'; import { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs';
import { getFilteringStatus, setFiltersConfig } from '../actions/filtering'; import { getFilteringStatus, setFiltersConfig } from '../actions/filtering';
@ -24,7 +24,7 @@ const mapDispatchToProps = {
initSettings, initSettings,
toggleSetting, toggleSetting,
getBlockedServices, getBlockedServices,
setBlockedServices, updateBlockedServices,
getStatsConfig, getStatsConfig,
setStatsConfig, setStatsConfig,
resetStats, resetStats,

View File

@ -552,3 +552,5 @@ export const DISABLE_PROTECTION_TIMINGS = {
}; };
export const LOCAL_STORAGE_THEME_KEY = 'account_theme'; export const LOCAL_STORAGE_THEME_KEY = 'account_theme';
export const LOCAL_TIMEZONE_VALUE = 'Local';

View File

@ -20,9 +20,9 @@ const services = handleActions(
processingAll: false, processingAll: false,
}), }),
[actions.setBlockedServicesRequest]: (state) => ({ ...state, processingSet: true }), [actions.updateBlockedServicesRequest]: (state) => ({ ...state, processingSet: true }),
[actions.setBlockedServicesFailure]: (state) => ({ ...state, processingSet: false }), [actions.updateBlockedServicesFailure]: (state) => ({ ...state, processingSet: false }),
[actions.setBlockedServicesSuccess]: (state) => ({ [actions.updateBlockedServicesSuccess]: (state) => ({
...state, ...state,
processingSet: false, processingSet: false,
}), }),
@ -31,7 +31,7 @@ const services = handleActions(
processing: true, processing: true,
processingAll: true, processingAll: true,
processingSet: false, processingSet: false,
list: [], list: {},
allServices: [], allServices: [],
}, },
); );

View File

@ -2,7 +2,6 @@
package aghhttp package aghhttp
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -61,23 +60,3 @@ func WriteTextPlainDeprecated(w http.ResponseWriter, r *http.Request) (isPlainTe
return true return true
} }
// WriteJSONResponse sets the content-type header in w.Header() to
// "application/json", writes a header with a "200 OK" status, encodes resp to
// w, calls [Error] on any returned error, and returns it as well.
func WriteJSONResponse(w http.ResponseWriter, r *http.Request, resp any) (err error) {
return WriteJSONResponseCode(w, r, http.StatusOK, resp)
}
// WriteJSONResponseCode is like [WriteJSONResponse] but adds the ability to
// redefine the status code.
func WriteJSONResponseCode(w http.ResponseWriter, r *http.Request, code int, resp any) (err error) {
w.Header().Set(httphdr.ContentType, HdrValApplicationJSON)
w.WriteHeader(code)
err = json.NewEncoder(w).Encode(resp)
if err != nil {
Error(r, w, http.StatusInternalServerError, "encoding resp: %s", err)
}
return err
}

View File

@ -1,4 +1,4 @@
package websvc package aghhttp
import ( import (
"encoding/json" "encoding/json"
@ -7,7 +7,6 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/httphdr" "github.com/AdguardTeam/golibs/httphdr"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )
@ -87,30 +86,29 @@ func (t *JSONTime) UnmarshalJSON(b []byte) (err error) {
return nil return nil
} }
// writeJSONOKResponse writes headers with the code 200 OK, encodes v into w, // WriteJSONResponse writes headers with the code, encodes resp into w, and logs
// and logs any errors it encounters. r is used to get additional information // any errors it encounters. r is used to get additional information from the
// from the request.
func writeJSONOKResponse(w http.ResponseWriter, r *http.Request, v any) {
writeJSONResponse(w, r, v, http.StatusOK)
}
// writeJSONResponse writes headers with code, encodes v into w, and logs any
// errors it encounters. r is used to get additional information from the
// request. // request.
func writeJSONResponse(w http.ResponseWriter, r *http.Request, v any, code int) { func WriteJSONResponse(w http.ResponseWriter, r *http.Request, code int, resp any) {
// TODO(a.garipov): Put some of these to a middleware.
h := w.Header() h := w.Header()
h.Set(httphdr.ContentType, aghhttp.HdrValApplicationJSON) h.Set(httphdr.ContentType, HdrValApplicationJSON)
h.Set(httphdr.Server, aghhttp.UserAgent()) h.Set(httphdr.Server, UserAgent())
w.WriteHeader(code) w.WriteHeader(code)
err := json.NewEncoder(w).Encode(v) err := json.NewEncoder(w).Encode(resp)
if err != nil { if err != nil {
log.Error("websvc: writing resp to %s %s: %s", r.Method, r.URL.Path, err) log.Error("aghhttp: writing json resp to %s %s: %s", r.Method, r.URL.Path, err)
} }
} }
// WriteJSONResponseOK writes headers with the code 200 OK, encodes v into w,
// and logs any errors it encounters. r is used to get additional information
// from the request.
func WriteJSONResponseOK(w http.ResponseWriter, r *http.Request, v any) {
WriteJSONResponse(w, r, http.StatusOK, v)
}
// ErrorCode is the error code as used by the HTTP API. See the ErrorCode // ErrorCode is the error code as used by the HTTP API. See the ErrorCode
// definition in the OpenAPI specification. // definition in the OpenAPI specification.
type ErrorCode string type ErrorCode string
@ -131,14 +129,14 @@ type HTTPAPIErrorResp struct {
Msg string `json:"msg"` Msg string `json:"msg"`
} }
// writeJSONErrorResponse encodes err as a JSON error into w, and logs any // WriteJSONResponseError encodes err as a JSON error into w, and logs any
// errors it encounters. r is used to get additional information from the // errors it encounters. r is used to get additional information from the
// request. // request.
func writeJSONErrorResponse(w http.ResponseWriter, r *http.Request, err error) { func WriteJSONResponseError(w http.ResponseWriter, r *http.Request, err error) {
log.Error("websvc: %s %s: %s", r.Method, r.URL.Path, err) log.Error("aghhttp: writing json error to %s %s: %s", r.Method, r.URL.Path, err)
writeJSONResponse(w, r, &HTTPAPIErrorResp{ WriteJSONResponse(w, r, http.StatusUnprocessableEntity, &HTTPAPIErrorResp{
Code: ErrorCodeTMP000, Code: ErrorCodeTMP000,
Msg: err.Error(), Msg: err.Error(),
}, http.StatusUnprocessableEntity) })
} }

View File

@ -1,18 +1,18 @@
package websvc_test package aghhttp_test
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// testJSONTime is the JSON time for tests. // testJSONTime is the JSON time for tests.
var testJSONTime = websvc.JSONTime(time.Unix(1_234_567_890, 123_456_000).UTC()) var testJSONTime = aghhttp.JSONTime(time.Unix(1_234_567_890, 123_456_000).UTC())
// testJSONTimeStr is the string with the JSON encoding of testJSONTime. // testJSONTimeStr is the string with the JSON encoding of testJSONTime.
const testJSONTimeStr = "1234567890123.456" const testJSONTimeStr = "1234567890123.456"
@ -21,17 +21,17 @@ func TestJSONTime_MarshalJSON(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
wantErrMsg string wantErrMsg string
in websvc.JSONTime in aghhttp.JSONTime
want []byte want []byte
}{{ }{{
name: "unix_zero", name: "unix_zero",
wantErrMsg: "", wantErrMsg: "",
in: websvc.JSONTime(time.Unix(0, 0)), in: aghhttp.JSONTime(time.Unix(0, 0)),
want: []byte("0"), want: []byte("0"),
}, { }, {
name: "empty", name: "empty",
wantErrMsg: "", wantErrMsg: "",
in: websvc.JSONTime{}, in: aghhttp.JSONTime{},
want: []byte("-6795364578871.345"), want: []byte("-6795364578871.345"),
}, { }, {
name: "time", name: "time",
@ -51,7 +51,7 @@ func TestJSONTime_MarshalJSON(t *testing.T) {
t.Run("json", func(t *testing.T) { t.Run("json", func(t *testing.T) {
in := &struct { in := &struct {
A websvc.JSONTime A aghhttp.JSONTime
}{ }{
A: testJSONTime, A: testJSONTime,
} }
@ -67,7 +67,7 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
wantErrMsg string wantErrMsg string
want websvc.JSONTime want aghhttp.JSONTime
data []byte data []byte
}{{ }{{
name: "time", name: "time",
@ -78,13 +78,13 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) {
name: "bad", name: "bad",
wantErrMsg: `parsing json time: strconv.ParseFloat: parsing "{}": ` + wantErrMsg: `parsing json time: strconv.ParseFloat: parsing "{}": ` +
`invalid syntax`, `invalid syntax`,
want: websvc.JSONTime{}, want: aghhttp.JSONTime{},
data: []byte(`{}`), data: []byte(`{}`),
}} }}
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
var got websvc.JSONTime var got aghhttp.JSONTime
err := got.UnmarshalJSON(tc.data) err := got.UnmarshalJSON(tc.data)
testutil.AssertErrorMsg(t, tc.wantErrMsg, err) testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
@ -93,7 +93,7 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) {
} }
t.Run("nil", func(t *testing.T) { t.Run("nil", func(t *testing.T) {
err := (*websvc.JSONTime)(nil).UnmarshalJSON([]byte("0")) err := (*aghhttp.JSONTime)(nil).UnmarshalJSON([]byte("0"))
require.Error(t, err) require.Error(t, err)
msg := err.Error() msg := err.Error()
@ -103,7 +103,7 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) {
t.Run("json", func(t *testing.T) { t.Run("json", func(t *testing.T) {
want := testJSONTime want := testJSONTime
var got struct { var got struct {
A websvc.JSONTime A aghhttp.JSONTime
} }
err := json.Unmarshal([]byte(`{"A":`+testJSONTimeStr+`}`), &got) err := json.Unmarshal([]byte(`{"A":`+testJSONTimeStr+`}`), &got)

View File

@ -146,7 +146,7 @@ func (s *server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
status.Leases = leasesToDynamic(s.Leases(LeasesDynamic)) status.Leases = leasesToDynamic(s.Leases(LeasesDynamic))
status.StaticLeases = leasesToStatic(s.Leases(LeasesStatic)) status.StaticLeases = leasesToStatic(s.Leases(LeasesStatic))
_ = aghhttp.WriteJSONResponse(w, r, status) aghhttp.WriteJSONResponseOK(w, r, status)
} }
func (s *server) enableDHCP(ifaceName string) (code int, err error) { func (s *server) enableDHCP(ifaceName string) (code int, err error) {
@ -395,7 +395,7 @@ func (s *server) handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
} }
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// newNetInterfaceJSON creates a JSON object from a [net.Interface] iface. // newNetInterfaceJSON creates a JSON object from a [net.Interface] iface.
@ -547,7 +547,7 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
setOtherDHCPResult(ifaceName, result) setOtherDHCPResult(ifaceName, result)
_ = aghhttp.WriteJSONResponse(w, r, result) aghhttp.WriteJSONResponseOK(w, r, result)
} }
// setOtherDHCPResult sets the results of the check for another DHCP server in // setOtherDHCPResult sets the results of the check for another DHCP server in

View File

@ -24,7 +24,7 @@ type jsonError struct {
// TODO(a.garipov): Either take the logger from the server after we've // TODO(a.garipov): Either take the logger from the server after we've
// refactored logging or make this not a method of *Server. // refactored logging or make this not a method of *Server.
func (s *server) notImplemented(w http.ResponseWriter, r *http.Request) { func (s *server) notImplemented(w http.ResponseWriter, r *http.Request) {
_ = aghhttp.WriteJSONResponseCode(w, r, http.StatusNotImplemented, &jsonError{ aghhttp.WriteJSONResponse(w, r, http.StatusNotImplemented, &jsonError{
Message: aghos.Unsupported("dhcp").Error(), Message: aghos.Unsupported("dhcp").Error(),
}) })
} }

View File

@ -182,7 +182,7 @@ func (s *Server) accessListJSON() (j accessListJSON) {
} }
func (s *Server) handleAccessList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAccessList(w http.ResponseWriter, r *http.Request) {
_ = aghhttp.WriteJSONResponse(w, r, s.accessListJSON()) aghhttp.WriteJSONResponseOK(w, r, s.accessListJSON())
} }
// validateAccessSet checks the internal accessListJSON lists. To search for // validateAccessSet checks the internal accessListJSON lists. To search for

View File

@ -169,7 +169,7 @@ func (s *Server) getDNSConfig() (c *jsonDNSConfig) {
// handleGetConfig handles requests to the GET /control/dns_info endpoint. // handleGetConfig handles requests to the GET /control/dns_info endpoint.
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
resp := s.getDNSConfig() resp := s.getDNSConfig()
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
func (req *jsonDNSConfig) checkBlockingMode() (err error) { func (req *jsonDNSConfig) checkBlockingMode() (err error) {
@ -758,7 +758,7 @@ func (s *Server) handleTestUpstreamDNS(w http.ResponseWriter, r *http.Request) {
} }
} }
_ = aghhttp.WriteJSONResponse(w, r, result) aghhttp.WriteJSONResponseOK(w, r, result)
} }
// handleCacheClear is the handler for the POST /control/cache_clear HTTP API. // handleCacheClear is the handler for the POST /control/cache_clear HTTP API.

View File

@ -50,10 +50,10 @@ func initBlockedServices() {
// BlockedServices is the configuration of blocked services. // BlockedServices is the configuration of blocked services.
type BlockedServices struct { type BlockedServices struct {
// Schedule is blocked services schedule for every day of the week. // Schedule is blocked services schedule for every day of the week.
Schedule *schedule.Weekly `yaml:"schedule"` Schedule *schedule.Weekly `json:"schedule" yaml:"schedule"`
// IDs is the names of blocked services. // IDs is the names of blocked services.
IDs []string `yaml:"ids"` IDs []string `json:"ids" yaml:"ids"`
} }
// Clone returns a deep copy of blocked services. // Clone returns a deep copy of blocked services.
@ -114,25 +114,33 @@ func (d *DNSFilter) ApplyBlockedServicesList(setts *Settings, list []string) {
} }
func (d *DNSFilter) handleBlockedServicesIDs(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleBlockedServicesIDs(w http.ResponseWriter, r *http.Request) {
_ = aghhttp.WriteJSONResponse(w, r, serviceIDs) aghhttp.WriteJSONResponseOK(w, r, serviceIDs)
} }
func (d *DNSFilter) handleBlockedServicesAll(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleBlockedServicesAll(w http.ResponseWriter, r *http.Request) {
_ = aghhttp.WriteJSONResponse(w, r, struct { aghhttp.WriteJSONResponseOK(w, r, struct {
BlockedServices []blockedService `json:"blocked_services"` BlockedServices []blockedService `json:"blocked_services"`
}{ }{
BlockedServices: blockedServices, BlockedServices: blockedServices,
}) })
} }
// handleBlockedServicesList is the handler for the GET
// /control/blocked_services/list HTTP API.
//
// Deprecated: Use handleBlockedServicesGet.
func (d *DNSFilter) handleBlockedServicesList(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleBlockedServicesList(w http.ResponseWriter, r *http.Request) {
d.confLock.RLock() d.confLock.RLock()
list := d.Config.BlockedServices.IDs list := d.Config.BlockedServices.IDs
d.confLock.RUnlock() d.confLock.RUnlock()
_ = aghhttp.WriteJSONResponse(w, r, list) aghhttp.WriteJSONResponseOK(w, r, list)
} }
// handleBlockedServicesSet is the handler for the POST
// /control/blocked_services/set HTTP API.
//
// Deprecated: Use handleBlockedServicesUpdate.
func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Request) {
list := []string{} list := []string{}
err := json.NewDecoder(r.Body).Decode(&list) err := json.NewDecoder(r.Body).Decode(&list)
@ -150,3 +158,51 @@ func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Requ
d.Config.ConfigModified() d.Config.ConfigModified()
} }
// handleBlockedServicesGet is the handler for the GET
// /control/blocked_services/get HTTP API.
func (d *DNSFilter) handleBlockedServicesGet(w http.ResponseWriter, r *http.Request) {
var bsvc *BlockedServices
func() {
d.confLock.RLock()
defer d.confLock.RUnlock()
bsvc = d.Config.BlockedServices.Clone()
}()
aghhttp.WriteJSONResponseOK(w, r, bsvc)
}
// handleBlockedServicesUpdate is the handler for the PUT
// /control/blocked_services/update HTTP API.
func (d *DNSFilter) handleBlockedServicesUpdate(w http.ResponseWriter, r *http.Request) {
bsvc := &BlockedServices{}
err := json.NewDecoder(r.Body).Decode(bsvc)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
return
}
err = bsvc.Validate()
if err != nil {
aghhttp.Error(r, w, http.StatusUnprocessableEntity, "validating: %s", err)
return
}
if bsvc.Schedule == nil {
bsvc.Schedule = schedule.EmptyWeekly()
}
func() {
d.confLock.Lock()
defer d.confLock.Unlock()
d.Config.BlockedServices = bsvc
}()
log.Debug("updated blocked services schedule: %d", len(bsvc.IDs))
d.Config.ConfigModified()
}

View File

@ -301,7 +301,7 @@ func (d *DNSFilter) handleFilteringRefresh(w http.ResponseWriter, r *http.Reques
return return
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
type filterJSON struct { type filterJSON struct {
@ -354,7 +354,7 @@ func (d *DNSFilter) handleFilteringStatus(w http.ResponseWriter, r *http.Request
resp.UserRules = d.UserRules resp.UserRules = d.UserRules
d.filtersMu.RUnlock() d.filtersMu.RUnlock()
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// Set filtering configuration // Set filtering configuration
@ -456,7 +456,7 @@ func (d *DNSFilter) handleCheckHost(w http.ResponseWriter, r *http.Request) {
} }
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// setProtectedBool sets the value of a boolean pointer under a lock. l must // setProtectedBool sets the value of a boolean pointer under a lock. l must
@ -504,7 +504,7 @@ func (d *DNSFilter) handleSafeBrowsingStatus(w http.ResponseWriter, r *http.Requ
Enabled: protectedBool(&d.confLock, &d.Config.SafeBrowsingEnabled), Enabled: protectedBool(&d.confLock, &d.Config.SafeBrowsingEnabled),
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleParentalEnable is the handler for the POST /control/parental/enable // handleParentalEnable is the handler for the POST /control/parental/enable
@ -530,7 +530,7 @@ func (d *DNSFilter) handleParentalStatus(w http.ResponseWriter, r *http.Request)
Enabled: protectedBool(&d.confLock, &d.Config.ParentalEnabled), Enabled: protectedBool(&d.confLock, &d.Config.ParentalEnabled),
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// RegisterFilteringHandlers - register handlers // RegisterFilteringHandlers - register handlers
@ -560,9 +560,14 @@ func (d *DNSFilter) RegisterFilteringHandlers() {
registerHTTP(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesIDs) registerHTTP(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesIDs)
registerHTTP(http.MethodGet, "/control/blocked_services/all", d.handleBlockedServicesAll) registerHTTP(http.MethodGet, "/control/blocked_services/all", d.handleBlockedServicesAll)
// Deprecated handlers.
registerHTTP(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList) registerHTTP(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList)
registerHTTP(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet) registerHTTP(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet)
registerHTTP(http.MethodGet, "/control/blocked_services/get", d.handleBlockedServicesGet)
registerHTTP(http.MethodPut, "/control/blocked_services/update", d.handleBlockedServicesUpdate)
registerHTTP(http.MethodGet, "/control/filtering/status", d.handleFilteringStatus) registerHTTP(http.MethodGet, "/control/filtering/status", d.handleFilteringStatus)
registerHTTP(http.MethodPost, "/control/filtering/config", d.handleFilteringConfig) registerHTTP(http.MethodPost, "/control/filtering/config", d.handleFilteringConfig)
registerHTTP(http.MethodPost, "/control/filtering/add_url", d.handleFilteringAddURL) registerHTTP(http.MethodPost, "/control/filtering/add_url", d.handleFilteringAddURL)

View File

@ -28,7 +28,7 @@ func (d *DNSFilter) handleRewriteList(w http.ResponseWriter, r *http.Request) {
} }
d.confLock.Unlock() d.confLock.Unlock()
_ = aghhttp.WriteJSONResponse(w, r, arr) aghhttp.WriteJSONResponseOK(w, r, arr)
} }
func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) {

View File

@ -36,7 +36,7 @@ func (d *DNSFilter) handleSafeSearchStatus(w http.ResponseWriter, r *http.Reques
resp = d.Config.SafeSearchConf resp = d.Config.SafeSearchConf
}() }()
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleSafeSearchSettings is the handler for PUT /control/safesearch/settings // handleSafeSearchSettings is the handler for PUT /control/safesearch/settings

View File

@ -34,8 +34,12 @@ type clientJSON struct {
WHOIS *whois.Info `json:"whois_info,omitempty"` WHOIS *whois.Info `json:"whois_info,omitempty"`
SafeSearchConf *filtering.SafeSearchConfig `json:"safe_search"` SafeSearchConf *filtering.SafeSearchConfig `json:"safe_search"`
// Schedule is blocked services schedule for every day of the week.
Schedule *schedule.Weekly `json:"blocked_services_schedule"`
Name string `json:"name"` Name string `json:"name"`
// BlockedServices is the names of blocked services.
BlockedServices []string `json:"blocked_services"` BlockedServices []string `json:"blocked_services"`
IDs []string `json:"ids"` IDs []string `json:"ids"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
@ -53,6 +57,34 @@ type clientJSON struct {
IgnoreStatistics aghalg.NullBool `json:"ignore_statistics"` IgnoreStatistics aghalg.NullBool `json:"ignore_statistics"`
} }
// copySettings returns a copy of specific settings from JSON or a previous
// client.
func (j *clientJSON) copySettings(
prev *Client,
) (weekly *schedule.Weekly, ignoreQueryLog, ignoreStatistics bool) {
if j.Schedule != nil {
weekly = j.Schedule.Clone()
} else if prev != nil && prev.BlockedServices != nil {
weekly = prev.BlockedServices.Schedule.Clone()
} else {
weekly = schedule.EmptyWeekly()
}
if j.IgnoreQueryLog != aghalg.NBNull {
ignoreQueryLog = j.IgnoreQueryLog == aghalg.NBTrue
} else if prev != nil {
ignoreQueryLog = prev.IgnoreQueryLog
}
if j.IgnoreStatistics != aghalg.NBNull {
ignoreStatistics = j.IgnoreStatistics == aghalg.NBTrue
} else if prev != nil {
ignoreStatistics = prev.IgnoreStatistics
}
return weekly, ignoreQueryLog, ignoreStatistics
}
type runtimeClientJSON struct { type runtimeClientJSON struct {
WHOIS *whois.Info `json:"whois_info"` WHOIS *whois.Info `json:"whois_info"`
@ -93,7 +125,7 @@ func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http
data.Tags = clientTags data.Tags = clientTags
_ = aghhttp.WriteJSONResponse(w, r, data) aghhttp.WriteJSONResponseOK(w, r, data)
} }
// jsonToClient converts JSON object to Client object. // jsonToClient converts JSON object to Client object.
@ -119,9 +151,15 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C
} }
} }
weekly := schedule.EmptyWeekly() weekly, ignoreQueryLog, ignoreStatistics := cj.copySettings(prev)
if prev != nil {
weekly = prev.BlockedServices.Schedule.Clone() bs := &filtering.BlockedServices{
Schedule: weekly,
IDs: cj.BlockedServices,
}
err = bs.Validate()
if err != nil {
return nil, fmt.Errorf("validating blocked services: %w", err)
} }
c = &Client{ c = &Client{
@ -129,10 +167,7 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C
Name: cj.Name, Name: cj.Name,
BlockedServices: &filtering.BlockedServices{ BlockedServices: bs,
Schedule: weekly,
IDs: cj.BlockedServices,
},
IDs: cj.IDs, IDs: cj.IDs,
Tags: cj.Tags, Tags: cj.Tags,
@ -143,18 +178,8 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C
ParentalEnabled: cj.ParentalEnabled, ParentalEnabled: cj.ParentalEnabled,
SafeBrowsingEnabled: cj.SafeBrowsingEnabled, SafeBrowsingEnabled: cj.SafeBrowsingEnabled,
UseOwnBlockedServices: !cj.UseGlobalBlockedServices, UseOwnBlockedServices: !cj.UseGlobalBlockedServices,
} IgnoreQueryLog: ignoreQueryLog,
IgnoreStatistics: ignoreStatistics,
if cj.IgnoreQueryLog != aghalg.NBNull {
c.IgnoreQueryLog = cj.IgnoreQueryLog == aghalg.NBTrue
} else if prev != nil {
c.IgnoreQueryLog = prev.IgnoreQueryLog
}
if cj.IgnoreStatistics != aghalg.NBNull {
c.IgnoreStatistics = cj.IgnoreStatistics == aghalg.NBTrue
} else if prev != nil {
c.IgnoreStatistics = prev.IgnoreStatistics
} }
if safeSearchConf.Enabled { if safeSearchConf.Enabled {
@ -191,6 +216,7 @@ func clientToJSON(c *Client) (cj *clientJSON) {
UseGlobalBlockedServices: !c.UseOwnBlockedServices, UseGlobalBlockedServices: !c.UseOwnBlockedServices,
Schedule: c.BlockedServices.Schedule,
BlockedServices: c.BlockedServices.IDs, BlockedServices: c.BlockedServices.IDs,
Upstreams: c.Upstreams, Upstreams: c.Upstreams,
@ -338,7 +364,7 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http
}) })
} }
_ = aghhttp.WriteJSONResponse(w, r, data) aghhttp.WriteJSONResponseOK(w, r, data)
} }
// findRuntime looks up the IP in runtime and temporary storages, like // findRuntime looks up the IP in runtime and temporary storages, like

View File

@ -170,7 +170,7 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
resp.IsDHCPAvailable = Context.dhcpServer != nil resp.IsDHCPAvailable = Context.dhcpServer != nil
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// ------------------------ // ------------------------

View File

@ -59,7 +59,7 @@ func (web *webAPI) handleInstallGetAddresses(w http.ResponseWriter, r *http.Requ
data.Interfaces[iface.Name] = iface data.Interfaces[iface.Name] = iface
} }
_ = aghhttp.WriteJSONResponse(w, r, data) aghhttp.WriteJSONResponseOK(w, r, data)
} }
type checkConfReqEnt struct { type checkConfReqEnt struct {
@ -190,7 +190,7 @@ func (web *webAPI) handleInstallCheckConfig(w http.ResponseWriter, r *http.Reque
resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP) resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP)
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleStaticIP - handles static IP request // handleStaticIP - handles static IP request

View File

@ -33,7 +33,7 @@ func (web *webAPI) handleVersionJSON(w http.ResponseWriter, r *http.Request) {
resp := &versionResponse{} resp := &versionResponse{}
if web.conf.disableUpdate { if web.conf.disableUpdate {
resp.Disabled = true resp.Disabled = true
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
return return
} }
@ -68,7 +68,7 @@ func (web *webAPI) handleVersionJSON(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// requestVersionInfo sets the VersionInfo field of resp if it can reach the // requestVersionInfo sets the VersionInfo field of resp if it can reach the

View File

@ -58,7 +58,7 @@ type languageJSON struct {
func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) { func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) {
log.Printf("home: language is %s", config.Language) log.Printf("home: language is %s", config.Language)
_ = aghhttp.WriteJSONResponse(w, r, &languageJSON{ aghhttp.WriteJSONResponseOK(w, r, &languageJSON{
Language: config.Language, Language: config.Language,
}) })
} }

View File

@ -61,7 +61,7 @@ func handleGetProfile(w http.ResponseWriter, r *http.Request) {
} }
}() }()
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handlePutProfile is the handler for PUT /control/profile/update endpoint. // handlePutProfile is the handler for PUT /control/profile/update endpoint.

View File

@ -770,7 +770,7 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
data.PrivateKey = "" data.PrivateKey = ""
} }
_ = aghhttp.WriteJSONResponse(w, r, data) aghhttp.WriteJSONResponseOK(w, r, data)
} }
// registerWebHandlers registers HTTP handlers for TLS configuration. // registerWebHandlers registers HTTP handlers for TLS configuration.

View File

@ -7,6 +7,7 @@ import (
"net/netip" "net/netip"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
) )
@ -17,13 +18,13 @@ import (
type ReqPatchSettingsDNS struct { type ReqPatchSettingsDNS struct {
// TODO(a.garipov): Add more as we go. // TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"` Addresses []netip.AddrPort `json:"addresses"`
BootstrapServers []string `json:"bootstrap_servers"` BootstrapServers []string `json:"bootstrap_servers"`
UpstreamServers []string `json:"upstream_servers"` UpstreamServers []string `json:"upstream_servers"`
DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"`
UpstreamTimeout JSONDuration `json:"upstream_timeout"` UpstreamTimeout aghhttp.JSONDuration `json:"upstream_timeout"`
BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"`
UseDNS64 bool `json:"use_dns64"` UseDNS64 bool `json:"use_dns64"`
} }
// HTTPAPIDNSSettings are the DNS settings as used by the HTTP API. See the // HTTPAPIDNSSettings are the DNS settings as used by the HTTP API. See the
@ -31,13 +32,13 @@ type ReqPatchSettingsDNS struct {
type HTTPAPIDNSSettings struct { type HTTPAPIDNSSettings struct {
// TODO(a.garipov): Add more as we go. // TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"` Addresses []netip.AddrPort `json:"addresses"`
BootstrapServers []string `json:"bootstrap_servers"` BootstrapServers []string `json:"bootstrap_servers"`
UpstreamServers []string `json:"upstream_servers"` UpstreamServers []string `json:"upstream_servers"`
DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"`
UpstreamTimeout JSONDuration `json:"upstream_timeout"` UpstreamTimeout aghhttp.JSONDuration `json:"upstream_timeout"`
BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"`
UseDNS64 bool `json:"use_dns64"` UseDNS64 bool `json:"use_dns64"`
} }
// handlePatchSettingsDNS is the handler for the PATCH /api/v1/settings/dns HTTP // handlePatchSettingsDNS is the handler for the PATCH /api/v1/settings/dns HTTP
@ -53,7 +54,7 @@ func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Reques
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
if err != nil { if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err)) aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("decoding: %w", err))
return return
} }
@ -71,7 +72,7 @@ func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Reques
ctx := r.Context() ctx := r.Context()
err = svc.confMgr.UpdateDNS(ctx, newConf) err = svc.confMgr.UpdateDNS(ctx, newConf)
if err != nil { if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("updating: %w", err)) aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("updating: %w", err))
return return
} }
@ -79,17 +80,17 @@ func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Reques
newSvc := svc.confMgr.DNS() newSvc := svc.confMgr.DNS()
err = newSvc.Start() err = newSvc.Start()
if err != nil { if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("starting new service: %w", err)) aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("starting new service: %w", err))
return return
} }
writeJSONOKResponse(w, r, &HTTPAPIDNSSettings{ aghhttp.WriteJSONResponseOK(w, r, &HTTPAPIDNSSettings{
Addresses: newConf.Addresses, Addresses: newConf.Addresses,
BootstrapServers: newConf.BootstrapServers, BootstrapServers: newConf.BootstrapServers,
UpstreamServers: newConf.UpstreamServers, UpstreamServers: newConf.UpstreamServers,
DNS64Prefixes: newConf.DNS64Prefixes, DNS64Prefixes: newConf.DNS64Prefixes,
UpstreamTimeout: JSONDuration(newConf.UpstreamTimeout), UpstreamTimeout: aghhttp.JSONDuration(newConf.UpstreamTimeout),
BootstrapPreferIPv6: newConf.BootstrapPreferIPv6, BootstrapPreferIPv6: newConf.BootstrapPreferIPv6,
UseDNS64: newConf.UseDNS64, UseDNS64: newConf.UseDNS64,
}) })

View File

@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
@ -24,7 +25,7 @@ func TestService_HandlePatchSettingsDNS(t *testing.T) {
BootstrapServers: []string{"1.0.0.1"}, BootstrapServers: []string{"1.0.0.1"},
UpstreamServers: []string{"1.1.1.1"}, UpstreamServers: []string{"1.1.1.1"},
DNS64Prefixes: []netip.Prefix{netip.MustParsePrefix("1234::/64")}, DNS64Prefixes: []netip.Prefix{netip.MustParsePrefix("1234::/64")},
UpstreamTimeout: websvc.JSONDuration(2 * time.Second), UpstreamTimeout: aghhttp.JSONDuration(2 * time.Second),
BootstrapPreferIPv6: true, BootstrapPreferIPv6: true,
UseDNS64: true, UseDNS64: true,
} }

View File

@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )
@ -21,9 +22,9 @@ type ReqPatchSettingsHTTP struct {
// //
// TODO(a.garipov): Add wait time. // TODO(a.garipov): Add wait time.
Addresses []netip.AddrPort `json:"addresses"` Addresses []netip.AddrPort `json:"addresses"`
SecureAddresses []netip.AddrPort `json:"secure_addresses"` SecureAddresses []netip.AddrPort `json:"secure_addresses"`
Timeout JSONDuration `json:"timeout"` Timeout aghhttp.JSONDuration `json:"timeout"`
} }
// HTTPAPIHTTPSettings are the HTTP settings as used by the HTTP API. See the // HTTPAPIHTTPSettings are the HTTP settings as used by the HTTP API. See the
@ -31,10 +32,10 @@ type ReqPatchSettingsHTTP struct {
type HTTPAPIHTTPSettings struct { type HTTPAPIHTTPSettings struct {
// TODO(a.garipov): Add more as we go. // TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"` Addresses []netip.AddrPort `json:"addresses"`
SecureAddresses []netip.AddrPort `json:"secure_addresses"` SecureAddresses []netip.AddrPort `json:"secure_addresses"`
Timeout JSONDuration `json:"timeout"` Timeout aghhttp.JSONDuration `json:"timeout"`
ForceHTTPS bool `json:"force_https"` ForceHTTPS bool `json:"force_https"`
} }
// handlePatchSettingsHTTP is the handler for the PATCH /api/v1/settings/http // handlePatchSettingsHTTP is the handler for the PATCH /api/v1/settings/http
@ -46,7 +47,7 @@ func (svc *Service) handlePatchSettingsHTTP(w http.ResponseWriter, r *http.Reque
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
if err != nil { if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err)) aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("decoding: %w", err))
return return
} }
@ -65,10 +66,10 @@ func (svc *Service) handlePatchSettingsHTTP(w http.ResponseWriter, r *http.Reque
ForceHTTPS: svc.forceHTTPS, ForceHTTPS: svc.forceHTTPS,
} }
writeJSONOKResponse(w, r, &HTTPAPIHTTPSettings{ aghhttp.WriteJSONResponseOK(w, r, &HTTPAPIHTTPSettings{
Addresses: newConf.Addresses, Addresses: newConf.Addresses,
SecureAddresses: newConf.SecureAddresses, SecureAddresses: newConf.SecureAddresses,
Timeout: JSONDuration(newConf.Timeout), Timeout: aghhttp.JSONDuration(newConf.Timeout),
ForceHTTPS: newConf.ForceHTTPS, ForceHTTPS: newConf.ForceHTTPS,
}) })

View File

@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc" "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -20,7 +21,7 @@ func TestService_HandlePatchSettingsHTTP(t *testing.T) {
wantWeb := &websvc.HTTPAPIHTTPSettings{ wantWeb := &websvc.HTTPAPIHTTPSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:80")}, Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:80")},
SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:443")}, SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:443")},
Timeout: websvc.JSONDuration(10 * time.Second), Timeout: aghhttp.JSONDuration(10 * time.Second),
ForceHTTPS: false, ForceHTTPS: false,
} }

View File

@ -2,6 +2,8 @@ package websvc
import ( import (
"net/http" "net/http"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
) )
// All Settings Handlers // All Settings Handlers
@ -25,20 +27,20 @@ func (svc *Service) handleGetSettingsAll(w http.ResponseWriter, r *http.Request)
httpConf := webSvc.Config() httpConf := webSvc.Config()
// TODO(a.garipov): Add all currently supported parameters. // TODO(a.garipov): Add all currently supported parameters.
writeJSONOKResponse(w, r, &RespGetV1SettingsAll{ aghhttp.WriteJSONResponseOK(w, r, &RespGetV1SettingsAll{
DNS: &HTTPAPIDNSSettings{ DNS: &HTTPAPIDNSSettings{
Addresses: dnsConf.Addresses, Addresses: dnsConf.Addresses,
BootstrapServers: dnsConf.BootstrapServers, BootstrapServers: dnsConf.BootstrapServers,
UpstreamServers: dnsConf.UpstreamServers, UpstreamServers: dnsConf.UpstreamServers,
DNS64Prefixes: dnsConf.DNS64Prefixes, DNS64Prefixes: dnsConf.DNS64Prefixes,
UpstreamTimeout: JSONDuration(dnsConf.UpstreamTimeout), UpstreamTimeout: aghhttp.JSONDuration(dnsConf.UpstreamTimeout),
BootstrapPreferIPv6: dnsConf.BootstrapPreferIPv6, BootstrapPreferIPv6: dnsConf.BootstrapPreferIPv6,
UseDNS64: dnsConf.UseDNS64, UseDNS64: dnsConf.UseDNS64,
}, },
HTTP: &HTTPAPIHTTPSettings{ HTTP: &HTTPAPIHTTPSettings{
Addresses: httpConf.Addresses, Addresses: httpConf.Addresses,
SecureAddresses: httpConf.SecureAddresses, SecureAddresses: httpConf.SecureAddresses,
Timeout: JSONDuration(httpConf.Timeout), Timeout: aghhttp.JSONDuration(httpConf.Timeout),
ForceHTTPS: httpConf.ForceHTTPS, ForceHTTPS: httpConf.ForceHTTPS,
}, },
}) })

View File

@ -9,6 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc" "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
@ -23,14 +24,14 @@ func TestService_HandleGetSettingsAll(t *testing.T) {
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:53")}, Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:53")},
BootstrapServers: []string{"94.140.14.140", "94.140.14.141"}, BootstrapServers: []string{"94.140.14.140", "94.140.14.141"},
UpstreamServers: []string{"94.140.14.14", "1.1.1.1"}, UpstreamServers: []string{"94.140.14.14", "1.1.1.1"},
UpstreamTimeout: websvc.JSONDuration(1 * time.Second), UpstreamTimeout: aghhttp.JSONDuration(1 * time.Second),
BootstrapPreferIPv6: true, BootstrapPreferIPv6: true,
} }
wantWeb := &websvc.HTTPAPIHTTPSettings{ wantWeb := &websvc.HTTPAPIHTTPSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:80")}, Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:80")},
SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:443")}, SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:443")},
Timeout: websvc.JSONDuration(5 * time.Second), Timeout: aghhttp.JSONDuration(5 * time.Second),
ForceHTTPS: true, ForceHTTPS: true,
} }

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"runtime" "runtime"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/version" "github.com/AdguardTeam/AdGuardHome/internal/version"
) )
@ -12,24 +13,24 @@ import (
// RespGetV1SystemInfo describes the response of the GET /api/v1/system/info // RespGetV1SystemInfo describes the response of the GET /api/v1/system/info
// HTTP API. // HTTP API.
type RespGetV1SystemInfo struct { type RespGetV1SystemInfo struct {
Arch string `json:"arch"` Arch string `json:"arch"`
Channel string `json:"channel"` Channel string `json:"channel"`
OS string `json:"os"` OS string `json:"os"`
NewVersion string `json:"new_version,omitempty"` NewVersion string `json:"new_version,omitempty"`
Start JSONTime `json:"start"` Start aghhttp.JSONTime `json:"start"`
Version string `json:"version"` Version string `json:"version"`
} }
// handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP // handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP
// API. // API.
func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) { func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) {
writeJSONOKResponse(w, r, &RespGetV1SystemInfo{ aghhttp.WriteJSONResponseOK(w, r, &RespGetV1SystemInfo{
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
Channel: version.Channel(), Channel: version.Channel(),
OS: runtime.GOOS, OS: runtime.GOOS,
// TODO(a.garipov): Fill this when we have an updater. // TODO(a.garipov): Fill this when we have an updater.
NewVersion: "", NewVersion: "",
Start: JSONTime(svc.start), Start: aghhttp.JSONTime(svc.start),
Version: version.Version(), Version: version.Version(),
}) })
} }

View File

@ -93,7 +93,7 @@ func (l *queryLog) handleQueryLog(w http.ResponseWriter, r *http.Request) {
resp := entriesToJSON(entries, oldest, l.anonymizer.Load()) resp := entriesToJSON(entries, oldest, l.anonymizer.Load())
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleQueryLogClear is the handler for the POST /control/querylog/clear HTTP // handleQueryLogClear is the handler for the POST /control/querylog/clear HTTP
@ -118,7 +118,7 @@ func (l *queryLog) handleQueryLogInfo(w http.ResponseWriter, r *http.Request) {
ivl = timeutil.Day * 90 ivl = timeutil.Day * 90
} }
_ = aghhttp.WriteJSONResponse(w, r, configJSON{ aghhttp.WriteJSONResponseOK(w, r, configJSON{
Enabled: aghalg.BoolToNullBool(l.conf.Enabled), Enabled: aghalg.BoolToNullBool(l.conf.Enabled),
Interval: ivl.Hours() / 24, Interval: ivl.Hours() / 24,
AnonymizeClientIP: aghalg.BoolToNullBool(l.conf.AnonymizeClientIP), AnonymizeClientIP: aghalg.BoolToNullBool(l.conf.AnonymizeClientIP),
@ -143,7 +143,7 @@ func (l *queryLog) handleGetQueryLogConfig(w http.ResponseWriter, r *http.Reques
slices.Sort(resp.Ignored) slices.Sort(resp.Ignored)
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// AnonymizeIP masks ip to anonymize the client if the ip is a valid one. // AnonymizeIP masks ip to anonymize the client if the ip is a valid one.

View File

@ -2,9 +2,11 @@
package schedule package schedule
import ( import (
"encoding/json"
"fmt" "fmt"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/timeutil" "github.com/AdguardTeam/golibs/timeutil"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -50,6 +52,10 @@ func FullWeekly() (w *Weekly) {
// Clone returns a deep copy of a weekly. // Clone returns a deep copy of a weekly.
func (w *Weekly) Clone() (c *Weekly) { func (w *Weekly) Clone() (c *Weekly) {
if w == nil {
return nil
}
// NOTE: Do not use time.LoadLocation, because the results will be // NOTE: Do not use time.LoadLocation, because the results will be
// different on time zone database update. // different on time zone database update.
return &Weekly{ return &Weekly{
@ -75,12 +81,62 @@ func (w *Weekly) Contains(t time.Time) (ok bool) {
return dr.contains(offset) return dr.contains(offset)
} }
// type check
var _ json.Unmarshaler = (*Weekly)(nil)
// UnmarshalJSON implements the [json.Unmarshaler] interface for *Weekly.
func (w *Weekly) UnmarshalJSON(data []byte) (err error) {
conf := &weeklyConfigJSON{}
err = json.Unmarshal(data, conf)
if err != nil {
return err
}
weekly := Weekly{}
weekly.location, err = time.LoadLocation(conf.TimeZone)
if err != nil {
return err
}
days := []*dayConfigJSON{
time.Sunday: conf.Sunday,
time.Monday: conf.Monday,
time.Tuesday: conf.Tuesday,
time.Wednesday: conf.Wednesday,
time.Thursday: conf.Thursday,
time.Friday: conf.Friday,
time.Saturday: conf.Saturday,
}
for i, d := range days {
var r dayRange
if d != nil {
r = dayRange{
start: time.Duration(d.Start),
end: time.Duration(d.End),
}
}
err = w.validate(r)
if err != nil {
return fmt.Errorf("weekday %s: %w", time.Weekday(i), err)
}
weekly.days[i] = r
}
*w = weekly
return nil
}
// type check // type check
var _ yaml.Unmarshaler = (*Weekly)(nil) var _ yaml.Unmarshaler = (*Weekly)(nil)
// UnmarshalYAML implements the [yaml.Unmarshaler] interface for *Weekly. // UnmarshalYAML implements the [yaml.Unmarshaler] interface for *Weekly.
func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) { func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) {
conf := &weeklyConfig{} conf := &weeklyConfigYAML{}
err = value.Decode(conf) err = value.Decode(conf)
if err != nil { if err != nil {
@ -96,7 +152,7 @@ func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) {
return err return err
} }
days := []dayConfig{ days := []dayConfigYAML{
time.Sunday: conf.Sunday, time.Sunday: conf.Sunday,
time.Monday: conf.Monday, time.Monday: conf.Monday,
time.Tuesday: conf.Tuesday, time.Tuesday: conf.Tuesday,
@ -124,24 +180,24 @@ func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) {
return nil return nil
} }
// weeklyConfig is the YAML configuration structure of Weekly. // weeklyConfigYAML is the YAML configuration structure of Weekly.
type weeklyConfig struct { type weeklyConfigYAML struct {
// TimeZone is the local time zone. // TimeZone is the local time zone.
TimeZone string `yaml:"time_zone"` TimeZone string `yaml:"time_zone"`
// Days of the week. // Days of the week.
Sunday dayConfig `yaml:"sun,omitempty"` Sunday dayConfigYAML `yaml:"sun,omitempty"`
Monday dayConfig `yaml:"mon,omitempty"` Monday dayConfigYAML `yaml:"mon,omitempty"`
Tuesday dayConfig `yaml:"tue,omitempty"` Tuesday dayConfigYAML `yaml:"tue,omitempty"`
Wednesday dayConfig `yaml:"wed,omitempty"` Wednesday dayConfigYAML `yaml:"wed,omitempty"`
Thursday dayConfig `yaml:"thu,omitempty"` Thursday dayConfigYAML `yaml:"thu,omitempty"`
Friday dayConfig `yaml:"fri,omitempty"` Friday dayConfigYAML `yaml:"fri,omitempty"`
Saturday dayConfig `yaml:"sat,omitempty"` Saturday dayConfigYAML `yaml:"sat,omitempty"`
} }
// dayConfig is the YAML configuration structure of dayRange. // dayConfigYAML is the YAML configuration structure of dayRange.
type dayConfig struct { type dayConfigYAML struct {
Start timeutil.Duration `yaml:"start"` Start timeutil.Duration `yaml:"start"`
End timeutil.Duration `yaml:"end"` End timeutil.Duration `yaml:"end"`
} }
@ -172,38 +228,57 @@ func (w *Weekly) validate(r dayRange) (err error) {
} }
} }
// type check
var _ json.Marshaler = (*Weekly)(nil)
// MarshalJSON implements the [json.Marshaler] interface for *Weekly.
func (w *Weekly) MarshalJSON() (data []byte, err error) {
c := &weeklyConfigJSON{
TimeZone: w.location.String(),
Sunday: w.days[time.Sunday].toDayConfigJSON(),
Monday: w.days[time.Monday].toDayConfigJSON(),
Tuesday: w.days[time.Tuesday].toDayConfigJSON(),
Wednesday: w.days[time.Wednesday].toDayConfigJSON(),
Thursday: w.days[time.Thursday].toDayConfigJSON(),
Friday: w.days[time.Friday].toDayConfigJSON(),
Saturday: w.days[time.Saturday].toDayConfigJSON(),
}
return json.Marshal(c)
}
// type check // type check
var _ yaml.Marshaler = (*Weekly)(nil) var _ yaml.Marshaler = (*Weekly)(nil)
// MarshalYAML implements the [yaml.Marshaler] interface for *Weekly. // MarshalYAML implements the [yaml.Marshaler] interface for *Weekly.
func (w *Weekly) MarshalYAML() (v any, err error) { func (w *Weekly) MarshalYAML() (v any, err error) {
return weeklyConfig{ return weeklyConfigYAML{
TimeZone: w.location.String(), TimeZone: w.location.String(),
Sunday: dayConfig{ Sunday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Sunday].start}, Start: timeutil.Duration{Duration: w.days[time.Sunday].start},
End: timeutil.Duration{Duration: w.days[time.Sunday].end}, End: timeutil.Duration{Duration: w.days[time.Sunday].end},
}, },
Monday: dayConfig{ Monday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Monday].start}, Start: timeutil.Duration{Duration: w.days[time.Monday].start},
End: timeutil.Duration{Duration: w.days[time.Monday].end}, End: timeutil.Duration{Duration: w.days[time.Monday].end},
}, },
Tuesday: dayConfig{ Tuesday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Tuesday].start}, Start: timeutil.Duration{Duration: w.days[time.Tuesday].start},
End: timeutil.Duration{Duration: w.days[time.Tuesday].end}, End: timeutil.Duration{Duration: w.days[time.Tuesday].end},
}, },
Wednesday: dayConfig{ Wednesday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Wednesday].start}, Start: timeutil.Duration{Duration: w.days[time.Wednesday].start},
End: timeutil.Duration{Duration: w.days[time.Wednesday].end}, End: timeutil.Duration{Duration: w.days[time.Wednesday].end},
}, },
Thursday: dayConfig{ Thursday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Thursday].start}, Start: timeutil.Duration{Duration: w.days[time.Thursday].start},
End: timeutil.Duration{Duration: w.days[time.Thursday].end}, End: timeutil.Duration{Duration: w.days[time.Thursday].end},
}, },
Friday: dayConfig{ Friday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Friday].start}, Start: timeutil.Duration{Duration: w.days[time.Friday].start},
End: timeutil.Duration{Duration: w.days[time.Friday].end}, End: timeutil.Duration{Duration: w.days[time.Friday].end},
}, },
Saturday: dayConfig{ Saturday: dayConfigYAML{
Start: timeutil.Duration{Duration: w.days[time.Saturday].start}, Start: timeutil.Duration{Duration: w.days[time.Saturday].start},
End: timeutil.Duration{Duration: w.days[time.Saturday].end}, End: timeutil.Duration{Duration: w.days[time.Saturday].end},
}, },
@ -248,3 +323,38 @@ func (r dayRange) validate() (err error) {
func (r *dayRange) contains(offset time.Duration) (ok bool) { func (r *dayRange) contains(offset time.Duration) (ok bool) {
return r.start <= offset && offset < r.end return r.start <= offset && offset < r.end
} }
// toDayConfigJSON returns nil if the day range is empty, otherwise returns
// initialized JSON configuration of the day range.
func (r dayRange) toDayConfigJSON() (j *dayConfigJSON) {
if (r == dayRange{}) {
return nil
}
return &dayConfigJSON{
Start: aghhttp.JSONDuration(r.start),
End: aghhttp.JSONDuration(r.end),
}
}
// weeklyConfigJSON is the JSON configuration structure of Weekly.
type weeklyConfigJSON struct {
// TimeZone is the local time zone.
TimeZone string `json:"time_zone"`
// Days of the week.
Sunday *dayConfigJSON `json:"sun,omitempty"`
Monday *dayConfigJSON `json:"mon,omitempty"`
Tuesday *dayConfigJSON `json:"tue,omitempty"`
Wednesday *dayConfigJSON `json:"wed,omitempty"`
Thursday *dayConfigJSON `json:"thu,omitempty"`
Friday *dayConfigJSON `json:"fri,omitempty"`
Saturday *dayConfigJSON `json:"sat,omitempty"`
}
// dayConfigJSON is the JSON configuration structure of dayRange.
type dayConfigJSON struct {
Start aghhttp.JSONDuration `json:"start"`
End aghhttp.JSONDuration `json:"end"`
}

View File

@ -1,6 +1,7 @@
package schedule package schedule
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
@ -122,7 +123,7 @@ func TestWeekly_Contains(t *testing.T) {
} }
} }
const brusselsSunday = ` const brusselsSundayYAML = `
sun: sun:
start: 12h start: 12h
end: 14h end: 14h
@ -179,7 +180,7 @@ yaml: "bad"
}, { }, {
name: "brussels_sunday", name: "brussels_sunday",
wantErrMsg: "", wantErrMsg: "",
data: []byte(brusselsSunday), data: []byte(brusselsSundayYAML),
want: brusselsWeekly, want: brusselsWeekly,
}, { }, {
name: "start_equal_end", name: "start_equal_end",
@ -240,7 +241,7 @@ func TestWeekly_MarshalYAML(t *testing.T) {
want: &Weekly{}, want: &Weekly{},
}, { }, {
name: "brussels_sunday", name: "brussels_sunday",
data: []byte(brusselsSunday), data: []byte(brusselsSundayYAML),
want: brusselsWeekly, want: brusselsWeekly,
}} }}
@ -369,3 +370,142 @@ func TestDayRange_Validate(t *testing.T) {
}) })
} }
} }
const brusselsSundayJSON = `{
"sun": {
"end": 50400000,
"start": 43200000
},
"time_zone": "Europe/Brussels"
}`
func TestWeekly_UnmarshalJSON(t *testing.T) {
const (
sameTime = `{
"sun": {
"end": 32400000,
"start": 32400000
}
}`
negativeStart = `{
"sun": {
"end": 3600000,
"start": -3600000
}
}`
badTZ = `{
"time_zone": "bad_timezone"
}`
badJSON = `{
"bad": "json",
}`
)
brusseltsTZ, err := time.LoadLocation("Europe/Brussels")
require.NoError(t, err)
brusselsWeekly := &Weekly{
days: [7]dayRange{{
start: time.Hour * 12,
end: time.Hour * 14,
}},
location: brusseltsTZ,
}
testCases := []struct {
name string
wantErrMsg string
data []byte
want *Weekly
}{{
name: "empty",
wantErrMsg: "unexpected end of JSON input",
data: []byte(""),
want: &Weekly{},
}, {
name: "null",
wantErrMsg: "",
data: []byte("null"),
want: &Weekly{location: time.UTC},
}, {
name: "brussels_sunday",
wantErrMsg: "",
data: []byte(brusselsSundayJSON),
want: brusselsWeekly,
}, {
name: "start_equal_end",
wantErrMsg: "weekday Sunday: bad day range: start 9h0m0s is greater or equal to end 9h0m0s",
data: []byte(sameTime),
want: &Weekly{},
}, {
name: "start_negative",
wantErrMsg: "weekday Sunday: bad day range: start -1h0m0s is negative",
data: []byte(negativeStart),
want: &Weekly{},
}, {
name: "bad_time_zone",
wantErrMsg: "unknown time zone bad_timezone",
data: []byte(badTZ),
want: &Weekly{},
}, {
name: "bad_json",
wantErrMsg: "invalid character '}' looking for beginning of object key string",
data: []byte(badJSON),
want: &Weekly{},
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := &Weekly{}
err = json.Unmarshal(tc.data, w)
testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
assert.Equal(t, tc.want, w)
})
}
}
func TestWeekly_MarshalJSON(t *testing.T) {
brusselsTZ, err := time.LoadLocation("Europe/Brussels")
require.NoError(t, err)
brusselsWeekly := &Weekly{
days: [7]dayRange{time.Sunday: {
start: time.Hour * 12,
end: time.Hour * 14,
}},
location: brusselsTZ,
}
testCases := []struct {
name string
data []byte
want *Weekly
}{{
name: "empty",
data: []byte(""),
want: &Weekly{},
}, {
name: "null",
data: []byte("null"),
want: &Weekly{},
}, {
name: "brussels_sunday",
data: []byte(brusselsSundayJSON),
want: brusselsWeekly,
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var data []byte
data, err = json.Marshal(brusselsWeekly)
require.NoError(t, err)
w := &Weekly{}
err = json.Unmarshal(data, w)
require.NoError(t, err)
assert.Equal(t, brusselsWeekly, w)
})
}
}

View File

@ -73,7 +73,7 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// configResp is the response to the GET /control/stats_info. // configResp is the response to the GET /control/stats_info.
@ -122,7 +122,7 @@ func (s *StatsCtx) handleStatsInfo(w http.ResponseWriter, r *http.Request) {
resp.IntervalDays = 0 resp.IntervalDays = 0
} }
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleGetStatsConfig is the handler for the GET /control/stats/config HTTP // handleGetStatsConfig is the handler for the GET /control/stats/config HTTP
@ -142,7 +142,7 @@ func (s *StatsCtx) handleGetStatsConfig(w http.ResponseWriter, r *http.Request)
slices.Sort(resp.Ignored) slices.Sort(resp.Ignored)
_ = aghhttp.WriteJSONResponse(w, r, resp) aghhttp.WriteJSONResponseOK(w, r, resp)
} }
// handleStatsConfig is the handler for the POST /control/stats_config HTTP API. // handleStatsConfig is the handler for the POST /control/stats_config HTTP API.

View File

@ -4,6 +4,86 @@
## v0.108.0: API changes ## v0.108.0: API changes
## v0.107.37: API changes
### Deprecated blocked services APIs
* The `GET /control/blocked_services/list` HTTP API; use the new `GET
/control/blocked_services/get` API instead.
* The `POST /control/blocked_services/set` HTTP API; use the new `PUT
/control/blocked_services/update` API instead.
### New blocked services APIs
* The new `GET /control/blocked_services/get` HTTP API.
* The new `PUT /control/blocked_services/update` HTTP API allows config
updates.
These APIs accept and return a JSON object with the following format:
```json
{
"schedule": {
"time_zone": "Local",
"sun": {
"start": 46800000,
"end": 82800000
}
},
"ids": [
"vk"
]
}
```
### `/control/clients` HTTP APIs
The following HTTP APIs have been changed:
* `GET /control/clients`;
* `GET /control/clients/find?ip0=...&ip1=...&ip2=...`;
* `POST /control/clients/add`;
* `POST /control/clients/update`;
The new field `blocked_services_schedule` has been added to JSON objects. It
has the following format:
```json
{
"time_zone": "Local",
"sun": {
"start": 0,
"end": 86400000
},
"mon": {
"start": 60000,
"end": 82800000
},
"thu": {
"start": 120000,
"end": 79200000
},
"tue": {
"start": 180000,
"end": 75600000
},
"wed": {
"start": 240000,
"end": 72000000
},
"fri": {
"start": 300000,
"end": 68400000
},
"sat": {
"start": 360000,
"end": 64800000
}
}
```
## v0.107.36: API changes ## v0.107.36: API changes
### The new fields `"top_upstreams_responses"` and `"top_upstreams_avg_time"` in `Stats` object ### The new fields `"top_upstreams_responses"` and `"top_upstreams_avg_time"` in `Stats` object

View File

@ -1001,6 +1001,9 @@
'$ref': '#/components/schemas/BlockedServicesAll' '$ref': '#/components/schemas/BlockedServicesAll'
'/blocked_services/list': '/blocked_services/list':
'get': 'get':
'deprecated': true
'description': >
Deprecated: Use `GET /blocked_services/get` instead.
'tags': 'tags':
- 'blocked_services' - 'blocked_services'
'operationId': 'blockedServicesList' 'operationId': 'blockedServicesList'
@ -1014,6 +1017,9 @@
'$ref': '#/components/schemas/BlockedServicesArray' '$ref': '#/components/schemas/BlockedServicesArray'
'/blocked_services/set': '/blocked_services/set':
'post': 'post':
'deprecated': true
'description': >
Deprecated: Use `PUT /blocked_services/update` instead.
'tags': 'tags':
- 'blocked_services' - 'blocked_services'
'operationId': 'blockedServicesSet' 'operationId': 'blockedServicesSet'
@ -1026,6 +1032,34 @@
'responses': 'responses':
'200': '200':
'description': 'OK.' 'description': 'OK.'
'/blocked_services/get':
'get':
'tags':
- 'blocked_services'
'operationId': 'blockedServicesSchedule'
'summary': 'Get blocked services'
'responses':
'200':
'description': 'OK.'
'content':
'application/json':
'schema':
'$ref': '#/components/schemas/BlockedServicesSchedule'
'/blocked_services/update':
'put':
'tags':
- 'blocked_services'
'operationId': 'blockedServicesScheduleUpdate'
'summary': 'Update blocked services'
'requestBody':
'content':
'application/json':
'schema':
'$ref': '#/components/schemas/BlockedServicesSchedule'
'required': true
'responses':
'200':
'description': 'OK.'
'/rewrite/list': '/rewrite/list':
'get': 'get':
'tags': 'tags':
@ -2485,6 +2519,54 @@
'type': 'boolean' 'type': 'boolean'
'youtube': 'youtube':
'type': 'boolean' 'type': 'boolean'
'Schedule':
'type': 'object'
'description': >
Sets periods of inactivity for filtering blocked services. The
schedule contains 7 days (Sunday to Saturday) and a time zone.
'properties':
'time_zone':
'description': >
Time zone name according to IANA time zone database. For example
`Europe/Brussels`. `Local` represents the system's local time
zone.
'type': 'string'
'sun':
'$ref': '#/components/schemas/DayRange'
'mon':
'$ref': '#/components/schemas/DayRange'
'tue':
'$ref': '#/components/schemas/DayRange'
'wed':
'$ref': '#/components/schemas/DayRange'
'thu':
'$ref': '#/components/schemas/DayRange'
'fri':
'$ref': '#/components/schemas/DayRange'
'sat':
'$ref': '#/components/schemas/DayRange'
'DayRange':
'type': 'object'
'description': >
The single interval within a day. It begins at the `start` and ends
before the `end`.
'properties':
'start':
'type': 'number'
'description': >
The number of milliseconds elapsed from the start of a day. It
must be less than `end` and is expected to be rounded to minutes.
So the maximum value is `86340000` (23 hours and 59 minutes).
'minimum': 0
'maximum': 86340000
'end':
'type': 'number'
'description': >
The number of milliseconds elapsed from the start of a day. It is
expected to be rounded to minutes. The maximum value is `86400000`
(24 hours).
'minimum': 0
'maximum': 86400000
'Client': 'Client':
'type': 'object' 'type': 'object'
'description': 'Client information.' 'description': 'Client information.'
@ -2513,6 +2595,8 @@
'$ref': '#/components/schemas/SafeSearchConfig' '$ref': '#/components/schemas/SafeSearchConfig'
'use_global_blocked_services': 'use_global_blocked_services':
'type': 'boolean' 'type': 'boolean'
'blocked_services_schedule':
'$ref': '#/components/schemas/Schedule'
'blocked_services': 'blocked_services':
'type': 'array' 'type': 'array'
'items': 'items':
@ -2793,6 +2877,17 @@
- 'name' - 'name'
- 'rules' - 'rules'
'type': 'object' 'type': 'object'
'BlockedServicesSchedule':
'type': 'object'
'properties':
'schedule':
'$ref': '#/components/schemas/Schedule'
'ids':
'description': >
The names of the blocked services.
'type': 'array'
'items':
'type': 'string'
'CheckConfigRequest': 'CheckConfigRequest':
'type': 'object' 'type': 'object'
'description': 'Configuration to be checked' 'description': 'Configuration to be checked'