Skip to content

Commit 9499d4d

Browse files
authored
fix: infinite preference sync feedback loop and default item page size (#1632)
* fix: set default item page size to 12 * fix: infinite preference sync feedback loop * fix: prevent prototype pollution in preference sync
1 parent 5b18d14 commit 9499d4d

1 file changed

Lines changed: 120 additions & 14 deletions

File tree

frontend/composables/use-preferences.ts

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ export type LocationViewPreferences = {
3434
};
3535
};
3636
export type PreferenceSyncConfig = Partial<Record<keyof LocationViewPreferences, boolean>>;
37+
type PreferenceChange = true | Record<string, PreferenceChange>;
38+
type PreferenceChanges = Partial<Record<keyof LocationViewPreferences, PreferenceChange>>;
3739

3840
const DEFAULT_PREFERENCES: LocationViewPreferences = {
3941
showDetails: true,
4042
showEmpty: true,
4143
editorAdvancedView: false,
4244
itemDisplayView: "card",
4345
theme: "homebox",
44-
itemsPerTablePage: 10,
46+
itemsPerTablePage: 12,
4547
displayLegacyHeader: false,
4648
legacyImageFit: false,
4749
language: null,
@@ -84,41 +86,131 @@ function buildSyncedSettings(preferences: LocationViewPreferences): Record<strin
8486
return payload;
8587
}
8688

89+
function isPlainObject(value: unknown): value is Record<string, unknown> {
90+
return value !== null && typeof value === "object" && !Array.isArray(value);
91+
}
92+
93+
function isPrototypeKey(key: string): boolean {
94+
return key === "__proto__" || key === "constructor" || key === "prototype";
95+
}
96+
97+
function mergeSyncedValue(serverValue: unknown, localValue: unknown, localChange?: PreferenceChange): unknown {
98+
if (localChange === undefined) {
99+
return serverValue;
100+
}
101+
102+
if (localChange === true || !isPlainObject(serverValue) || !isPlainObject(localValue)) {
103+
return localValue;
104+
}
105+
106+
const mergedValue: Record<string, unknown> = {};
107+
const keys = new Set([...Object.keys(serverValue), ...Object.keys(localValue)]);
108+
109+
for (const key of keys) {
110+
if (isPrototypeKey(key)) {
111+
continue;
112+
}
113+
114+
const nestedChange = localChange[key];
115+
if (nestedChange !== undefined) {
116+
mergedValue[key] = mergeSyncedValue(serverValue[key], localValue[key], nestedChange);
117+
continue;
118+
}
119+
120+
if (Object.hasOwn(serverValue, key)) {
121+
mergedValue[key] = serverValue[key];
122+
} else {
123+
mergedValue[key] = localValue[key];
124+
}
125+
}
126+
127+
return mergedValue;
128+
}
129+
87130
function mergeSyncedSettings(
88131
settings: Record<string, unknown>,
89-
preferences: LocationViewPreferences
132+
preferences: LocationViewPreferences,
133+
localChanges: PreferenceChanges = {}
90134
): LocationViewPreferences {
91135
const nextPreferences = { ...preferences };
92136

93137
forEachSyncedPreference(key => {
94138
if (key in settings) {
95-
nextPreferences[key] = settings[key] as never;
139+
nextPreferences[key] = mergeSyncedValue(settings[key], preferences[key], localChanges[key]) as never;
96140
}
97141
});
98142

99143
return nextPreferences;
100144
}
101145

