Pull request 2475: AG-46192-lang-validation

Squashed commit of the following:

commit 4129832c67272fa28c2cee0caeb0859900c9fb28
Merge: c3db487b9 b4f1ab280
Author: Igor Lobanov <i.lobanov@adguard.com>
Date:   Mon Sep 22 11:06:22 2025 +0200

    Merge branch 'master' into AG-46192-lang-validation
    
    # Conflicts:
    #	CHANGELOG.md

commit c3db487b9d
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 19 16:12:06 2025 +0300

    ADG-10728 remove language update request on page reload

commit 20f3a3057b
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Sep 16 15:47:50 2025 +0300

    home: imp naming

commit 942c28f4c0
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Sep 16 13:15:28 2025 +0300

    all: add tests

commit a871d51341
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Sep 15 18:09:30 2025 +0300

    home: lang validation
This commit is contained in:
Stanislav Chzhen 2025-09-22 14:47:08 +03:00
parent b4f1ab2808
commit 2a81beeb46
6 changed files with 159 additions and 16 deletions

View File

@ -28,6 +28,7 @@ NOTE: Add new changes BELOW THIS COMMENT.
### Fixed
- Excessive configuration file overwrites when visiting the Web UI and a non-empty `language` is set.
- Lowered the severity of log messages for failed deletion of old filter files ([#7964]).
[#7964]: https://github.com/AdguardTeam/AdGuardHome/issues/7964

View File

@ -26,7 +26,7 @@ import { getLogsUrlParams, setHtmlLangAttr, setUITheme } from '../../helpers/hel
import Header from '../Header';
import { changeLanguage, getDnsStatus, getTimerStatus } from '../../actions';
import { getDnsStatus, getTimerStatus } from '../../actions';
import Dashboard from '../../containers/Dashboard';
import SetupGuide from '../../containers/SetupGuide';
@ -134,16 +134,12 @@ const App = () => {
}, []);
const setLanguage = () => {
if (!processing) {
if (language) {
i18n.changeLanguage(language);
setHtmlLangAttr(language);
}
if (processing || !language) {
return;
}
i18n.on('languageChanged', (lang) => {
dispatch(changeLanguage(lang));
});
i18n.changeLanguage(language);
setHtmlLangAttr(language);
};
useEffect(() => {

View File

@ -13,7 +13,7 @@ import './Select.css';
import { setHtmlLangAttr, setUITheme } from '../../helpers/helpers';
import { changeTheme } from '../../actions';
import { changeLanguage, changeTheme } from '../../actions';
import { RootState } from '../../initialState';
const linksData = [
@ -46,10 +46,10 @@ const Footer = () => {
return today.getFullYear();
};
const changeLanguage = (event: any) => {
const { value } = event.target;
i18n.changeLanguage(value);
setHtmlLangAttr(value);
const onLanguageChange = (language: string) => {
i18n.changeLanguage(language);
setHtmlLangAttr(language);
dispatch(changeLanguage(language));
};
const onThemeChange = (value: any) => {
@ -143,7 +143,7 @@ const Footer = () => {
<select
className="form-control select select--language"
value={i18n.language}
onChange={changeLanguage}>
onChange={(e) => onLanguageChange(e.target.value)}>
{Object.keys(LANGUAGES).map((lang) => (
<option key={lang} value={lang}>
{LANGUAGES[lang]}

View File

@ -721,6 +721,14 @@ func validateConfig(ctx context.Context, l *slog.Logger) (err error) {
l.WarnContext(ctx, "no users in the configuration file; authentication is disabled")
}
if config.Language != "" && !allowedLanguages.Has(config.Language) {
l.WarnContext(ctx, "unsupported language", "lang", config.Language)
// Clear the language so the frontend can use the client's browser
// language.
config.Language = ""
}
return nil
}

View File

@ -99,16 +99,26 @@ func (web *webAPI) handlePutProfile(w http.ResponseWriter, r *http.Request) {
theme := profileReq.Theme
changed := false
func() {
config.Lock()
defer config.Unlock()
if config.Language == lang && config.Theme == theme {
web.logger.DebugContext(ctx, "updating profile; no changes")
return
}
changed = true
config.Language = lang
config.Theme = theme
web.logger.InfoContext(ctx, "profile updated", "lang", lang, "theme", theme)
}()
web.confModifier.Apply(ctx)
if changed {
web.confModifier.Apply(ctx)
}
aghhttp.OK(w)
}

View File

@ -1,7 +1,11 @@
package home
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@ -10,6 +14,10 @@ import (
"time"
"github.com/AdguardTeam/AdGuardHome/internal/agh"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/httphdr"
"github.com/AdguardTeam/golibs/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -117,3 +125,123 @@ func TestWeb_HandleGetProfile(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code)
}))
}
func TestWeb_HandlePutProfile(t *testing.T) {
storeGlobals(t)
mux := http.NewServeMux()
globalContext.mux = mux
isConfigChanged := false
confModifier := &aghtest.ConfigModifier{
OnApply: func(_ context.Context) { isConfigChanged = true },
}
ctx := testutil.ContextWithTimeout(t, testTimeout)
web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, confModifier, false)
require.NoError(t, err)
globalContext.web = web
var (
dataValid = errors.Must(json.Marshal(&profileJSON{
Language: "en",
Theme: "auto",
}))
dataInvalidLang = errors.Must(json.Marshal(&profileJSON{
Language: "invalid_lang",
Theme: "auto",
}))
dataInvalidTheme = errors.Must(json.Marshal(&profileJSON{
Language: "en",
Theme: "invalid_theme",
}))
)
testCases := []struct {
req *http.Request
name string
wantBody string
wantCode int
}{{
req: newProfileUpdateRequest(http.MethodPut, dataValid, true),
name: "basic",
wantBody: "OK\n",
wantCode: http.StatusOK,
}, {
req: newProfileUpdateRequest(http.MethodGet, dataValid, true),
name: "invalid_method",
wantBody: "only method PUT is allowed\n",
wantCode: http.StatusMethodNotAllowed,
}, {
req: newProfileUpdateRequest(http.MethodPut, dataValid, false),
name: "invalid_content_type",
wantBody: "only content-type application/json is allowed\n",
wantCode: http.StatusUnsupportedMediaType,
}, {
req: newProfileUpdateRequest(http.MethodPut, nil, false),
name: "empty_body",
wantBody: "reading req: EOF\n",
wantCode: http.StatusBadRequest,
}, {
req: newProfileUpdateRequest(http.MethodPut, dataInvalidLang, true),
name: "invalid_language",
wantBody: `unknown language: "invalid_lang"` + "\n",
wantCode: http.StatusBadRequest,
}, {
req: newProfileUpdateRequest(http.MethodPut, dataInvalidTheme, true),
name: "invalid_theme",
wantBody: `reading req: invalid theme "invalid_theme", ` +
`supported: "auto", "dark", "light"` + "\n",
wantCode: http.StatusBadRequest,
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
mux.ServeHTTP(w, tc.req)
assert.Equal(t, tc.wantCode, w.Code)
assert.Equal(t, tc.wantBody, w.Body.String())
})
}
require.True(t, t.Run("single_config_update", func(t *testing.T) {
isConfigChanged = false
config.Language = ""
config.Theme = ""
w := httptest.NewRecorder()
mux.ServeHTTP(w, newProfileUpdateRequest(http.MethodPut, dataValid, true))
require.Equal(t, http.StatusOK, w.Code)
assert.True(t, isConfigChanged)
isConfigChanged = false
mux.ServeHTTP(w, newProfileUpdateRequest(http.MethodPut, dataValid, true))
require.Equal(t, http.StatusOK, w.Code)
assert.False(t, isConfigChanged)
}))
}
// newProfileUpdateRequest builds an *http.Request for the profile update
// endpoint. If body is non-nil, it is used as the request body. If setCT is
// true, the Content-Type header is set to application/json.
func newProfileUpdateRequest(method string, body []byte, setCT bool) (req *http.Request) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req = httptest.NewRequest(method, "/control/profile/update", r)
if setCT {
req.Header.Set(httphdr.ContentType, aghhttp.HdrValApplicationJSON)
}
return req
}