mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Implement RFID tag dump PWA with REST API, auth, and CI (#1)
Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag dumps, multi-user Auth.js (credentials + optional OIDC), personal API tokens in Settings, versioned /api/v1 for UI and future CLI use, MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and GitHub Actions CI. Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
co-authored by
Cursor Agent
parent
5029945cc2
commit
8500d2f673
@@ -0,0 +1,62 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db/client";
|
||||
import { apiTokens, users, type User } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
|
||||
import { jsonError, jsonOk } from "@/lib/api/errors";
|
||||
|
||||
export type AuthUser = Pick<User, "id" | "email" | "name">;
|
||||
|
||||
export { jsonError, jsonOk };
|
||||
|
||||
export async function requireApiUser(
|
||||
request: NextRequest
|
||||
): Promise<AuthUser | Response> {
|
||||
const header = request.headers.get("authorization");
|
||||
if (header?.toLowerCase().startsWith("bearer ")) {
|
||||
const token = header.slice(7).trim();
|
||||
if (!isApiTokenFormat(token)) {
|
||||
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
|
||||
}
|
||||
const db = getDb();
|
||||
const tokenHash = hashToken(token);
|
||||
const row = db
|
||||
.select({
|
||||
tokenId: apiTokens.id,
|
||||
expiresAt: apiTokens.expiresAt,
|
||||
userId: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
})
|
||||
.from(apiTokens)
|
||||
.innerJoin(users, eq(apiTokens.userId, users.id))
|
||||
.where(eq(apiTokens.tokenHash, tokenHash))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
|
||||
}
|
||||
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
||||
return jsonError(401, "UNAUTHORIZED", "API token expired");
|
||||
}
|
||||
|
||||
db.update(apiTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiTokens.id, row.tokenId))
|
||||
.run();
|
||||
|
||||
return { id: row.userId, email: row.email, name: row.name };
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return jsonError(401, "UNAUTHORIZED", "Authentication required");
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? "",
|
||||
name: session.user.name ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function jsonError(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string
|
||||
): Response {
|
||||
return Response.json({ error: { code, message } }, { status });
|
||||
}
|
||||
|
||||
export function jsonOk<T>(data: T, status = 200): Response {
|
||||
return Response.json(data, { status });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
/**
|
||||
* Edge-safe auth config (no Node DB imports).
|
||||
* Used by middleware; full providers live in auth.ts.
|
||||
*/
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: { strategy: "jwt" },
|
||||
providers: [],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user?.id) {
|
||||
token.sub = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.sub) {
|
||||
session.user.id = token.sub;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
authorized({ auth, request }) {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/api/auth") ||
|
||||
pathname === "/manifest.webmanifest" ||
|
||||
pathname === "/sw.js" ||
|
||||
pathname.startsWith("/icons")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// API v1 uses its own Bearer/session check in handlers
|
||||
if (pathname.startsWith("/api/v1")) {
|
||||
return true;
|
||||
}
|
||||
return !!auth?.user;
|
||||
},
|
||||
},
|
||||
trustHost: true,
|
||||
} satisfies NextAuthConfig;
|
||||
@@ -0,0 +1,92 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import { compare } from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db/client";
|
||||
import { users } from "@/db/schema";
|
||||
import { authConfig } from "@/lib/auth/auth.config";
|
||||
|
||||
function buildProviders(): Provider[] {
|
||||
const providers: Provider[] = [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const email = credentials?.email?.toString().trim().toLowerCase();
|
||||
const password = credentials?.password?.toString() ?? "";
|
||||
if (!email || !password) return null;
|
||||
|
||||
const db = getDb();
|
||||
const user = db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (!user?.passwordHash) return null;
|
||||
|
||||
const ok = await compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const issuer = process.env.AUTH_OIDC_ISSUER;
|
||||
const clientId = process.env.AUTH_OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
|
||||
if (issuer && clientId && clientSecret) {
|
||||
providers.push({
|
||||
id: "oidc",
|
||||
name: process.env.AUTH_OIDC_NAME || "SSO",
|
||||
type: "oidc",
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
} as Provider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: buildProviders(),
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "credentials") return true;
|
||||
if (!user.email) return false;
|
||||
|
||||
const db = getDb();
|
||||
const email = user.email.toLowerCase();
|
||||
let existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (!existing) {
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(users)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
name: user.name || email.split("@")[0],
|
||||
passwordHash: null,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
existing = db.select().from(users).where(eq(users.id, id)).get();
|
||||
}
|
||||
if (existing) {
|
||||
user.id = existing.id;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function isOidcConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.AUTH_OIDC_ISSUER &&
|
||||
process.env.AUTH_OIDC_CLIENT_ID &&
|
||||
process.env.AUTH_OIDC_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const TOKEN_PREFIX = "rfid_";
|
||||
|
||||
export function generateApiToken(): {
|
||||
token: string;
|
||||
tokenHash: string;
|
||||
prefix: string;
|
||||
} {
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
const token = `${TOKEN_PREFIX}${secret}`;
|
||||
return {
|
||||
token,
|
||||
tokenHash: hashToken(token),
|
||||
prefix: token.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function isApiTokenFormat(token: string): boolean {
|
||||
return token.startsWith(TOKEN_PREFIX) && token.length > TOKEN_PREFIX.length + 16;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MifareClassicDump, NtagDump, LfDump } from "@/lib/validation/rfid";
|
||||
|
||||
export function isMifareClassicDump(data: unknown): data is MifareClassicDump {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"sectors" in data &&
|
||||
Array.isArray((data as MifareClassicDump).sectors)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNtagDump(data: unknown): data is NtagDump {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"pages" in data &&
|
||||
Array.isArray((data as NtagDump).pages)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLfDump(data: unknown): data is LfDump {
|
||||
return (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"uidBytes" in data &&
|
||||
typeof (data as LfDump).uidBytes === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
isLfDump,
|
||||
isMifareClassicDump,
|
||||
isNtagDump,
|
||||
} from "@/lib/rfid/dump-types";
|
||||
import type { ExportFormat } from "@/lib/rfid/types";
|
||||
|
||||
export type { CanonicalTag, ExportFormat } from "@/lib/rfid/types";
|
||||
|
||||
export function exportTag(
|
||||
tag: {
|
||||
id: string;
|
||||
label: string;
|
||||
frequency: string;
|
||||
protocol: string;
|
||||
uid: string;
|
||||
dumpData: unknown;
|
||||
keys: unknown;
|
||||
notes: string | null;
|
||||
},
|
||||
format: ExportFormat
|
||||
): { body: string; contentType: string; filename: string } {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
frequency: tag.frequency,
|
||||
protocol: tag.protocol,
|
||||
uid: tag.uid,
|
||||
dumpData: tag.dumpData,
|
||||
keys: tag.keys,
|
||||
notes: tag.notes,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
filename: `${safeName(tag.label)}.json`,
|
||||
};
|
||||
case "proxmark":
|
||||
return {
|
||||
body: exportProxmark(tag),
|
||||
contentType: "application/json",
|
||||
filename: `${safeName(tag.label)}.pm3.json`,
|
||||
};
|
||||
case "mct":
|
||||
return {
|
||||
body: exportMct(tag),
|
||||
contentType: "text/plain",
|
||||
filename: `${safeName(tag.label)}.mct`,
|
||||
};
|
||||
case "hex":
|
||||
return {
|
||||
body: exportHex(tag),
|
||||
contentType: "text/plain",
|
||||
filename: `${safeName(tag.label)}.hex`,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeName(label: string): string {
|
||||
return label.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 64) || "tag";
|
||||
}
|
||||
|
||||
function exportProxmark(tag: {
|
||||
uid: string;
|
||||
protocol: string;
|
||||
dumpData: unknown;
|
||||
keys: unknown;
|
||||
}): string {
|
||||
const blocks: string[] = [];
|
||||
if (isMifareClassicDump(tag.dumpData)) {
|
||||
for (const sector of [...tag.dumpData.sectors].sort(
|
||||
(a, b) => a.index - b.index
|
||||
)) {
|
||||
for (const block of sector.blocks) {
|
||||
blocks.push(block.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
Created: new Date().toISOString(),
|
||||
FileType: "mfcard",
|
||||
Card: {
|
||||
UID: tag.uid.toUpperCase(),
|
||||
ATQA: "",
|
||||
SAK: "",
|
||||
},
|
||||
blocks,
|
||||
SectorKeys: tag.keys ?? {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function exportMct(tag: {
|
||||
uid: string;
|
||||
dumpData: unknown;
|
||||
}): string {
|
||||
const lines: string[] = [`+UID: ${tag.uid.toUpperCase()}`];
|
||||
if (isMifareClassicDump(tag.dumpData)) {
|
||||
for (const sector of [...tag.dumpData.sectors].sort(
|
||||
(a, b) => a.index - b.index
|
||||
)) {
|
||||
lines.push(`+Sector: ${sector.index}`);
|
||||
for (const block of sector.blocks) {
|
||||
lines.push(block.toUpperCase());
|
||||
}
|
||||
}
|
||||
} else if (isNtagDump(tag.dumpData)) {
|
||||
for (const page of [...tag.dumpData.pages].sort(
|
||||
(a, b) => a.index - b.index
|
||||
)) {
|
||||
lines.push(`+Page: ${page.index}`);
|
||||
lines.push(page.data.toUpperCase());
|
||||
}
|
||||
} else if (isLfDump(tag.dumpData)) {
|
||||
lines.push(`+LF: ${tag.dumpData.format}`);
|
||||
lines.push(tag.dumpData.uidBytes.toUpperCase());
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function exportHex(tag: { dumpData: unknown; uid: string }): string {
|
||||
const lines: string[] = [`# UID ${tag.uid.toUpperCase()}`];
|
||||
if (isMifareClassicDump(tag.dumpData)) {
|
||||
for (const sector of [...tag.dumpData.sectors].sort(
|
||||
(a, b) => a.index - b.index
|
||||
)) {
|
||||
sector.blocks.forEach((block, i) => {
|
||||
lines.push(`S${sector.index}B${i} ${block.toUpperCase()}`);
|
||||
});
|
||||
}
|
||||
} else if (isNtagDump(tag.dumpData)) {
|
||||
for (const page of [...tag.dumpData.pages].sort(
|
||||
(a, b) => a.index - b.index
|
||||
)) {
|
||||
lines.push(`P${page.index} ${page.data.toUpperCase()}`);
|
||||
}
|
||||
} else if (isLfDump(tag.dumpData)) {
|
||||
lines.push(tag.dumpData.uidBytes.toUpperCase());
|
||||
} else {
|
||||
lines.push(JSON.stringify(tag.dumpData));
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { CanonicalTag } from "@/lib/rfid/types";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
import type { Protocol } from "@/lib/rfid/protocols";
|
||||
import type { MifareClassicDump } from "@/lib/validation/rfid";
|
||||
|
||||
export function parseImport(
|
||||
content: string,
|
||||
filenameHint?: string
|
||||
): CanonicalTag {
|
||||
const trimmed = content.trim();
|
||||
const lower = (filenameHint ?? "").toLowerCase();
|
||||
|
||||
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
|
||||
return parseJsonDump(trimmed);
|
||||
}
|
||||
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
|
||||
return parseMct(trimmed);
|
||||
}
|
||||
// Heuristic: MCT-like or hex lines
|
||||
if (trimmed.includes("+Sector:") || trimmed.includes("+UID:")) {
|
||||
return parseMct(trimmed);
|
||||
}
|
||||
throw new Error(
|
||||
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonDump(content: string): CanonicalTag {
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
// Proxmark-style
|
||||
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
|
||||
return parseProxmarkJson(data);
|
||||
}
|
||||
|
||||
// Canonical
|
||||
if (data.dumpData && data.uid && data.protocol) {
|
||||
return {
|
||||
label: typeof data.label === "string" ? data.label : undefined,
|
||||
frequency: (data.frequency as "LF" | "HF") ?? "HF",
|
||||
protocol: data.protocol as Protocol,
|
||||
uid: normalizeUid(String(data.uid)),
|
||||
dumpData: data.dumpData,
|
||||
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
|
||||
notes: typeof data.notes === "string" ? data.notes : null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
|
||||
}
|
||||
|
||||
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
|
||||
const card = (data.Card as { UID?: string } | undefined) ?? {};
|
||||
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
|
||||
const blocks = (data.blocks as string[]) ?? [];
|
||||
const dumpData = blocksToClassicDump(blocks);
|
||||
return {
|
||||
frequency: "HF",
|
||||
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||
uid,
|
||||
dumpData,
|
||||
keys: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
||||
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
|
||||
const size = normalized.length > 64 ? "4K" : "1K";
|
||||
const sectors: MifareClassicDump["sectors"] = [];
|
||||
let offset = 0;
|
||||
let sectorIndex = 0;
|
||||
while (offset < normalized.length) {
|
||||
const blocksPerSector = sectorIndex < 32 ? 4 : 16;
|
||||
const slice = normalized.slice(offset, offset + blocksPerSector);
|
||||
if (slice.length === 0) break;
|
||||
sectors.push({ index: sectorIndex, blocks: slice });
|
||||
offset += blocksPerSector;
|
||||
sectorIndex += 1;
|
||||
}
|
||||
return { size, sectors };
|
||||
}
|
||||
|
||||
export function parseMct(content: string): CanonicalTag {
|
||||
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
let uid = "";
|
||||
const sectors: MifareClassicDump["sectors"] = [];
|
||||
let current: { index: number; blocks: string[] } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+UID:")) {
|
||||
uid = normalizeUid(line.slice(5).trim());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+Sector:")) {
|
||||
if (current) sectors.push(current);
|
||||
current = {
|
||||
index: Number.parseInt(line.slice(8).trim(), 10),
|
||||
blocks: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (/^[0-9A-Fa-f]+$/.test(line) && current) {
|
||||
current.blocks.push(line.toUpperCase());
|
||||
}
|
||||
}
|
||||
if (current) sectors.push(current);
|
||||
|
||||
if (!uid) {
|
||||
// try first block of sector 0
|
||||
const b0 = sectors.find((s) => s.index === 0)?.blocks[0];
|
||||
if (b0 && b0.length >= 8) {
|
||||
uid = b0.slice(0, 8);
|
||||
}
|
||||
}
|
||||
if (!uid) throw new Error("MCT dump missing UID");
|
||||
|
||||
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
|
||||
const size = totalBlocks > 64 ? "4K" : "1K";
|
||||
|
||||
return {
|
||||
frequency: "HF",
|
||||
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||
uid,
|
||||
dumpData: { size, sectors },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const FREQUENCIES = ["LF", "HF"] as const;
|
||||
export type Frequency = (typeof FREQUENCIES)[number];
|
||||
|
||||
export const PROTOCOLS = [
|
||||
"EM4100",
|
||||
"HID_PROX",
|
||||
"MIFARE_CLASSIC_1K",
|
||||
"MIFARE_CLASSIC_4K",
|
||||
"NTAG213",
|
||||
"NTAG215",
|
||||
"NTAG216",
|
||||
"MIFARE_UL",
|
||||
"ISO15693",
|
||||
"UNKNOWN",
|
||||
] as const;
|
||||
export type Protocol = (typeof PROTOCOLS)[number];
|
||||
|
||||
export const LF_PROTOCOLS: Protocol[] = ["EM4100", "HID_PROX"];
|
||||
export const HF_PROTOCOLS: Protocol[] = [
|
||||
"MIFARE_CLASSIC_1K",
|
||||
"MIFARE_CLASSIC_4K",
|
||||
"NTAG213",
|
||||
"NTAG215",
|
||||
"NTAG216",
|
||||
"MIFARE_UL",
|
||||
"ISO15693",
|
||||
"UNKNOWN",
|
||||
];
|
||||
|
||||
export function defaultFrequencyForProtocol(protocol: Protocol): Frequency {
|
||||
return LF_PROTOCOLS.includes(protocol) ? "LF" : "HF";
|
||||
}
|
||||
|
||||
export function protocolLabel(protocol: Protocol): string {
|
||||
return protocol.replaceAll("_", " ");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TagKeys } from "@/lib/validation/rfid";
|
||||
import type { Protocol } from "@/lib/rfid/protocols";
|
||||
|
||||
export type CanonicalTag = {
|
||||
label?: string;
|
||||
frequency: "LF" | "HF";
|
||||
protocol: Protocol;
|
||||
uid: string;
|
||||
dumpData: unknown;
|
||||
keys?: TagKeys;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ExportFormat = "json" | "proxmark" | "mct" | "hex";
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Normalize RFID UIDs to uppercase hex without separators.
|
||||
* Accepts common forms: "04:A1:B2:C3", "04 A1 B2 C3", "04a1b2c3".
|
||||
*/
|
||||
export function normalizeUid(input: string): string {
|
||||
const cleaned = input.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
|
||||
if (cleaned.length < 4 || cleaned.length % 2 !== 0) {
|
||||
throw new Error(
|
||||
`Invalid UID "${input}": expected even-length hex of at least 2 bytes`
|
||||
);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Display form with colon separators: 04:A1:B2:C3 */
|
||||
export function formatUid(uid: string): string {
|
||||
const normalized = normalizeUid(uid);
|
||||
return normalized.match(/.{1,2}/g)?.join(":") ?? normalized;
|
||||
}
|
||||
|
||||
export function tryNormalizeUid(input: string): string | null {
|
||||
try {
|
||||
return normalizeUid(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { FREQUENCIES, PROTOCOLS } from "@/lib/rfid/protocols";
|
||||
|
||||
export const mifareClassicDumpSchema = z.object({
|
||||
size: z.enum(["1K", "4K"]),
|
||||
sectors: z.array(
|
||||
z.object({
|
||||
index: z.number().int().min(0),
|
||||
blocks: z.array(z.string().regex(/^[0-9A-Fa-f]+$/)).min(1),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const ntagDumpSchema = z.object({
|
||||
pages: z.array(
|
||||
z.object({
|
||||
index: z.number().int().min(0),
|
||||
data: z.string().regex(/^[0-9A-Fa-f]+$/),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const lfDumpSchema = z.object({
|
||||
uidBytes: z.string().regex(/^[0-9A-Fa-f]+$/),
|
||||
format: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dumpDataSchema = z.union([
|
||||
mifareClassicDumpSchema,
|
||||
ntagDumpSchema,
|
||||
lfDumpSchema,
|
||||
z.record(z.string(), z.unknown()),
|
||||
]);
|
||||
|
||||
export const keysSchema = z
|
||||
.object({
|
||||
A: z.array(z.string()).optional(),
|
||||
B: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
export const siteCreateSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
code: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.regex(/^[A-Za-z0-9_-]+$/, "Code must be alphanumeric"),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const siteUpdateSchema = siteCreateSchema.partial();
|
||||
|
||||
export const tagCreateSchema = z.object({
|
||||
siteId: z.string().uuid(),
|
||||
label: z.string().min(1).max(200),
|
||||
frequency: z.enum(FREQUENCIES),
|
||||
protocol: z.enum(PROTOCOLS),
|
||||
uid: z.string().min(1),
|
||||
dumpData: dumpDataSchema,
|
||||
keys: keysSchema,
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
lastWrittenAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
export const tagUpdateSchema = tagCreateSchema.partial().omit({ siteId: true }).extend({
|
||||
siteId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const tagUpsertSchema = tagCreateSchema;
|
||||
|
||||
export const tokenCreateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
export const exportFormatSchema = z.enum(["json", "proxmark", "mct", "hex"]);
|
||||
|
||||
export type MifareClassicDump = z.infer<typeof mifareClassicDumpSchema>;
|
||||
export type NtagDump = z.infer<typeof ntagDumpSchema>;
|
||||
export type LfDump = z.infer<typeof lfDumpSchema>;
|
||||
export type DumpData = z.infer<typeof dumpDataSchema>;
|
||||
export type TagKeys = z.infer<typeof keysSchema>;
|
||||
Reference in New Issue
Block a user