Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/__locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
"add_allowlist": "Add allowlist",
"cancel_btn": "Cancel",
"enter_name_hint": "Enter name",
"name_auto_fetch_hint": "Name will be fetched automatically from URL",
"fetching_name_from_url": "Fetching name from URL...",
"enter_url_or_path_hint": "Enter a URL or an absolute path of the list",
"check_updates_btn": "Check for updates",
"new_blocklist": "New blocklist",
Expand Down
25 changes: 25 additions & 0 deletions client/src/actions/filtering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,28 @@ export const checkHost = (host: any) => async (dispatch: any) => {
dispatch(checkHostFailure());
}
};

export const fetchFilterTitleRequest = createAction('FETCH_FILTER_TITLE_REQUEST');
export const fetchFilterTitleFailure = createAction('FETCH_FILTER_TITLE_FAILURE');
export const fetchFilterTitleSuccess = createAction('FETCH_FILTER_TITLE_SUCCESS');

/**
* Fetches the title from a filter URL without adding it to the list.
* @param {string} url - The URL to fetch the title from
* @returns {Promise<string>} - The title extracted from the filter
*/
export const fetchFilterTitle = (url: string) => async (dispatch: any) => {
dispatch(fetchFilterTitleRequest());
try {
const data = await apiClient.fetchFilterTitle(url);
dispatch(fetchFilterTitleSuccess(data));
return data.title || '';
} catch (error) {
// Silent failure for UX - fetching title is optional
dispatch(fetchFilterTitleFailure());
if (process.env.NODE_ENV === 'development') {
console.debug('Failed to fetch filter title:', error);
}
return '';
}
};
11 changes: 11 additions & 0 deletions client/src/api/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class Api {

FILTERING_CHECK_HOST = { path: 'filtering/check_host', method: 'GET' };

FILTERING_FETCH_TITLE = { path: 'filtering/fetch_title', method: 'POST' };

getFilteringStatus() {
const { path, method } = this.FILTERING_STATUS;

Expand Down Expand Up @@ -164,6 +166,15 @@ class Api {
return this.makeRequest(url, method);
}

fetchFilterTitle(url: string) {
const { path, method } = this.FILTERING_FETCH_TITLE;
const parameters = {
data: { url },
};

return this.makeRequest(path, method, parameters);
}

// Parental
PARENTAL_STATUS = { path: 'parental/status', method: 'GET' };

Expand Down
65 changes: 61 additions & 4 deletions client/src/components/Filters/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import React, { useEffect, useState, useRef } from 'react';
import { useForm, Controller, FormProvider, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { validatePath, validateRequiredValue } from '../../helpers/validators';

import { MODAL_OPEN_TIMEOUT, MODAL_TYPE } from '../../helpers/constants';
import filtersCatalog from '../../helpers/filters/filters';
import { FiltersList } from './FiltersList';
import { Input } from '../ui/Controls/Input';
import { fetchFilterTitle } from '../../actions/filtering';

type FormValues = {
enabled: boolean;
Expand Down Expand Up @@ -44,6 +46,8 @@ export const Form = ({
initialValues,
}: Props) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const [isFetchingTitle, setIsFetchingTitle] = useState(false);

const methods = useForm({
defaultValues: {
Expand All @@ -52,7 +56,56 @@ export const Form = ({
},
mode: 'onBlur',
});
const { handleSubmit, control } = methods;
const { handleSubmit, control, setValue, getValues } = methods;

// Watch URL field for changes
const urlValue = useWatch({ control, name: 'url' });
const nameValue = useWatch({ control, name: 'name' });
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);

// Auto-fetch title from URL
useEffect(() => {
// Clear any existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}

// Don't fetch if:
// - URL is empty
// - Name field already has a value (user has typed something)
// - We're in edit mode (initialValues provided)
// - URL doesn't pass basic validation
if (!urlValue || nameValue || initialValues?.name) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency array includes initialValues, but the condition only checks initialValues?.name. Consider either checking the entire initialValues object in the condition or only including initialValues?.name in the dependency array for consistency.

return;
}

// Basic URL validation check
const isValidUrl = validatePath(urlValue) === undefined;
if (!isValidUrl) {
return;
}

// Debounce: wait 800ms after user stops typing
debounceTimerRef.current = setTimeout(async () => {
setIsFetchingTitle(true);
try {
const title = await dispatch(fetchFilterTitle(urlValue) as any);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using as any bypasses TypeScript's type checking, which can hide potential errors. Consider properly typing the fetchFilterTitle action return value or using a more specific type assertion that preserves some type safety.

// Only set the name if it's still empty (user hasn't typed anything)
if (!getValues('name') && title) {
setValue('name', title, { shouldValidate: false });
}
} finally {
setIsFetchingTitle(false);
}
}, 800);

// Cleanup on unmount
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [urlValue, nameValue, dispatch, setValue, getValues, initialValues]);

const openModal = (modalType: keyof typeof MODAL_TYPE, timeout = MODAL_OPEN_TIMEOUT) => {
toggleFilteringModal(undefined);
Expand Down Expand Up @@ -98,7 +151,11 @@ export const Form = ({
{...field}
type="text"
data-testid="filters_name"
placeholder={t('enter_name_hint')}
placeholder={
isFetchingTitle
? t('fetching_name_from_url')
: t('name_auto_fetch_hint')
}
error={fieldState.error?.message}
trimOnBlur
/>
Expand Down
28 changes: 28 additions & 0 deletions internal/filtering/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,34 @@ func (d *DNSFilter) readerFromURL(fltURL string) (r io.ReadCloser, err error) {
return resp.Body, nil
}

// fetchFilterTitle downloads the beginning of a filter file from url and
// extracts the title. It returns an empty string if no title is found or if
// the URL is a file path.
func (d *DNSFilter) fetchFilterTitle(url string) (title string, err error) {
r, err := d.reader(url)
if err != nil {
// Don't wrap the error since it's informative enough as is.
return "", err
}
defer func() { err = errors.WithDeferred(err, r.Close()) }()

// Use a limited reader to avoid downloading large files. 4KB should be
// enough to find the title in the header comments.
const maxTitleBytes = 4096
lr := io.LimitReader(r, maxTitleBytes)

bufPtr := d.bufPool.Get()
defer d.bufPool.Put(bufPtr)

p := rulelist.NewParser()
res, err := p.Parse(io.Discard, lr, *bufPtr)
if err != nil {
return "", fmt.Errorf("parsing filter: %w", err)
Comment on lines +629 to +632
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function downloads and parses the entire filter content (up to 4KB) even though we only need the title. Consider adding a method to rulelist.Parser that can extract just the title without parsing all rules, which would be more efficient.

}

return res.Title, nil
}

// loads filter contents from the file in dataDir
func (d *DNSFilter) load(ctx context.Context, flt *FilterYAML) (err error) {
fileName := flt.Path(d.conf.DataDir)
Expand Down
106 changes: 106 additions & 0 deletions internal/filtering/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ func (d *DNSFilter) validateFilterURL(urlStr string) (err error) {
return nil
}

// validateFilterURLForFetch is a more permissive validation function for the
// fetch_title endpoint. Since this endpoint only reads data without saving
// it, we can be less restrictive about URL format. This allows URLs without
// file extensions, which are common for blocklists (e.g., "/hosts",
// "/blocklist").
func (d *DNSFilter) validateFilterURLForFetch(urlStr string) (err error) {
defer func() { err = errors.Annotate(err, "checking filter for fetch: %w") }()

if filepath.IsAbs(urlStr) {
urlStr = filepath.Clean(urlStr)
_, err = os.Stat(urlStr)
if err != nil {
// Don't wrap the error since it's informative enough as is.
return err
}

if !pathMatchesAny(d.safeFSPatterns, urlStr) {
return fmt.Errorf("path %q does not match safe patterns", urlStr)
}

return nil
}

u, err := url.ParseRequestURI(urlStr)
if err != nil {
// Don't wrap the error, because it's informative enough as is.
return err
}

// Only validate that it's HTTP or HTTPS, don't apply stricter rules
// that might reject URLs without file extensions.
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid url scheme: %q", u.Scheme)
}

if u.Host == "" {
return errors.Error("empty url host")
}

return nil
}

type filterAddJSON struct {
Name string `json:"name"`
URL string `json:"url"`
Expand Down Expand Up @@ -610,6 +652,69 @@ func (d *DNSFilter) handleCheckHost(w http.ResponseWriter, r *http.Request) {
aghhttp.WriteJSONResponseOK(ctx, l, w, r, resp)
}

// filterTitleReq is the request structure for fetching filter title.
type filterTitleReq struct {
URL string `json:"url"`
}

// filterTitleResp is the response structure for fetching filter title.
type filterTitleResp struct {
Title string `json:"title"`
}

// handleFetchTitle is the handler for the POST /control/filtering/fetch_title
// HTTP API. It downloads the beginning of the filter file and extracts the
// title without saving the filter.
func (d *DNSFilter) handleFetchTitle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := d.logger

req := filterTitleReq{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
aghhttp.ErrorAndLog(
ctx,
l,
r,
w,
http.StatusBadRequest,
"failed to parse request body json: %s",
err,
)

return
}

err = d.validateFilterURLForFetch(req.URL)
if err != nil {
aghhttp.ErrorAndLog(ctx, l, r, w, http.StatusBadRequest, "%s", err)

return
}

title, err := d.fetchFilterTitle(req.URL)
if err != nil {
aghhttp.ErrorAndLog(
ctx,
l,
r,
w,
http.StatusBadRequest,
"couldn't fetch filter title from URL %q: %s",
req.URL,
err,
)

return
}

resp := filterTitleResp{
Title: title,
}

aghhttp.WriteJSONResponseOK(ctx, l, w, r, resp)
}

// stringToDNSType is a helper function that converts a string to DNS type. If
// the string is empty, it returns the default value [dns.TypeA].
func stringToDNSType(str string) (qtype uint16, err error) {
Expand Down Expand Up @@ -753,6 +858,7 @@ func (d *DNSFilter) RegisterFilteringHandlers() {
registerHTTP(http.MethodPost, "/control/filtering/refresh", d.handleFilteringRefresh)
registerHTTP(http.MethodPost, "/control/filtering/set_rules", d.handleFilteringSetRules)
registerHTTP(http.MethodGet, "/control/filtering/check_host", d.handleCheckHost)
registerHTTP(http.MethodPost, "/control/filtering/fetch_title", d.handleFetchTitle)
}

// ValidateUpdateIvl returns false if i is not a valid filters update interval.
Expand Down
Loading