first commit
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import dns from "node:dns/promises";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const addDomainSchema = z.object({
|
||||
hostname: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
|
||||
});
|
||||
|
||||
const resolver = new dns.Resolver();
|
||||
// Use public authoritative DNS servers to check live global records
|
||||
try {
|
||||
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
|
||||
export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Get all domains for a restaurant
|
||||
app.get("/restaurants/:id/domains", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: domains, error } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to load domains" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.send({
|
||||
domains: domains || [],
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add a custom domain
|
||||
app.post("/restaurants/:id/domains", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const parsed = addDomainSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz alan adı formatı", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { hostname } = parsed.data;
|
||||
|
||||
// Check if domain is already registered
|
||||
const { data: existing } = await supabase
|
||||
.from("domains")
|
||||
.select("id, restaurant_id")
|
||||
.eq("hostname", hostname)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
if (existing.restaurant_id === restaurantId) {
|
||||
return reply.code(409).send({ message: "Bu alan adı zaten bu restorana eklenmiş." });
|
||||
}
|
||||
return reply.code(409).send({ message: "Bu alan adı başka bir hesap tarafından kullanılıyor." });
|
||||
}
|
||||
|
||||
const { data: newDomain, error } = await supabase
|
||||
.from("domains")
|
||||
.insert({
|
||||
restaurant_id: restaurantId,
|
||||
hostname,
|
||||
is_custom: true,
|
||||
status: "pending",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !newDomain) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to add domain" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.status(201).send({
|
||||
domain: newDomain,
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
host: hostname,
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// REAL DNS Verification (Checks real CNAME records across 8.8.8.8 and 1.1.1.1)
|
||||
app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: domain, error: domainError } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.single();
|
||||
|
||||
if (domainError || !domain) {
|
||||
return reply.code(404).send({ message: "domain not found" });
|
||||
}
|
||||
|
||||
const expectedTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
let isVerified = false;
|
||||
let dnsErrorDetails = "";
|
||||
|
||||
try {
|
||||
// Real DNS lookup for CNAME
|
||||
const cnames = await resolver.resolveCname(domain.hostname);
|
||||
req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check");
|
||||
|
||||
isVerified = cnames.some(
|
||||
(c) =>
|
||||
c.toLowerCase().includes(env.ROOT_DOMAIN.toLowerCase()) ||
|
||||
c.toLowerCase().includes(`cname.${env.ROOT_DOMAIN}`.toLowerCase()) ||
|
||||
c.toLowerCase().includes("menul.io"),
|
||||
);
|
||||
|
||||
if (!isVerified) {
|
||||
dnsErrorDetails = `Bulunan CNAME: ${cnames.join(", ")} (Beklenen: ${expectedTarget})`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
req.log.warn({ hostname: domain.hostname, err: err?.message || err }, "DNS CNAME lookup failed");
|
||||
if (err?.code === "ENOTFOUND" || err?.code === "ENODATA") {
|
||||
dnsErrorDetails = "DNS kaydı bulunamadı. Henüz CNAME yönlendirmesi yapılmamış veya DNS yayılımı (propagation) tamamlanmamış.";
|
||||
} else {
|
||||
dnsErrorDetails = err?.message || "DNS sorgusu başarısız oldu.";
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
const { data: updated } = await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "verified",
|
||||
verified_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", domainId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return reply.send({
|
||||
verified: true,
|
||||
domain: updated,
|
||||
message: `Tebrikler! ${domain.hostname} alan adının CNAME kaydı doğrulandı ve menünüze bağlandı. 🎉`,
|
||||
});
|
||||
}
|
||||
|
||||
// If not verified, set/keep status as pending or failed
|
||||
await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "pending",
|
||||
verified_at: null,
|
||||
})
|
||||
.eq("id", domainId);
|
||||
|
||||
return reply.send({
|
||||
verified: false,
|
||||
domain: { ...domain, status: "pending", verified_at: null },
|
||||
message: `Doğrulanamadı: ${dnsErrorDetails}\n\nLütfen alan adı yönetim panelinizden (Cloudflare, GoDaddy, Natro vb.) ${domain.hostname} için CNAME kaydını ${expectedTarget} adresine yönlendirin.`,
|
||||
});
|
||||
});
|
||||
|
||||
// Delete custom domain
|
||||
app.delete("/restaurants/:id/domains/:domainId", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("domains")
|
||||
.delete()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId);
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete domain" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user