diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 0098e76a..e7dd1e89 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -81,6 +81,19 @@ } ], "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 + } + ] } } diff --git a/client/dev.eslintrc b/client/dev.eslintrc index 27341caf..9d7e5493 100644 --- a/client/dev.eslintrc +++ b/client/dev.eslintrc @@ -1,6 +1,6 @@ { "extends": ".eslintrc", "rules": { - "no-debugger":"warn", + "no-debugger":"warn" } } diff --git a/client/package-lock.json b/client/package-lock.json index ba1d3772..cc6c55ea 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15094,6 +15094,11 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index 22b83010..134b98b5 100644 --- a/client/package.json +++ b/client/package.json @@ -43,6 +43,7 @@ "redux-form": "^8.3.5", "redux-thunk": "^2.3.0", "string-length": "^5.0.1", + "timezones-list": "^3.0.2", "url-polyfill": "^1.1.9" }, "devDependencies": { diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 85313bb3..b8e72b3f 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -680,5 +680,37 @@ "protection_section_label": "Protection", "log_and_stats_section_label": "Query log and statistics", "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" } diff --git a/client/src/actions/services.js b/client/src/actions/services.js index 2f5f20db..28ae8837 100644 --- a/client/src/actions/services.js +++ b/client/src/actions/services.js @@ -32,19 +32,19 @@ export const getAllBlockedServices = () => async (dispatch) => { } }; -export const setBlockedServicesRequest = createAction('SET_BLOCKED_SERVICES_REQUEST'); -export const setBlockedServicesFailure = createAction('SET_BLOCKED_SERVICES_FAILURE'); -export const setBlockedServicesSuccess = createAction('SET_BLOCKED_SERVICES_SUCCESS'); +export const updateBlockedServicesRequest = createAction('UPDATE_BLOCKED_SERVICES_REQUEST'); +export const updateBlockedServicesFailure = createAction('UPDATE_BLOCKED_SERVICES_FAILURE'); +export const updateBlockedServicesSuccess = createAction('UPDATE_BLOCKED_SERVICES_SUCCESS'); -export const setBlockedServices = (values) => async (dispatch) => { - dispatch(setBlockedServicesRequest()); +export const updateBlockedServices = (values) => async (dispatch) => { + dispatch(updateBlockedServicesRequest()); try { - await apiClient.setBlockedServices(values); - dispatch(setBlockedServicesSuccess()); + await apiClient.updateBlockedServices(values); + dispatch(updateBlockedServicesSuccess()); dispatch(getBlockedServices()); dispatch(addSuccessToast('blocked_services_saved')); } catch (error) { dispatch(addErrorToast({ error })); - dispatch(setBlockedServicesFailure()); + dispatch(updateBlockedServicesFailure()); } }; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index a01c9d04..077c794e 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -489,9 +489,9 @@ class Api { } // 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' }; @@ -501,12 +501,12 @@ class Api { } getBlockedServices() { - const { path, method } = this.BLOCKED_SERVICES_LIST; + const { path, method } = this.BLOCKED_SERVICES_GET; return this.makeRequest(path, method); } - setBlockedServices(config) { - const { path, method } = this.BLOCKED_SERVICES_SET; + updateBlockedServices(config) { + const { path, method } = this.BLOCKED_SERVICES_UPDATE; const parameters = { data: config, }; diff --git a/client/src/components/Filters/Services/ScheduleForm/Modal.js b/client/src/components/Filters/Services/ScheduleForm/Modal.js new file mode 100644 index 00000000..429db9be --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/Modal.js @@ -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 ( + +
+
+

+ {currentDay ? t('schedule_edit') : t('schedule_new')} +