146+
function cloneSyncedSettings(settings: Record<string, unknown>): Record<string, unknown> {
147+
return JSON.parse(JSON.stringify(settings)) as Record<string, unknown>;
148+
}
149+
150+
function getPreferenceChange(previousValue: unknown, nextValue: unknown): PreferenceChange | null {
151+
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
152+
return null;
153+
}
154+
155+
if (isPlainObject(previousValue) && isPlainObject(nextValue)) {
156+
const changedFields: Record<string, PreferenceChange> = {};
157+
const keys = new Set([...Object.keys(previousValue), ...Object.keys(nextValue)]);
158+
159+
for (const key of keys) {
160+
if (isPrototypeKey(key)) {
161+
continue;
162+
}
163+
164+
const nestedChange = getPreferenceChange(previousValue[key], nextValue[key]);
165+
if (nestedChange !== null) {
166+
changedFields[key] = nestedChange;
167+
}
168+
}
169+
170+
if (Object.keys(changedFields).length > 0) {
171+
return changedFields;
172+
}
173+
}
174+
175+
return true;
176+
}
177+
178+
function getChangedPreferences(
179+
previousSettings: Record<string, unknown>,
180+
preferences: LocationViewPreferences
181+
): PreferenceChanges {
182+
const changedPreferences: PreferenceChanges = {};
183+
184+
forEachSyncedPreference(key => {
185+
const change = getPreferenceChange(previousSettings[key], preferences[key]);
186+
if (change !== null) {
187+
changedPreferences[key] = change;
188+
}
189+
});
190+
191+
return changedPreferences;
192+
}
193+
102194
export function configureViewPreferenceSync(config: PreferenceSyncConfig) {
103195
syncConfig = {
104196
...syncConfig,
105197
...config,
106198
};
107199
}
108200

109-
async function refreshViewPreferencesFromServer(preferences: Ref<LocationViewPreferences>) {
201+
async function fetchViewPreferencesFromServer(): Promise<Record<string, unknown> | null> {
110202
const auth = useAuthContext();
111203
if (!auth.isAuthorized()) {
112-
return;
204+
return null;
113205
}
114206

115207
const api = useUserApi();
116208
const { data, error } = await api.user.getSettings();
117209
if (error || !data?.item) {
118-
return;
210+
return null;
119211
}
120212

121-
preferences.value = mergeSyncedSettings(data.item, preferences.value);
213+
return data.item;
122214
}
123215
export function useViewPreferencesSync() {
124216
if (syncInitialized || !import.meta.client) {
@@ -155,7 +247,7 @@ export function useViewPreferencesSync() {
155247
};
156248

157249
const saveToServer = async () => {
158-
if (saveInFlight || pauseServerSaves || !auth.isAuthorized()) {
250+
if (saveInFlight || retryTimer !== null || pauseServerSaves || !auth.isAuthorized()) {
159251
return;
160252
}
161253

@@ -165,7 +257,14 @@ export function useViewPreferencesSync() {
165257
try {
166258
while (syncedRevision < localRevision && !pauseServerSaves && auth.isAuthorized()) {
167259
const targetRevision = localRevision;
168-
const { error } = await api.user.setSettings(buildSyncedSettings(preferences.value));
260+
let error = false;
261+
try {
262+
({ error } = await api.user.setSettings(buildSyncedSettings(preferences.value)));
263+
} catch {
264+
scheduleRetry();
265+
return;
266+
}
267+
169268
if (error) {
170269
scheduleRetry();
171270
return;
@@ -176,7 +275,7 @@ export function useViewPreferencesSync() {
176275
} finally {
177276
saveInFlight = false;
178277

179-
if (syncedRevision < localRevision && !pauseServerSaves) {
278+
if (syncedRevision < localRevision && retryTimer === null && !pauseServerSaves) {
180279
void saveToServer();
181280
}
182281
}
@@ -196,15 +295,22 @@ export function useViewPreferencesSync() {
196295
try {
197296
while (refreshRequested) {
198297
refreshRequested = false;
298+
const refreshRevision = localRevision;
299+
const refreshSettings = cloneSyncedSettings(buildSyncedSettings(preferences.value));
199300

200301
pauseServerSaves = true;
201-
applyingServerSnapshot = true;
202302
try {
203-
await refreshViewPreferencesFromServer(preferences);
303+
const settings = await fetchViewPreferencesFromServer();
304+
if (settings) {
305+
const localChanges =
306+
localRevision === refreshRevision ? {} : getChangedPreferences(refreshSettings, preferences.value);
307+
applyingServerSnapshot = true;
308+
preferences.value = mergeSyncedSettings(settings, preferences.value, localChanges);
309+
}
204310
} finally {
205311
applyingServerSnapshot = false;
312+
pauseServerSaves = false;
206313
}
207-
pauseServerSaves = false;
208314

209315
if (syncedRevision < localRevision) {
210316
await saveToServer();
@@ -224,7 +330,7 @@ export function useViewPreferencesSync() {
224330

225331
markDirty();
226332
},
227-
{ deep: true }
333+
{ deep: true, flush: "sync" }
228334
);
229335

230336
watch(

0 commit comments

Comments
 (0)