Compare commits
4 Commits
c070cc8053
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3c8ca62 | |||
| d14a154dcb | |||
| 89ab955efc | |||
| edc8568cc4 |
@@ -89,9 +89,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
setActiveChild(event.cookies, activeId);
|
setActiveChild(event.cookies, activeId);
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
} catch {
|
console.error(
|
||||||
|
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
// Expired/revoked/malformed — leave the cookie; the picker switch
|
// Expired/revoked/malformed — leave the cookie; the picker switch
|
||||||
// re-mints it server-side. Fall through to the single session.
|
// re-mints it server-side. Fall through to the single session.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,8 +115,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (freshToken !== token) {
|
if (freshToken !== token) {
|
||||||
setSessionCookie(event.cookies, freshToken);
|
setSessionCookie(event.cookies, freshToken);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Expired, malformed, or revoked — drop it and treat as logged out.
|
// Expired, malformed, or revoked — drop it and treat as logged out.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
clearSessionCookie(event.cookies);
|
clearSessionCookie(event.cookies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
|
|||||||
// Superuser PB client (memoized). Reserved for server-only privileged
|
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||||
// operations that must bypass collection rules: creating child users, minting
|
// operations that must bypass collection rules: creating child users, minting
|
||||||
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
||||||
|
//
|
||||||
|
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
|
||||||
|
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
|
||||||
|
// calls with 404 "resource wasn't found" — which the toggle path misreads as
|
||||||
|
// CHORE_GONE for every chore. So always re-auth when the stored token is no
|
||||||
|
// longer valid instead of reusing a dead client.
|
||||||
let superClient: PocketBase | null = null;
|
let superClient: PocketBase | null = null;
|
||||||
export async function createSuperClient() {
|
export async function createSuperClient() {
|
||||||
if (superClient) return superClient;
|
if (superClient?.authStore.isValid) return superClient;
|
||||||
|
superClient = null;
|
||||||
const pb = new PocketBase(PB_ENDPOINT);
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||||
@@ -40,29 +47,43 @@ export async function createSuperClient() {
|
|||||||
return pb;
|
return pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run a superuser op, retrying once with a freshly-authenticated client when
|
||||||
|
// the first attempt hits an auth-shaped failure. A locally-valid token can
|
||||||
|
// still be dead server-side (restart/revoked secret rotation), and PB
|
||||||
|
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
|
||||||
|
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status;
|
||||||
|
if (status === 401 || status === 403 || status === 404) {
|
||||||
|
superClient = null;
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
||||||
// server PB access (authenticated user + superuser) lives in this one module.
|
// server PB access (authenticated user + superuser) lives in this one module.
|
||||||
export const pbAdmin = {
|
export const pbAdmin = {
|
||||||
async getList(collection: string, filter = '') {
|
async getList(collection: string, filter = '') {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => {
|
||||||
const options: { filter?: string } = {};
|
const options: { filter?: string } = {};
|
||||||
if (filter) options.filter = filter;
|
if (filter) options.filter = filter;
|
||||||
return pb.collection(collection).getFullList(options);
|
return pb.collection(collection).getFullList(options);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async getOne(collection: string, id: string) {
|
async getOne(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).getOne(id));
|
||||||
return pb.collection(collection).getOne(id);
|
|
||||||
},
|
},
|
||||||
async create(collection: string, data: Record<string, unknown>) {
|
async create(collection: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).create(data));
|
||||||
return pb.collection(collection).create(data);
|
|
||||||
},
|
},
|
||||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).update(id, data));
|
||||||
return pb.collection(collection).update(id, data);
|
|
||||||
},
|
},
|
||||||
async remove(collection: string, id: string) {
|
async remove(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).delete(id));
|
||||||
return pb.collection(collection).delete(id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,10 +67,16 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
let chore: any;
|
let chore: any;
|
||||||
try {
|
try {
|
||||||
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
}
|
}
|
||||||
if (!chore || chore.famId !== famId) {
|
if (!chore || chore.famId !== famId) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
|
||||||
|
);
|
||||||
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
}
|
}
|
||||||
const isTodo = chore?.isTodo;
|
const isTodo = chore?.isTodo;
|
||||||
|
|||||||
@@ -498,9 +498,7 @@
|
|||||||
const counts: number[] = [];
|
const counts: number[] = [];
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const d = addDays(weekStart, i);
|
const d = addDays(weekStart, i);
|
||||||
counts.push(
|
counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
|
||||||
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
@@ -998,10 +996,18 @@
|
|||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
famStore.applyRecord('completions', optimistic, 'delete');
|
famStore.applyRecord('completions', optimistic, 'delete');
|
||||||
}
|
}
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (/CHORE_GONE/.test(msg)) {
|
||||||
|
// Drop the dead card synchronously so a second tap can't re-enter
|
||||||
|
// before the resync round-trip finishes (the repeated 400s).
|
||||||
|
famStore.applyRecord('assigned_chores', chore, 'delete');
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(drop-card) member=${memberId} fam=${famId} chore=${chore.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await famStore.resync();
|
await famStore.resync();
|
||||||
} catch {}
|
} catch {}
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
showToast(
|
showToast(
|
||||||
/CHORE_GONE/.test(msg)
|
/CHORE_GONE/.test(msg)
|
||||||
? 'This chore was changed — list refreshed.'
|
? 'This chore was changed — list refreshed.'
|
||||||
@@ -1231,7 +1237,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}>
|
<Card
|
||||||
|
cols={1}
|
||||||
|
title="Claims"
|
||||||
|
accent={hasClaimableRewards ? '#f97316' : undefined}
|
||||||
|
class={hasClaimableRewards ? 'claims-card' : ''}
|
||||||
|
>
|
||||||
{#if claimableRewards().length === 0}
|
{#if claimableRewards().length === 0}
|
||||||
<div class="claims-empty">
|
<div class="claims-empty">
|
||||||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||||||
@@ -1396,12 +1407,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<svg
|
<svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
|
||||||
class="hero-bg"
|
|
||||||
viewBox="0 0 700 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline
|
<polyline
|
||||||
points={heroSpark.line}
|
points={heroSpark.line}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1688,8 +1694,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if inYesterday}
|
{#if inYesterday}
|
||||||
<div class="yesterday-banner">
|
<div class="yesterday-banner">
|
||||||
<span>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span>
|
<span
|
||||||
<button class="yesterday-back" onclick={() => (viewDate = 'today')}>Back to today →</button>
|
>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span
|
||||||
|
>
|
||||||
|
<button class="yesterday-back" onclick={() => (viewDate = 'today')}
|
||||||
|
>Back to today →</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
@@ -1699,7 +1709,11 @@
|
|||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each dailyPending as chore}
|
{#each dailyPending as chore}
|
||||||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
<button
|
||||||
|
class="chore"
|
||||||
|
onclick={() => toggle(chore)}
|
||||||
|
disabled={togglingIds.has(chore.id)}
|
||||||
|
>
|
||||||
<span class="checkbox">{@html circleIcon}</span>
|
<span class="checkbox">{@html circleIcon}</span>
|
||||||
<span class="chore-name">{choreName(chore)}</span>
|
<span class="chore-name">{choreName(chore)}</span>
|
||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
@@ -1716,7 +1730,11 @@
|
|||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each weeklyPending as chore}
|
{#each weeklyPending as chore}
|
||||||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
<button
|
||||||
|
class="chore"
|
||||||
|
onclick={() => toggle(chore)}
|
||||||
|
disabled={togglingIds.has(chore.id)}
|
||||||
|
>
|
||||||
<span class="checkbox">{@html circleIcon}</span>
|
<span class="checkbox">{@html circleIcon}</span>
|
||||||
<span class="chore-name">{choreName(chore)}</span>
|
<span class="chore-name">{choreName(chore)}</span>
|
||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ export const actions = {
|
|||||||
const startDate = fd.get('startDate') as string;
|
const startDate = fd.get('startDate') as string;
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
if (type === 'emoji') {
|
if (type === 'emoji') {
|
||||||
data.value = 0;
|
// 1, not 0: PB treats 0 as blank on the required `value` field.
|
||||||
|
data.value = 1;
|
||||||
data.emoji = (fd.get('emoji') as string) || '🎉';
|
data.emoji = (fd.get('emoji') as string) || '🎉';
|
||||||
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
||||||
data.customName = customName || undefined;
|
data.customName = customName || undefined;
|
||||||
@@ -160,7 +161,7 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const type = fd.get('type') as string;
|
const type = fd.get('type') as string;
|
||||||
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0);
|
const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
|
||||||
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
||||||
const todoStart = fd.get('todoStart') as string;
|
const todoStart = fd.get('todoStart') as string;
|
||||||
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
|
|||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
export async function POST(event: RequestEvent) {
|
||||||
const u = event.locals.user;
|
const u = event.locals.user;
|
||||||
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
if (!u || !event.locals.pbToken) {
|
||||||
|
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
const pb = createPbClient(event.locals.pbToken);
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
const body = await event.request.json().catch(() => ({}));
|
const body = await event.request.json().catch(() => ({}));
|
||||||
try {
|
try {
|
||||||
const s = createServices(pb, { id: u.id, role: u.role });
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
return json(await s.completions.toggle(u.famId, body));
|
const out = await s.completions.toggle(u.famId, body);
|
||||||
|
console.log(
|
||||||
|
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
|
||||||
|
);
|
||||||
|
return json(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
|
||||||
|
// claims against server truth from both angles (superuser getOne — the toggle
|
||||||
|
// path — and the user's own list read — the kanban path). Authenticated,
|
||||||
|
// own-fam only, no tokens ever logged or returned.
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
|
||||||
|
|
||||||
|
let suChore: any = null;
|
||||||
|
let suError = '';
|
||||||
|
if (assignedChoreId) {
|
||||||
|
try {
|
||||||
|
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
suError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSeesIt: boolean | null = null;
|
||||||
|
let userListError = '';
|
||||||
|
try {
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const list = await pb
|
||||||
|
.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${u.famId}'` });
|
||||||
|
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
userListError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
session: { id: u.id, role: u.role, famId: u.famId },
|
||||||
|
asked: assignedChoreId,
|
||||||
|
superuser: suChore
|
||||||
|
? {
|
||||||
|
found: true,
|
||||||
|
famId: suChore.famId,
|
||||||
|
memberId: suChore.memberId,
|
||||||
|
isTodo: !!suChore.isTodo,
|
||||||
|
famMatch: suChore.famId === u.famId
|
||||||
|
}
|
||||||
|
: { found: false, error: suError },
|
||||||
|
userList: { seesIt: userSeesIt, error: userListError }
|
||||||
|
};
|
||||||
|
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
|
||||||
|
return json(out);
|
||||||
|
}
|
||||||
@@ -10,7 +10,10 @@ import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
|
|||||||
// frequency, this-week start/completeBy (so it expires and purges naturally).
|
// frequency, this-week start/completeBy (so it expires and purges naturally).
|
||||||
export async function POST(event: RequestEvent) {
|
export async function POST(event: RequestEvent) {
|
||||||
const u = event.locals.user;
|
const u = event.locals.user;
|
||||||
if (!u) throw error(401, 'Unauthorized');
|
if (!u) {
|
||||||
|
console.error('[diag] todos 401 (no session)');
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
const body = await event.request.json().catch(() => ({}));
|
const body = await event.request.json().catch(() => ({}));
|
||||||
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
||||||
if (!name) throw error(400, 'Give your todo a name');
|
if (!name) throw error(400, 'Give your todo a name');
|
||||||
@@ -33,15 +36,22 @@ export async function POST(event: RequestEvent) {
|
|||||||
memberId: u.id,
|
memberId: u.id,
|
||||||
frequency: 'weekly',
|
frequency: 'weekly',
|
||||||
type: 'emoji',
|
type: 'emoji',
|
||||||
value: 0,
|
// Must be 1, not 0: PB treats 0 as blank on the required `value`
|
||||||
|
// number field (400 validation_required). Value is meaningless for
|
||||||
|
// emoji todos — never summed, never displayed, never rewarded.
|
||||||
|
value: 1,
|
||||||
customName: name,
|
customName: name,
|
||||||
isTodo: true,
|
isTodo: true,
|
||||||
emoji: '🎯',
|
emoji: '🎯',
|
||||||
startDate: today,
|
startDate: today,
|
||||||
completeBy
|
completeBy
|
||||||
});
|
});
|
||||||
|
console.log(`[diag] todo ok member=${u.id} fam=${u.famId} todo=${record.id} name=${name}`);
|
||||||
return json({ record });
|
return json({ record });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] todo 400 member=${u.id} role=${u.role} fam=${u.famId} name=${name} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
|
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user