+ +
+
+
+ + +
+ {DAYS_OF_WEEK.map((day) => ( + + )) } +
+ +
+
+ setStartTime(v)} + /> + + setEndTime(v)} + /> +
+ + {wrongPeriod && ( +
+ {t('schedule_invalid_select')} +
+ )} +
+ +
+
+ {t('schedule_modal_time_off')} +
+
+ + + + {days.size ? ( + Array.from(days).map((day) => getFullDayName(t, day)).join(', ') + ) : ( + + — + + )} +
+
+ + + + {wrongPeriod ? ( + + — + + ) : ( + + )} +
+
+ +
+ {t('schedule_modal_description')} +
+
+
+
+ +
+
+
+
+
+ ); +}; + +Modal.propTypes = { + schedule: PropTypes.object.isRequired, + currentDay: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/TimePeriod.js b/client/src/components/Filters/Services/ScheduleForm/TimePeriod.js new file mode 100644 index 00000000..69ef2293 --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/TimePeriod.js @@ -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 ( +
+ +  –  + +
+ ); +}; + +TimePeriod.propTypes = { + startTimeMs: PropTypes.number.isRequired, + endTimeMs: PropTypes.number.isRequired, +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/TimeSelect.js b/client/src/components/Filters/Services/ScheduleForm/TimeSelect.js new file mode 100644 index 00000000..35998437 --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/TimeSelect.js @@ -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 ( +
+ +  :  + +
+ ); +}; + +TimeSelect.propTypes = { + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/Timezone.js b/client/src/components/Filters/Services/ScheduleForm/Timezone.js new file mode 100644 index 00000000..71d017d6 --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/Timezone.js @@ -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 ( +
+ + + +
+ ); +}; + +Timezone.propTypes = { + timezone: PropTypes.string.isRequired, + setTimezone: PropTypes.func.isRequired, +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/helpers.js b/client/src/components/Filters/Services/ScheduleForm/helpers.js new file mode 100644 index 00000000..c3986ed4 --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/helpers.js @@ -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(); +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/index.js b/client/src/components/Filters/Services/ScheduleForm/index.js new file mode 100644 index 00000000..f7bf605b --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/index.js @@ -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 ( +
+
+ {t('schedule_current_timezone', { value: schedule?.time_zone || LOCAL_TIMEZONE_VALUE })} +
+ +
+ {filteredScheduleKeys.map((day) => { + const data = schedule[day]; + + if (!data) { + return undefined; + } + + return ( +
+
+ {getFullDayName(t, day)} +
+
+ {getShortDayName(t, day)} +
+ +
+ + + +
+
+ ); + })} +
+ + + + {modalOpen && ( + + )} +
+ ); +}; + +ScheduleForm.propTypes = { + schedule: PropTypes.object, + onScheduleSubmit: PropTypes.func.isRequired, + clientForm: PropTypes.bool, +}; diff --git a/client/src/components/Filters/Services/ScheduleForm/styles.css b/client/src/components/Filters/Services/ScheduleForm/styles.css new file mode 100644 index 00000000..f839f92a --- /dev/null +++ b/client/src/components/Filters/Services/ScheduleForm/styles.css @@ -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; +} diff --git a/client/src/components/Filters/Services/index.js b/client/src/components/Filters/Services/index.js index 09cdd7c8..3a0a12fd 100644 --- a/client/src/components/Filters/Services/index.js +++ b/client/src/components/Filters/Services/index.js @@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import Form from './Form'; 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 { ScheduleForm } from './ScheduleForm'; + const getInitialDataForServices = (initial) => (initial ? initial.reduce( (acc, service) => { acc.blocked_services[service] = true; @@ -33,10 +35,24 @@ const Services = () => { .keys(values.blocked_services) .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 ( <> @@ -57,6 +73,17 @@ const Services = () => { /> + + + + ); }; diff --git a/client/src/components/Settings/Clients/ClientsTable/ClientsTable.js b/client/src/components/Settings/Clients/ClientsTable/ClientsTable.js index dab8994c..96f835c3 100644 --- a/client/src/components/Settings/Clients/ClientsTable/ClientsTable.js +++ b/client/src/components/Settings/Clients/ClientsTable/ClientsTable.js @@ -6,7 +6,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import ReactTable from 'react-table'; -import { getAllBlockedServices } from '../../../../actions/services'; +import { getAllBlockedServices, getBlockedServices } from '../../../../actions/services'; import { initSettings } from '../../../../actions'; import { splitByNewLine, @@ -14,7 +14,7 @@ import { sortIp, getService, } from '../../../../helpers/helpers'; -import { MODAL_TYPE } from '../../../../helpers/constants'; +import { MODAL_TYPE, LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants'; import Card from '../../../ui/Card'; import CellWrap from '../../../ui/CellWrap'; import LogsSearchLink from '../../../ui/LogsSearchLink'; @@ -45,6 +45,7 @@ const ClientsTable = ({ useEffect(() => { dispatch(getAllBlockedServices()); + dispatch(getBlockedServices()); dispatch(initSettings()); }, []); @@ -112,6 +113,9 @@ const ClientsTable = ({ tags: [], use_global_settings: true, use_global_blocked_services: true, + blocked_services_schedule: { + time_zone: LOCAL_TIMEZONE_VALUE, + }, safe_search: { ...(safesearch || {}) }, }; }; diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js index 190996e4..652957d0 100644 --- a/client/src/components/Settings/Clients/Form.js +++ b/client/src/components/Settings/Clients/Form.js @@ -11,6 +11,7 @@ import Select from 'react-select'; import i18n from '../../../i18n'; import Tabs from '../../ui/Tabs'; import Examples from '../Dns/Upstream/Examples'; +import { ScheduleForm } from '../../Filters/Services/ScheduleForm'; import { toggleAllServices, trimLinesAndRemoveEmpty, captitalizeWords } from '../../../helpers/helpers'; import { renderInputField, @@ -137,10 +138,10 @@ let Form = (props) => { handleSubmit, reset, change, - pristine, submitting, useGlobalSettings, useGlobalServices, + blockedServicesSchedule, toggleClientModal, processingAdding, processingUpdating, @@ -155,6 +156,10 @@ let Form = (props) => { const [activeTabLabel, setActiveTabLabel] = useState('settings'); + const handleScheduleSubmit = (values) => { + change('blocked_services_schedule', values); + }; + const tabs = { settings: { title: 'settings', @@ -269,6 +274,21 @@ let Form = (props) => { , }, + schedule_services: { + title: 'schedule_services', + component: ( + <> +
+ schedule_services_desc_client +
+ + + ), + }, upstream_dns: { title: 'upstream_dns', component:
@@ -355,8 +375,12 @@ let Form = (props) => {
- + {activeTab} @@ -380,7 +404,6 @@ let Form = (props) => { disabled={ submitting || invalid - || pristine || processingAdding || processingUpdating } @@ -402,6 +425,7 @@ Form.propTypes = { toggleClientModal: PropTypes.func.isRequired, useGlobalSettings: PropTypes.bool, useGlobalServices: PropTypes.bool, + blockedServicesSchedule: PropTypes.object, t: PropTypes.func.isRequired, processingAdding: PropTypes.bool.isRequired, processingUpdating: PropTypes.bool.isRequired, @@ -415,9 +439,11 @@ const selector = formValueSelector(FORM_NAME.CLIENT); Form = connect((state) => { const useGlobalSettings = selector(state, 'use_global_settings'); const useGlobalServices = selector(state, 'use_global_blocked_services'); + const blockedServicesSchedule = selector(state, 'blocked_services_schedule'); return { useGlobalSettings, useGlobalServices, + blockedServicesSchedule, }; })(Form); diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js index b38da489..288eadfc 100644 --- a/client/src/components/ui/Icons.js +++ b/client/src/components/ui/Icons.js @@ -225,6 +225,20 @@ const Icons = () => ( + + + + + + + + + + + + + + ); diff --git a/client/src/components/ui/Modal.css b/client/src/components/ui/Modal.css index b22b9709..13adb794 100644 --- a/client/src/components/ui/Modal.css +++ b/client/src/components/ui/Modal.css @@ -41,7 +41,8 @@ } @media (min-width: 576px) { - .modal-dialog--clients { + .modal-dialog--clients, + .modal-dialog--schedule { max-width: 650px; } } diff --git a/client/src/components/ui/Tabler.css b/client/src/components/ui/Tabler.css index 69b0e1c4..d49810e2 100644 --- a/client/src/components/ui/Tabler.css +++ b/client/src/components/ui/Tabler.css @@ -470,6 +470,10 @@ hr { border-top: 1px solid rgba(0, 40, 100, 0.12); } +[data-theme=dark] hr { + border-color: var(--card-border-color); +} + small, .small { font-size: 87.5%; diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index 866765de..6222f8eb 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; 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 { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs'; import { getFilteringStatus, setFiltersConfig } from '../actions/filtering'; @@ -24,7 +24,7 @@ const mapDispatchToProps = { initSettings, toggleSetting, getBlockedServices, - setBlockedServices, + updateBlockedServices, getStatsConfig, setStatsConfig, resetStats, diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index ec1e437e..e436d77f 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -552,3 +552,5 @@ export const DISABLE_PROTECTION_TIMINGS = { }; export const LOCAL_STORAGE_THEME_KEY = 'account_theme'; + +export const LOCAL_TIMEZONE_VALUE = 'Local'; diff --git a/client/src/reducers/services.js b/client/src/reducers/services.js index c0663c8e..07b9947c 100644 --- a/client/src/reducers/services.js +++ b/client/src/reducers/services.js @@ -20,9 +20,9 @@ const services = handleActions( processingAll: false, }), - [actions.setBlockedServicesRequest]: (state) => ({ ...state, processingSet: true }), - [actions.setBlockedServicesFailure]: (state) => ({ ...state, processingSet: false }), - [actions.setBlockedServicesSuccess]: (state) => ({ + [actions.updateBlockedServicesRequest]: (state) => ({ ...state, processingSet: true }), + [actions.updateBlockedServicesFailure]: (state) => ({ ...state, processingSet: false }), + [actions.updateBlockedServicesSuccess]: (state) => ({ ...state, processingSet: false, }), @@ -31,7 +31,7 @@ const services = handleActions( processing: true, processingAll: true, processingSet: false, - list: [], + list: {}, allServices: [], }, ); diff --git a/internal/aghhttp/aghhttp.go b/internal/aghhttp/aghhttp.go index 6cb2c670..88dfe7a7 100644 --- a/internal/aghhttp/aghhttp.go +++ b/internal/aghhttp/aghhttp.go @@ -2,7 +2,6 @@ package aghhttp import ( - "encoding/json" "fmt" "io" "net/http" @@ -61,23 +60,3 @@ func WriteTextPlainDeprecated(w http.ResponseWriter, r *http.Request) (isPlainTe 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 -} diff --git a/internal/next/websvc/json.go b/internal/aghhttp/json.go similarity index 75% rename from internal/next/websvc/json.go rename to internal/aghhttp/json.go index f7622b63..b7eca767 100644 --- a/internal/next/websvc/json.go +++ b/internal/aghhttp/json.go @@ -1,4 +1,4 @@ -package websvc +package aghhttp import ( "encoding/json" @@ -7,7 +7,6 @@ import ( "strconv" "time" - "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/golibs/httphdr" "github.com/AdguardTeam/golibs/log" ) @@ -87,30 +86,29 @@ func (t *JSONTime) UnmarshalJSON(b []byte) (err error) { return nil } -// writeJSONOKResponse 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 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 +// WriteJSONResponse writes headers with the code, encodes resp into w, and logs +// any errors it encounters. r is used to get additional information from the // request. -func writeJSONResponse(w http.ResponseWriter, r *http.Request, v any, code int) { - // TODO(a.garipov): Put some of these to a middleware. +func WriteJSONResponse(w http.ResponseWriter, r *http.Request, code int, resp any) { h := w.Header() - h.Set(httphdr.ContentType, aghhttp.HdrValApplicationJSON) - h.Set(httphdr.Server, aghhttp.UserAgent()) + h.Set(httphdr.ContentType, HdrValApplicationJSON) + h.Set(httphdr.Server, UserAgent()) w.WriteHeader(code) - err := json.NewEncoder(w).Encode(v) + err := json.NewEncoder(w).Encode(resp) 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 // definition in the OpenAPI specification. type ErrorCode string @@ -131,14 +129,14 @@ type HTTPAPIErrorResp struct { 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 // request. -func writeJSONErrorResponse(w http.ResponseWriter, r *http.Request, err error) { - log.Error("websvc: %s %s: %s", r.Method, r.URL.Path, err) +func WriteJSONResponseError(w http.ResponseWriter, r *http.Request, err error) { + 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, Msg: err.Error(), - }, http.StatusUnprocessableEntity) + }) } diff --git a/internal/next/websvc/json_test.go b/internal/aghhttp/json_test.go similarity index 81% rename from internal/next/websvc/json_test.go rename to internal/aghhttp/json_test.go index 90874958..9a2d33f1 100644 --- a/internal/next/websvc/json_test.go +++ b/internal/aghhttp/json_test.go @@ -1,18 +1,18 @@ -package websvc_test +package aghhttp_test import ( "encoding/json" "testing" "time" - "github.com/AdguardTeam/AdGuardHome/internal/next/websvc" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // 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. const testJSONTimeStr = "1234567890123.456" @@ -21,17 +21,17 @@ func TestJSONTime_MarshalJSON(t *testing.T) { testCases := []struct { name string wantErrMsg string - in websvc.JSONTime + in aghhttp.JSONTime want []byte }{{ name: "unix_zero", wantErrMsg: "", - in: websvc.JSONTime(time.Unix(0, 0)), + in: aghhttp.JSONTime(time.Unix(0, 0)), want: []byte("0"), }, { name: "empty", wantErrMsg: "", - in: websvc.JSONTime{}, + in: aghhttp.JSONTime{}, want: []byte("-6795364578871.345"), }, { name: "time", @@ -51,7 +51,7 @@ func TestJSONTime_MarshalJSON(t *testing.T) { t.Run("json", func(t *testing.T) { in := &struct { - A websvc.JSONTime + A aghhttp.JSONTime }{ A: testJSONTime, } @@ -67,7 +67,7 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) { testCases := []struct { name string wantErrMsg string - want websvc.JSONTime + want aghhttp.JSONTime data []byte }{{ name: "time", @@ -78,13 +78,13 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) { name: "bad", wantErrMsg: `parsing json time: strconv.ParseFloat: parsing "{}": ` + `invalid syntax`, - want: websvc.JSONTime{}, + want: aghhttp.JSONTime{}, data: []byte(`{}`), }} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var got websvc.JSONTime + var got aghhttp.JSONTime err := got.UnmarshalJSON(tc.data) testutil.AssertErrorMsg(t, tc.wantErrMsg, err) @@ -93,7 +93,7 @@ func TestJSONTime_UnmarshalJSON(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) msg := err.Error() @@ -103,7 +103,7 @@ func TestJSONTime_UnmarshalJSON(t *testing.T) { t.Run("json", func(t *testing.T) { want := testJSONTime var got struct { - A websvc.JSONTime + A aghhttp.JSONTime } err := json.Unmarshal([]byte(`{"A":`+testJSONTimeStr+`}`), &got) diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go index b07f9543..2ba693cc 100644 --- a/internal/dhcpd/http_unix.go +++ b/internal/dhcpd/http_unix.go @@ -146,7 +146,7 @@ func (s *server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) { status.Leases = leasesToDynamic(s.Leases(LeasesDynamic)) 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) { @@ -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. @@ -547,7 +547,7 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque 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 diff --git a/internal/dhcpd/http_windows.go b/internal/dhcpd/http_windows.go index fda72d48..eb82b861 100644 --- a/internal/dhcpd/http_windows.go +++ b/internal/dhcpd/http_windows.go @@ -24,7 +24,7 @@ type jsonError struct { // TODO(a.garipov): Either take the logger from the server after we've // refactored logging or make this not a method of *Server. 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(), }) } diff --git a/internal/dnsforward/access.go b/internal/dnsforward/access.go index d29afbde..ba9300c2 100644 --- a/internal/dnsforward/access.go +++ b/internal/dnsforward/access.go @@ -182,7 +182,7 @@ func (s *Server) accessListJSON() (j accessListJSON) { } 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 diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go index 184e5e7c..3be25372 100644 --- a/internal/dnsforward/http.go +++ b/internal/dnsforward/http.go @@ -169,7 +169,7 @@ func (s *Server) getDNSConfig() (c *jsonDNSConfig) { // handleGetConfig handles requests to the GET /control/dns_info endpoint. func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { resp := s.getDNSConfig() - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } 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. diff --git a/internal/filtering/blocked.go b/internal/filtering/blocked.go index f403d0ab..2dba12da 100644 --- a/internal/filtering/blocked.go +++ b/internal/filtering/blocked.go @@ -50,10 +50,10 @@ func initBlockedServices() { // BlockedServices is the configuration of blocked services. type BlockedServices struct { // 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 []string `yaml:"ids"` + IDs []string `json:"ids" yaml:"ids"` } // 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) { - _ = aghhttp.WriteJSONResponse(w, r, serviceIDs) + aghhttp.WriteJSONResponseOK(w, r, serviceIDs) } 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: 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) { d.confLock.RLock() list := d.Config.BlockedServices.IDs 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) { list := []string{} err := json.NewDecoder(r.Body).Decode(&list) @@ -150,3 +158,51 @@ func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Requ 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() +} diff --git a/internal/filtering/http.go b/internal/filtering/http.go index 43191ca6..df3fe95e 100644 --- a/internal/filtering/http.go +++ b/internal/filtering/http.go @@ -301,7 +301,7 @@ func (d *DNSFilter) handleFilteringRefresh(w http.ResponseWriter, r *http.Reques return } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } type filterJSON struct { @@ -354,7 +354,7 @@ func (d *DNSFilter) handleFilteringStatus(w http.ResponseWriter, r *http.Request resp.UserRules = d.UserRules d.filtersMu.RUnlock() - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // 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 @@ -504,7 +504,7 @@ func (d *DNSFilter) handleSafeBrowsingStatus(w http.ResponseWriter, r *http.Requ 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 @@ -530,7 +530,7 @@ func (d *DNSFilter) handleParentalStatus(w http.ResponseWriter, r *http.Request) Enabled: protectedBool(&d.confLock, &d.Config.ParentalEnabled), } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // 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/all", d.handleBlockedServicesAll) + + // Deprecated handlers. registerHTTP(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList) 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.MethodPost, "/control/filtering/config", d.handleFilteringConfig) registerHTTP(http.MethodPost, "/control/filtering/add_url", d.handleFilteringAddURL) diff --git a/internal/filtering/rewritehttp.go b/internal/filtering/rewritehttp.go index fea5a650..b3dc275a 100644 --- a/internal/filtering/rewritehttp.go +++ b/internal/filtering/rewritehttp.go @@ -28,7 +28,7 @@ func (d *DNSFilter) handleRewriteList(w http.ResponseWriter, r *http.Request) { } d.confLock.Unlock() - _ = aghhttp.WriteJSONResponse(w, r, arr) + aghhttp.WriteJSONResponseOK(w, r, arr) } func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) { diff --git a/internal/filtering/safesearchhttp.go b/internal/filtering/safesearchhttp.go index 6048cfea..d1358a2f 100644 --- a/internal/filtering/safesearchhttp.go +++ b/internal/filtering/safesearchhttp.go @@ -36,7 +36,7 @@ func (d *DNSFilter) handleSafeSearchStatus(w http.ResponseWriter, r *http.Reques resp = d.Config.SafeSearchConf }() - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // handleSafeSearchSettings is the handler for PUT /control/safesearch/settings diff --git a/internal/home/clientshttp.go b/internal/home/clientshttp.go index 38ea666d..240ef4de 100644 --- a/internal/home/clientshttp.go +++ b/internal/home/clientshttp.go @@ -34,8 +34,12 @@ type clientJSON struct { WHOIS *whois.Info `json:"whois_info,omitempty"` 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"` + // BlockedServices is the names of blocked services. BlockedServices []string `json:"blocked_services"` IDs []string `json:"ids"` Tags []string `json:"tags"` @@ -53,6 +57,34 @@ type clientJSON struct { 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 { WHOIS *whois.Info `json:"whois_info"` @@ -93,7 +125,7 @@ func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http data.Tags = clientTags - _ = aghhttp.WriteJSONResponse(w, r, data) + aghhttp.WriteJSONResponseOK(w, r, data) } // jsonToClient converts JSON object to Client object. @@ -119,9 +151,15 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C } } - weekly := schedule.EmptyWeekly() - if prev != nil { - weekly = prev.BlockedServices.Schedule.Clone() + weekly, ignoreQueryLog, ignoreStatistics := cj.copySettings(prev) + + 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{ @@ -129,10 +167,7 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C Name: cj.Name, - BlockedServices: &filtering.BlockedServices{ - Schedule: weekly, - IDs: cj.BlockedServices, - }, + BlockedServices: bs, IDs: cj.IDs, Tags: cj.Tags, @@ -143,18 +178,8 @@ func (clients *clientsContainer) jsonToClient(cj clientJSON, prev *Client) (c *C ParentalEnabled: cj.ParentalEnabled, SafeBrowsingEnabled: cj.SafeBrowsingEnabled, UseOwnBlockedServices: !cj.UseGlobalBlockedServices, - } - - 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 + IgnoreQueryLog: ignoreQueryLog, + IgnoreStatistics: ignoreStatistics, } if safeSearchConf.Enabled { @@ -191,6 +216,7 @@ func clientToJSON(c *Client) (cj *clientJSON) { UseGlobalBlockedServices: !c.UseOwnBlockedServices, + Schedule: c.BlockedServices.Schedule, BlockedServices: c.BlockedServices.IDs, 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 diff --git a/internal/home/control.go b/internal/home/control.go index a2414b35..f7286aed 100644 --- a/internal/home/control.go +++ b/internal/home/control.go @@ -170,7 +170,7 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { resp.IsDHCPAvailable = Context.dhcpServer != nil } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // ------------------------ diff --git a/internal/home/controlinstall.go b/internal/home/controlinstall.go index a5be3354..d6985eab 100644 --- a/internal/home/controlinstall.go +++ b/internal/home/controlinstall.go @@ -59,7 +59,7 @@ func (web *webAPI) handleInstallGetAddresses(w http.ResponseWriter, r *http.Requ data.Interfaces[iface.Name] = iface } - _ = aghhttp.WriteJSONResponse(w, r, data) + aghhttp.WriteJSONResponseOK(w, r, data) } 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) } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // handleStaticIP - handles static IP request diff --git a/internal/home/controlupdate.go b/internal/home/controlupdate.go index 5238134c..50a1a6f3 100644 --- a/internal/home/controlupdate.go +++ b/internal/home/controlupdate.go @@ -33,7 +33,7 @@ func (web *webAPI) handleVersionJSON(w http.ResponseWriter, r *http.Request) { resp := &versionResponse{} if web.conf.disableUpdate { resp.Disabled = true - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) return } @@ -68,7 +68,7 @@ func (web *webAPI) handleVersionJSON(w http.ResponseWriter, r *http.Request) { return } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // requestVersionInfo sets the VersionInfo field of resp if it can reach the diff --git a/internal/home/i18n.go b/internal/home/i18n.go index d9e3435c..267205d3 100644 --- a/internal/home/i18n.go +++ b/internal/home/i18n.go @@ -58,7 +58,7 @@ type languageJSON struct { func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) { log.Printf("home: language is %s", config.Language) - _ = aghhttp.WriteJSONResponse(w, r, &languageJSON{ + aghhttp.WriteJSONResponseOK(w, r, &languageJSON{ Language: config.Language, }) } diff --git a/internal/home/profilehttp.go b/internal/home/profilehttp.go index 12e036c3..91e36f28 100644 --- a/internal/home/profilehttp.go +++ b/internal/home/profilehttp.go @@ -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. diff --git a/internal/home/tls.go b/internal/home/tls.go index c42d5175..004e9412 100644 --- a/internal/home/tls.go +++ b/internal/home/tls.go @@ -770,7 +770,7 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) { data.PrivateKey = "" } - _ = aghhttp.WriteJSONResponse(w, r, data) + aghhttp.WriteJSONResponseOK(w, r, data) } // registerWebHandlers registers HTTP handlers for TLS configuration. diff --git a/internal/next/websvc/dns.go b/internal/next/websvc/dns.go index 34cf9a15..39f05d22 100644 --- a/internal/next/websvc/dns.go +++ b/internal/next/websvc/dns.go @@ -7,6 +7,7 @@ import ( "net/netip" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" ) @@ -17,13 +18,13 @@ import ( type ReqPatchSettingsDNS struct { // TODO(a.garipov): Add more as we go. - Addresses []netip.AddrPort `json:"addresses"` - BootstrapServers []string `json:"bootstrap_servers"` - UpstreamServers []string `json:"upstream_servers"` - DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` - UpstreamTimeout JSONDuration `json:"upstream_timeout"` - BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` - UseDNS64 bool `json:"use_dns64"` + Addresses []netip.AddrPort `json:"addresses"` + BootstrapServers []string `json:"bootstrap_servers"` + UpstreamServers []string `json:"upstream_servers"` + DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` + UpstreamTimeout aghhttp.JSONDuration `json:"upstream_timeout"` + BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` + UseDNS64 bool `json:"use_dns64"` } // HTTPAPIDNSSettings are the DNS settings as used by the HTTP API. See the @@ -31,13 +32,13 @@ type ReqPatchSettingsDNS struct { type HTTPAPIDNSSettings struct { // TODO(a.garipov): Add more as we go. - Addresses []netip.AddrPort `json:"addresses"` - BootstrapServers []string `json:"bootstrap_servers"` - UpstreamServers []string `json:"upstream_servers"` - DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` - UpstreamTimeout JSONDuration `json:"upstream_timeout"` - BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` - UseDNS64 bool `json:"use_dns64"` + Addresses []netip.AddrPort `json:"addresses"` + BootstrapServers []string `json:"bootstrap_servers"` + UpstreamServers []string `json:"upstream_servers"` + DNS64Prefixes []netip.Prefix `json:"dns64_prefixes"` + UpstreamTimeout aghhttp.JSONDuration `json:"upstream_timeout"` + BootstrapPreferIPv6 bool `json:"bootstrap_prefer_ipv6"` + UseDNS64 bool `json:"use_dns64"` } // 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) if err != nil { - writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err)) + aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("decoding: %w", err)) return } @@ -71,7 +72,7 @@ func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Reques ctx := r.Context() err = svc.confMgr.UpdateDNS(ctx, newConf) if err != nil { - writeJSONErrorResponse(w, r, fmt.Errorf("updating: %w", err)) + aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("updating: %w", err)) return } @@ -79,17 +80,17 @@ func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Reques newSvc := svc.confMgr.DNS() err = newSvc.Start() 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 } - writeJSONOKResponse(w, r, &HTTPAPIDNSSettings{ + aghhttp.WriteJSONResponseOK(w, r, &HTTPAPIDNSSettings{ Addresses: newConf.Addresses, BootstrapServers: newConf.BootstrapServers, UpstreamServers: newConf.UpstreamServers, DNS64Prefixes: newConf.DNS64Prefixes, - UpstreamTimeout: JSONDuration(newConf.UpstreamTimeout), + UpstreamTimeout: aghhttp.JSONDuration(newConf.UpstreamTimeout), BootstrapPreferIPv6: newConf.BootstrapPreferIPv6, UseDNS64: newConf.UseDNS64, }) diff --git a/internal/next/websvc/dns_test.go b/internal/next/websvc/dns_test.go index c39ba1ab..5f6e3d44 100644 --- a/internal/next/websvc/dns_test.go +++ b/internal/next/websvc/dns_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" @@ -24,7 +25,7 @@ func TestService_HandlePatchSettingsDNS(t *testing.T) { BootstrapServers: []string{"1.0.0.1"}, UpstreamServers: []string{"1.1.1.1"}, DNS64Prefixes: []netip.Prefix{netip.MustParsePrefix("1234::/64")}, - UpstreamTimeout: websvc.JSONDuration(2 * time.Second), + UpstreamTimeout: aghhttp.JSONDuration(2 * time.Second), BootstrapPreferIPv6: true, UseDNS64: true, } diff --git a/internal/next/websvc/http.go b/internal/next/websvc/http.go index 7e4785cd..db32372d 100644 --- a/internal/next/websvc/http.go +++ b/internal/next/websvc/http.go @@ -8,6 +8,7 @@ import ( "net/netip" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/golibs/log" ) @@ -21,9 +22,9 @@ type ReqPatchSettingsHTTP struct { // // TODO(a.garipov): Add wait time. - Addresses []netip.AddrPort `json:"addresses"` - SecureAddresses []netip.AddrPort `json:"secure_addresses"` - Timeout JSONDuration `json:"timeout"` + Addresses []netip.AddrPort `json:"addresses"` + SecureAddresses []netip.AddrPort `json:"secure_addresses"` + Timeout aghhttp.JSONDuration `json:"timeout"` } // HTTPAPIHTTPSettings are the HTTP settings as used by the HTTP API. See the @@ -31,10 +32,10 @@ type ReqPatchSettingsHTTP struct { type HTTPAPIHTTPSettings struct { // TODO(a.garipov): Add more as we go. - Addresses []netip.AddrPort `json:"addresses"` - SecureAddresses []netip.AddrPort `json:"secure_addresses"` - Timeout JSONDuration `json:"timeout"` - ForceHTTPS bool `json:"force_https"` + Addresses []netip.AddrPort `json:"addresses"` + SecureAddresses []netip.AddrPort `json:"secure_addresses"` + Timeout aghhttp.JSONDuration `json:"timeout"` + ForceHTTPS bool `json:"force_https"` } // 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) if err != nil { - writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err)) + aghhttp.WriteJSONResponseError(w, r, fmt.Errorf("decoding: %w", err)) return } @@ -65,10 +66,10 @@ func (svc *Service) handlePatchSettingsHTTP(w http.ResponseWriter, r *http.Reque ForceHTTPS: svc.forceHTTPS, } - writeJSONOKResponse(w, r, &HTTPAPIHTTPSettings{ + aghhttp.WriteJSONResponseOK(w, r, &HTTPAPIHTTPSettings{ Addresses: newConf.Addresses, SecureAddresses: newConf.SecureAddresses, - Timeout: JSONDuration(newConf.Timeout), + Timeout: aghhttp.JSONDuration(newConf.Timeout), ForceHTTPS: newConf.ForceHTTPS, }) diff --git a/internal/next/websvc/http_test.go b/internal/next/websvc/http_test.go index c04921f6..3168ec03 100644 --- a/internal/next/websvc/http_test.go +++ b/internal/next/websvc/http_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/websvc" "github.com/stretchr/testify/assert" @@ -20,7 +21,7 @@ func TestService_HandlePatchSettingsHTTP(t *testing.T) { wantWeb := &websvc.HTTPAPIHTTPSettings{ Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:80")}, SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:443")}, - Timeout: websvc.JSONDuration(10 * time.Second), + Timeout: aghhttp.JSONDuration(10 * time.Second), ForceHTTPS: false, } diff --git a/internal/next/websvc/settings.go b/internal/next/websvc/settings.go index ab308836..44364ca3 100644 --- a/internal/next/websvc/settings.go +++ b/internal/next/websvc/settings.go @@ -2,6 +2,8 @@ package websvc import ( "net/http" + + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" ) // All Settings Handlers @@ -25,20 +27,20 @@ func (svc *Service) handleGetSettingsAll(w http.ResponseWriter, r *http.Request) httpConf := webSvc.Config() // TODO(a.garipov): Add all currently supported parameters. - writeJSONOKResponse(w, r, &RespGetV1SettingsAll{ + aghhttp.WriteJSONResponseOK(w, r, &RespGetV1SettingsAll{ DNS: &HTTPAPIDNSSettings{ Addresses: dnsConf.Addresses, BootstrapServers: dnsConf.BootstrapServers, UpstreamServers: dnsConf.UpstreamServers, DNS64Prefixes: dnsConf.DNS64Prefixes, - UpstreamTimeout: JSONDuration(dnsConf.UpstreamTimeout), + UpstreamTimeout: aghhttp.JSONDuration(dnsConf.UpstreamTimeout), BootstrapPreferIPv6: dnsConf.BootstrapPreferIPv6, UseDNS64: dnsConf.UseDNS64, }, HTTP: &HTTPAPIHTTPSettings{ Addresses: httpConf.Addresses, SecureAddresses: httpConf.SecureAddresses, - Timeout: JSONDuration(httpConf.Timeout), + Timeout: aghhttp.JSONDuration(httpConf.Timeout), ForceHTTPS: httpConf.ForceHTTPS, }, }) diff --git a/internal/next/websvc/settings_test.go b/internal/next/websvc/settings_test.go index cacf28a9..f07d8b3e 100644 --- a/internal/next/websvc/settings_test.go +++ b/internal/next/websvc/settings_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/next/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc" "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")}, BootstrapServers: []string{"94.140.14.140", "94.140.14.141"}, UpstreamServers: []string{"94.140.14.14", "1.1.1.1"}, - UpstreamTimeout: websvc.JSONDuration(1 * time.Second), + UpstreamTimeout: aghhttp.JSONDuration(1 * time.Second), BootstrapPreferIPv6: true, } wantWeb := &websvc.HTTPAPIHTTPSettings{ Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:80")}, SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:443")}, - Timeout: websvc.JSONDuration(5 * time.Second), + Timeout: aghhttp.JSONDuration(5 * time.Second), ForceHTTPS: true, } diff --git a/internal/next/websvc/system.go b/internal/next/websvc/system.go index fbf60fe4..d4ca34ad 100644 --- a/internal/next/websvc/system.go +++ b/internal/next/websvc/system.go @@ -4,6 +4,7 @@ import ( "net/http" "runtime" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/version" ) @@ -12,24 +13,24 @@ import ( // RespGetV1SystemInfo describes the response of the GET /api/v1/system/info // HTTP API. type RespGetV1SystemInfo struct { - Arch string `json:"arch"` - Channel string `json:"channel"` - OS string `json:"os"` - NewVersion string `json:"new_version,omitempty"` - Start JSONTime `json:"start"` - Version string `json:"version"` + Arch string `json:"arch"` + Channel string `json:"channel"` + OS string `json:"os"` + NewVersion string `json:"new_version,omitempty"` + Start aghhttp.JSONTime `json:"start"` + Version string `json:"version"` } // handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP // API. func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) { - writeJSONOKResponse(w, r, &RespGetV1SystemInfo{ + aghhttp.WriteJSONResponseOK(w, r, &RespGetV1SystemInfo{ Arch: runtime.GOARCH, Channel: version.Channel(), OS: runtime.GOOS, // TODO(a.garipov): Fill this when we have an updater. NewVersion: "", - Start: JSONTime(svc.start), + Start: aghhttp.JSONTime(svc.start), Version: version.Version(), }) } diff --git a/internal/querylog/http.go b/internal/querylog/http.go index 1a29e23d..7a5c24a9 100644 --- a/internal/querylog/http.go +++ b/internal/querylog/http.go @@ -93,7 +93,7 @@ func (l *queryLog) handleQueryLog(w http.ResponseWriter, r *http.Request) { 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 @@ -118,7 +118,7 @@ func (l *queryLog) handleQueryLogInfo(w http.ResponseWriter, r *http.Request) { ivl = timeutil.Day * 90 } - _ = aghhttp.WriteJSONResponse(w, r, configJSON{ + aghhttp.WriteJSONResponseOK(w, r, configJSON{ Enabled: aghalg.BoolToNullBool(l.conf.Enabled), Interval: ivl.Hours() / 24, AnonymizeClientIP: aghalg.BoolToNullBool(l.conf.AnonymizeClientIP), @@ -143,7 +143,7 @@ func (l *queryLog) handleGetQueryLogConfig(w http.ResponseWriter, r *http.Reques 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. diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 1bf96016..fe5aeb82 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -2,9 +2,11 @@ package schedule import ( + "encoding/json" "fmt" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/timeutil" "gopkg.in/yaml.v3" @@ -50,6 +52,10 @@ func FullWeekly() (w *Weekly) { // Clone returns a deep copy of a weekly. func (w *Weekly) Clone() (c *Weekly) { + if w == nil { + return nil + } + // NOTE: Do not use time.LoadLocation, because the results will be // different on time zone database update. return &Weekly{ @@ -75,12 +81,62 @@ func (w *Weekly) Contains(t time.Time) (ok bool) { 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 var _ yaml.Unmarshaler = (*Weekly)(nil) // UnmarshalYAML implements the [yaml.Unmarshaler] interface for *Weekly. func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) { - conf := &weeklyConfig{} + conf := &weeklyConfigYAML{} err = value.Decode(conf) if err != nil { @@ -96,7 +152,7 @@ func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) { return err } - days := []dayConfig{ + days := []dayConfigYAML{ time.Sunday: conf.Sunday, time.Monday: conf.Monday, time.Tuesday: conf.Tuesday, @@ -124,24 +180,24 @@ func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) { return nil } -// weeklyConfig is the YAML configuration structure of Weekly. -type weeklyConfig struct { +// weeklyConfigYAML is the YAML configuration structure of Weekly. +type weeklyConfigYAML struct { // TimeZone is the local time zone. TimeZone string `yaml:"time_zone"` // Days of the week. - Sunday dayConfig `yaml:"sun,omitempty"` - Monday dayConfig `yaml:"mon,omitempty"` - Tuesday dayConfig `yaml:"tue,omitempty"` - Wednesday dayConfig `yaml:"wed,omitempty"` - Thursday dayConfig `yaml:"thu,omitempty"` - Friday dayConfig `yaml:"fri,omitempty"` - Saturday dayConfig `yaml:"sat,omitempty"` + Sunday dayConfigYAML `yaml:"sun,omitempty"` + Monday dayConfigYAML `yaml:"mon,omitempty"` + Tuesday dayConfigYAML `yaml:"tue,omitempty"` + Wednesday dayConfigYAML `yaml:"wed,omitempty"` + Thursday dayConfigYAML `yaml:"thu,omitempty"` + Friday dayConfigYAML `yaml:"fri,omitempty"` + Saturday dayConfigYAML `yaml:"sat,omitempty"` } -// dayConfig is the YAML configuration structure of dayRange. -type dayConfig struct { +// dayConfigYAML is the YAML configuration structure of dayRange. +type dayConfigYAML struct { Start timeutil.Duration `yaml:"start"` 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 var _ yaml.Marshaler = (*Weekly)(nil) // MarshalYAML implements the [yaml.Marshaler] interface for *Weekly. func (w *Weekly) MarshalYAML() (v any, err error) { - return weeklyConfig{ + return weeklyConfigYAML{ TimeZone: w.location.String(), - Sunday: dayConfig{ + Sunday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Sunday].start}, End: timeutil.Duration{Duration: w.days[time.Sunday].end}, }, - Monday: dayConfig{ + Monday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Monday].start}, End: timeutil.Duration{Duration: w.days[time.Monday].end}, }, - Tuesday: dayConfig{ + Tuesday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Tuesday].start}, End: timeutil.Duration{Duration: w.days[time.Tuesday].end}, }, - Wednesday: dayConfig{ + Wednesday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Wednesday].start}, End: timeutil.Duration{Duration: w.days[time.Wednesday].end}, }, - Thursday: dayConfig{ + Thursday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Thursday].start}, End: timeutil.Duration{Duration: w.days[time.Thursday].end}, }, - Friday: dayConfig{ + Friday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Friday].start}, End: timeutil.Duration{Duration: w.days[time.Friday].end}, }, - Saturday: dayConfig{ + Saturday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Saturday].start}, 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) { 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"` +} diff --git a/internal/schedule/schedule_internal_test.go b/internal/schedule/schedule_internal_test.go index f500524e..0d9aa705 100644 --- a/internal/schedule/schedule_internal_test.go +++ b/internal/schedule/schedule_internal_test.go @@ -1,6 +1,7 @@ package schedule import ( + "encoding/json" "testing" "time" @@ -122,7 +123,7 @@ func TestWeekly_Contains(t *testing.T) { } } -const brusselsSunday = ` +const brusselsSundayYAML = ` sun: start: 12h end: 14h @@ -179,7 +180,7 @@ yaml: "bad" }, { name: "brussels_sunday", wantErrMsg: "", - data: []byte(brusselsSunday), + data: []byte(brusselsSundayYAML), want: brusselsWeekly, }, { name: "start_equal_end", @@ -240,7 +241,7 @@ func TestWeekly_MarshalYAML(t *testing.T) { want: &Weekly{}, }, { name: "brussels_sunday", - data: []byte(brusselsSunday), + data: []byte(brusselsSundayYAML), 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) + }) + } +} diff --git a/internal/stats/http.go b/internal/stats/http.go index 764579b1..3eee5bbb 100644 --- a/internal/stats/http.go +++ b/internal/stats/http.go @@ -73,7 +73,7 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) { return } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // 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 } - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // 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) - _ = aghhttp.WriteJSONResponse(w, r, resp) + aghhttp.WriteJSONResponseOK(w, r, resp) } // handleStatsConfig is the handler for the POST /control/stats_config HTTP API. diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md index fe35e4c9..667dff8c 100644 --- a/openapi/CHANGELOG.md +++ b/openapi/CHANGELOG.md @@ -4,6 +4,86 @@ ## 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 ### The new fields `"top_upstreams_responses"` and `"top_upstreams_avg_time"` in `Stats` object diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 64f376e4..89a1a1c1 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1001,6 +1001,9 @@ '$ref': '#/components/schemas/BlockedServicesAll' '/blocked_services/list': 'get': + 'deprecated': true + 'description': > + Deprecated: Use `GET /blocked_services/get` instead. 'tags': - 'blocked_services' 'operationId': 'blockedServicesList' @@ -1014,6 +1017,9 @@ '$ref': '#/components/schemas/BlockedServicesArray' '/blocked_services/set': 'post': + 'deprecated': true + 'description': > + Deprecated: Use `PUT /blocked_services/update` instead. 'tags': - 'blocked_services' 'operationId': 'blockedServicesSet' @@ -1026,6 +1032,34 @@ 'responses': '200': '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': 'get': 'tags': @@ -2485,6 +2519,54 @@ 'type': 'boolean' 'youtube': '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': 'type': 'object' 'description': 'Client information.' @@ -2513,6 +2595,8 @@ '$ref': '#/components/schemas/SafeSearchConfig' 'use_global_blocked_services': 'type': 'boolean' + 'blocked_services_schedule': + '$ref': '#/components/schemas/Schedule' 'blocked_services': 'type': 'array' 'items': @@ -2793,6 +2877,17 @@ - 'name' - 'rules' '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': 'type': 'object' 'description': 'Configuration to be checked'