feat: add share link service, API routes, and short URL redirect
Create share.service.ts with token generation (128-bit base64url), CRUD operations, validation, and visibility transition side effects. Add share endpoints under /api/setups/:id/shares, shared access at /api/shared/:token, and /s/:token short URL redirect. Plan: 32-02 (Setup Sharing System - Share Link Backend) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ import { setupRoutes } from "./routes/setups.ts";
|
||||
import { tagRoutes } from "./routes/tags.ts";
|
||||
import { threadRoutes } from "./routes/threads.ts";
|
||||
import { totalRoutes } from "./routes/totals.ts";
|
||||
import { getSetupWithItemsById } from "./services/setup.service.ts";
|
||||
import { validateShareToken } from "./services/share.service.ts";
|
||||
import { withImageUrls } from "./services/storage.service.ts";
|
||||
|
||||
// Seed default data on startup
|
||||
await seedDefaults();
|
||||
@@ -163,6 +166,27 @@ app.use("/api/users/:id/profile", async (c, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
// Shared setup access via token (no auth required)
|
||||
app.get("/api/shared/:token", async (c) => {
|
||||
const db = c.get("db");
|
||||
const token = c.req.param("token");
|
||||
const result = await validateShareToken(db, token);
|
||||
if (!result) return c.json({ error: "Not found" }, 404);
|
||||
const setup = await getSetupWithItemsById(db, result.setupId);
|
||||
if (!setup) return c.json({ error: "Not found" }, 404);
|
||||
const enrichedItems = await withImageUrls(setup.items);
|
||||
return c.json({ ...setup, items: enrichedItems });
|
||||
});
|
||||
|
||||
// Short share URL redirect (no auth required — before SPA catch-all)
|
||||
app.get("/s/:token", async (c) => {
|
||||
const db = c.get("db");
|
||||
const token = c.req.param("token");
|
||||
const result = await validateShareToken(db, token);
|
||||
if (!result) return c.redirect("/", 302);
|
||||
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
|
||||
});
|
||||
|
||||
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
||||
app.use("/api/*", async (c, next) => {
|
||||
// Skip auth routes — they handle their own auth
|
||||
@@ -178,6 +202,9 @@ app.use("/api/*", async (c, next) => {
|
||||
// Skip public tags endpoint (GET /api/tags)
|
||||
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
||||
return next();
|
||||
// Skip shared setup access (GET /api/shared/:token)
|
||||
if (c.req.path.startsWith("/api/shared/") && c.req.method === "GET")
|
||||
return next();
|
||||
// Skip public discovery endpoints (GET /api/discovery/*)
|
||||
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
||||
return next();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createSetupSchema,
|
||||
createShareLinkSchema,
|
||||
syncSetupItemsSchema,
|
||||
updateClassificationSchema,
|
||||
updateSetupSchema,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
updateItemClassification,
|
||||
updateSetup,
|
||||
} from "../services/setup.service.ts";
|
||||
import {
|
||||
createShareLink,
|
||||
getShareLinks,
|
||||
revokeShareLink,
|
||||
} from "../services/share.service.ts";
|
||||
import { withImageUrls } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
@@ -125,4 +131,43 @@ app.delete("/:id/items/:itemId", async (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Share Links
|
||||
|
||||
app.post(
|
||||
"/:id/shares",
|
||||
zValidator("json", createShareLinkSchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const userId = c.get("userId")!;
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: data.expiresInDays,
|
||||
});
|
||||
if (!share) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(share, 201);
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/:id/shares", async (c) => {
|
||||
const db = c.get("db");
|
||||
const userId = c.get("userId")!;
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
if (!links) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(links);
|
||||
});
|
||||
|
||||
app.delete("/:id/shares/:shareId", async (c) => {
|
||||
const db = c.get("db");
|
||||
const userId = c.get("userId")!;
|
||||
const shareId = parseId(c.req.param("shareId"));
|
||||
if (!shareId) return c.json({ error: "Invalid share ID" }, 400);
|
||||
const share = await revokeShareLink(db, userId, shareId);
|
||||
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||
return c.json(share);
|
||||
});
|
||||
|
||||
export { app as setupRoutes };
|
||||
|
||||
@@ -117,6 +117,57 @@ export async function getSetupWithItems(
|
||||
return { ...setup, items: itemList };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setup with items by setup ID only (no user/visibility check).
|
||||
* Used for share-token-authorized access where the token already authorizes viewing.
|
||||
*/
|
||||
export async function getSetupWithItemsById(db: Db, setupId: number) {
|
||||
const [setup] = await db.select().from(setups).where(eq(setups.id, setupId));
|
||||
if (!setup) return null;
|
||||
|
||||
const itemList = await db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: sql<string>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
||||
ELSE ${items.name}
|
||||
END,
|
||||
${items.name}
|
||||
)`.as("name"),
|
||||
weightGrams: sql<number | null>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
||||
${items.weightGrams}
|
||||
)`.as("weight_grams"),
|
||||
priceCents: sql<number | null>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
||||
${items.priceCents}
|
||||
)`.as("price_cents"),
|
||||
quantity: items.quantity,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: sql<string | null>`COALESCE(
|
||||
${items.imageFilename},
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
||||
)`.as("image_filename"),
|
||||
globalItemId: items.globalItemId,
|
||||
purchasePriceCents: items.purchasePriceCents,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
classification: setupItems.classification,
|
||||
})
|
||||
.from(setupItems)
|
||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||
.where(eq(setupItems.setupId, setupId));
|
||||
|
||||
return { ...setup, items: itemList };
|
||||
}
|
||||
|
||||
export async function updateSetup(
|
||||
db: Db,
|
||||
userId: number,
|
||||
@@ -124,7 +175,7 @@ export async function updateSetup(
|
||||
data: UpdateSetup,
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select({ id: setups.id })
|
||||
.select({ id: setups.id, visibility: setups.visibility })
|
||||
.from(setups)
|
||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||
if (!existing) return null;
|
||||
@@ -143,6 +194,25 @@ export async function updateSetup(
|
||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
|
||||
.returning();
|
||||
|
||||
// Handle visibility transition side effects for share links
|
||||
if (
|
||||
data.visibility !== undefined &&
|
||||
data.visibility !== existing.visibility
|
||||
) {
|
||||
const { deactivateShareLinks, reactivateShareLinks } = await import(
|
||||
"./share.service.ts"
|
||||
);
|
||||
|
||||
if (data.visibility === "private" && existing.visibility !== "private") {
|
||||
await deactivateShareLinks(db, setupId);
|
||||
} else if (
|
||||
data.visibility !== "private" &&
|
||||
existing.visibility === "private"
|
||||
) {
|
||||
await reactivateShareLinks(db, setupId);
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
136
src/server/services/share.service.ts
Normal file
136
src/server/services/share.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, isNotNull, isNull, or } from "drizzle-orm";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { setups, shares } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
/**
|
||||
* Create a share link for a setup.
|
||||
* Returns null if the setup doesn't belong to the user.
|
||||
*/
|
||||
export async function createShareLink(
|
||||
db: Db,
|
||||
userId: number,
|
||||
setupId: number,
|
||||
options: { expiresInDays: number | null },
|
||||
) {
|
||||
// Verify ownership
|
||||
const [setup] = await db
|
||||
.select({ id: setups.id })
|
||||
.from(setups)
|
||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||
if (!setup) return null;
|
||||
|
||||
const token = randomBytes(16).toString("base64url");
|
||||
const expiresAt = options.expiresInDays
|
||||
? new Date(Date.now() + options.expiresInDays * 86400000)
|
||||
: null;
|
||||
|
||||
const [row] = await db
|
||||
.insert(shares)
|
||||
.values({
|
||||
setupId,
|
||||
token,
|
||||
permission: "read",
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all share links for a setup owned by the user.
|
||||
* Returns links ordered by createdAt desc.
|
||||
*/
|
||||
export async function getShareLinks(db: Db, userId: number, setupId: number) {
|
||||
// Verify ownership
|
||||
const [setup] = await db
|
||||
.select({ id: setups.id })
|
||||
.from(setups)
|
||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||
if (!setup) return null;
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(eq(shares.setupId, setupId))
|
||||
.orderBy(shares.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a share link. Returns null if the share doesn't belong to the user's setup.
|
||||
*/
|
||||
export async function revokeShareLink(db: Db, userId: number, shareId: number) {
|
||||
// Join shares with setups to verify ownership
|
||||
const [existing] = await db
|
||||
.select({ shareId: shares.id, setupId: shares.setupId })
|
||||
.from(shares)
|
||||
.innerJoin(setups, eq(shares.setupId, setups.id))
|
||||
.where(and(eq(shares.id, shareId), eq(setups.userId, userId)));
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const [row] = await db
|
||||
.update(shares)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(shares.id, shareId))
|
||||
.returning();
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a share token. Returns { setupId, permission } if valid, null otherwise.
|
||||
* Invalid = nonexistent, revoked, or expired.
|
||||
*/
|
||||
export async function validateShareToken(db: Db, token: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
setupId: shares.setupId,
|
||||
permission: shares.permission,
|
||||
expiresAt: shares.expiresAt,
|
||||
})
|
||||
.from(shares)
|
||||
.where(and(eq(shares.token, token), isNull(shares.revokedAt)));
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
// Check expiration
|
||||
if (row.expiresAt && row.expiresAt < new Date()) return null;
|
||||
|
||||
return { setupId: row.setupId, permission: row.permission };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate all active share links for a setup.
|
||||
* Called when visibility transitions to "private".
|
||||
*/
|
||||
export async function deactivateShareLinks(db: Db, setupId: number) {
|
||||
await db
|
||||
.update(shares)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(shares.setupId, setupId), isNull(shares.revokedAt)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate share links for a setup.
|
||||
* Called when visibility transitions from "private" back to "link" or "public".
|
||||
* Clears revokedAt on all non-expired shares for the setup.
|
||||
*/
|
||||
export async function reactivateShareLinks(db: Db, setupId: number) {
|
||||
// Per D-03: clear revokedAt on all non-expired shares for the setup.
|
||||
// This reactivates everything including manually revoked — acceptable UX
|
||||
// since user explicitly chose to re-enable sharing.
|
||||
await db
|
||||
.update(shares)
|
||||
.set({ revokedAt: null })
|
||||
.where(
|
||||
and(
|
||||
eq(shares.setupId, setupId),
|
||||
isNotNull(shares.revokedAt),
|
||||
or(isNull(shares.expiresAt), gt(shares.expiresAt, new Date())),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,12 @@ export const updateSetupSchema = z.object({
|
||||
visibility: z.enum(["private", "link", "public"]).optional(),
|
||||
});
|
||||
|
||||
export const createShareLinkSchema = z.object({
|
||||
expiresInDays: z
|
||||
.union([z.literal(7), z.literal(14), z.literal(30), z.null()])
|
||||
.default(14),
|
||||
});
|
||||
|
||||
export const syncSetupItemsSchema = z.object({
|
||||
itemIds: z.array(z.number().int().positive()),
|
||||
});
|
||||
|
||||
222
tests/services/share.service.test.ts
Normal file
222
tests/services/share.service.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "../../src/db/schema.ts";
|
||||
import {
|
||||
createSetup,
|
||||
updateSetup,
|
||||
} from "../../src/server/services/setup.service.ts";
|
||||
import {
|
||||
createShareLink,
|
||||
deactivateShareLinks,
|
||||
getShareLinks,
|
||||
reactivateShareLinks,
|
||||
revokeShareLink,
|
||||
validateShareToken,
|
||||
} from "../../src/server/services/share.service.ts";
|
||||
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||
|
||||
describe("Share Service", () => {
|
||||
let db: TestDb["db"];
|
||||
let userId: number;
|
||||
let setupId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testData = await createTestDb();
|
||||
db = testData.db;
|
||||
userId = testData.userId;
|
||||
|
||||
const setup = await createSetup(db, userId, {
|
||||
name: "Test Setup",
|
||||
visibility: "link",
|
||||
});
|
||||
setupId = setup.id;
|
||||
});
|
||||
|
||||
describe("createShareLink", () => {
|
||||
it("creates a share link with token and expiration", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 7,
|
||||
});
|
||||
expect(share).not.toBeNull();
|
||||
expect(share!.token).toBeTruthy();
|
||||
expect(share!.token.length).toBeGreaterThanOrEqual(20);
|
||||
expect(share!.setupId).toBe(setupId);
|
||||
expect(share!.permission).toBe("read");
|
||||
expect(share!.expiresAt).not.toBeNull();
|
||||
expect(share!.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a share link with no expiration when null", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: null,
|
||||
});
|
||||
expect(share).not.toBeNull();
|
||||
expect(share!.expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-owned setup", async () => {
|
||||
const otherUserId = await createSecondTestUser(db);
|
||||
const share = await createShareLink(db, otherUserId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
expect(share).toBeNull();
|
||||
});
|
||||
|
||||
it("generates unique URL-safe tokens", async () => {
|
||||
const share1 = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
const share2 = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
expect(share1!.token).not.toBe(share2!.token);
|
||||
// base64url should not contain +, /, or =
|
||||
expect(share1!.token).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShareLinks", () => {
|
||||
it("returns all shares for a setup owned by the user", async () => {
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
expect(links).not.toBeNull();
|
||||
expect(links!).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null for non-owned setup", async () => {
|
||||
const otherUserId = await createSecondTestUser(db);
|
||||
const links = await getShareLinks(db, otherUserId, setupId);
|
||||
expect(links).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeShareLink", () => {
|
||||
it("sets revokedAt on the share", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
const revoked = await revokeShareLink(db, userId, share!.id);
|
||||
expect(revoked).not.toBeNull();
|
||||
expect(revoked!.revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-owned share", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
const otherUserId = await createSecondTestUser(db);
|
||||
const result = await revokeShareLink(db, otherUserId, share!.id);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateShareToken", () => {
|
||||
it("returns setupId for valid token", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
const result = await validateShareToken(db, share!.token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.setupId).toBe(setupId);
|
||||
expect(result!.permission).toBe("read");
|
||||
});
|
||||
|
||||
it("returns null for expired token", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 1,
|
||||
});
|
||||
// Manually set expiresAt to the past
|
||||
await db
|
||||
.update(schema.shares)
|
||||
.set({ expiresAt: new Date(Date.now() - 86400000) })
|
||||
.where(eq(schema.shares.id, share!.id));
|
||||
|
||||
const result = await validateShareToken(db, share!.token);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for revoked token", async () => {
|
||||
const share = await createShareLink(db, userId, setupId, {
|
||||
expiresInDays: 14,
|
||||
});
|
||||
await revokeShareLink(db, userId, share!.id);
|
||||
const result = await validateShareToken(db, share!.token);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for nonexistent token", async () => {
|
||||
const result = await validateShareToken(db, "nonexistent-token");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateShareLinks", () => {
|
||||
it("sets revokedAt on all active links for a setup", async () => {
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||
|
||||
await deactivateShareLinks(db, setupId);
|
||||
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
for (const link of links!) {
|
||||
expect(link.revokedAt).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactivateShareLinks", () => {
|
||||
it("clears revokedAt on deactivated links", async () => {
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||
|
||||
await deactivateShareLinks(db, setupId);
|
||||
await reactivateShareLinks(db, setupId);
|
||||
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
for (const link of links!) {
|
||||
expect(link.revokedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility transition side effects", () => {
|
||||
it("deactivates links when visibility changes to private", async () => {
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||
|
||||
await updateSetup(db, userId, setupId, {
|
||||
name: "Test Setup",
|
||||
visibility: "private",
|
||||
});
|
||||
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
for (const link of links!) {
|
||||
expect(link.revokedAt).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("reactivates links when visibility changes from private to link", async () => {
|
||||
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||
|
||||
// Deactivate by going private
|
||||
await updateSetup(db, userId, setupId, {
|
||||
name: "Test Setup",
|
||||
visibility: "private",
|
||||
});
|
||||
|
||||
// Reactivate by going back to link
|
||||
await updateSetup(db, userId, setupId, {
|
||||
name: "Test Setup",
|
||||
visibility: "link",
|
||||
});
|
||||
|
||||
const links = await getShareLinks(db, userId, setupId);
|
||||
for (const link of links!) {
|
||||
expect(link.revokedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user