263 lines
9.3 KiB
TypeScript
263 lines
9.3 KiB
TypeScript
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";
|
||
import { addDomainToWebApp } from "../lib/coolify.js";
|
||
import { createCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js";
|
||
|
||
const addDomainSchema = z.object({
|
||
hostname: z
|
||
.string()
|
||
.min(3)
|
||
.max(255)
|
||
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
|
||
});
|
||
|
||
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}`;
|
||
|
||
const enrichedDomains = await Promise.all(
|
||
(domains || []).map(async (domain) => {
|
||
if (domain.status === "verified") return domain;
|
||
|
||
try {
|
||
const cfHostname = await getCustomHostnameByHostname(domain.hostname);
|
||
if (cfHostname) {
|
||
const ownershipTxt = cfHostname.ownership_verification
|
||
? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value }
|
||
: null;
|
||
const sslTxt = (cfHostname.ssl?.validation_records ?? []).map((r) => ({
|
||
name: r.txt_name,
|
||
value: r.txt_value,
|
||
}));
|
||
|
||
return {
|
||
...domain,
|
||
instructions: {
|
||
cname: { type: "CNAME", host: domain.hostname, target: cnameTarget },
|
||
ownershipTxt,
|
||
sslTxt,
|
||
},
|
||
cloudflareStatus: {
|
||
hostnameStatus: cfHostname.status,
|
||
sslStatus: cfHostname.ssl?.status,
|
||
ownershipTxt,
|
||
sslTxt,
|
||
},
|
||
};
|
||
}
|
||
} catch (err) {
|
||
req.log.warn({ err, hostname: domain.hostname }, "Cloudflare check failed during GET domains");
|
||
}
|
||
|
||
return domain;
|
||
}),
|
||
);
|
||
|
||
return reply.send({
|
||
domains: enrichedDomains,
|
||
cnameTarget,
|
||
instructions: {
|
||
type: "CNAME",
|
||
target: cnameTarget,
|
||
},
|
||
});
|
||
});
|
||
|
||
// Add a custom domain — registers it with Cloudflare (SSL for SaaS) and
|
||
// the origin (Coolify) so it's routable/certifiable, not just recorded.
|
||
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;
|
||
|
||
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}`;
|
||
const warnings: string[] = [];
|
||
let dcvTxt: { name: string; value: string } | null = null;
|
||
let sslTxt: { name: string; value: string }[] = [];
|
||
|
||
try {
|
||
const hostnameRecord = await createCustomHostname(hostname);
|
||
if (hostnameRecord.ownership_verification) {
|
||
dcvTxt = { name: hostnameRecord.ownership_verification.name, value: hostnameRecord.ownership_verification.value };
|
||
}
|
||
sslTxt = (hostnameRecord.ssl.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value }));
|
||
} catch (err) {
|
||
req.log.warn({ err, hostname }, "Cloudflare custom hostname creation failed");
|
||
warnings.push("Cloudflare kaydı oluşturulamadı, manuel kurulum gerekebilir.");
|
||
}
|
||
|
||
try {
|
||
await addDomainToWebApp(hostname);
|
||
} catch (err) {
|
||
req.log.warn({ err, hostname }, "Coolify domain add failed");
|
||
warnings.push("Sunucu routing'i otomatik eklenemedi, manuel kurulum gerekebilir.");
|
||
}
|
||
|
||
return reply.status(201).send({
|
||
domain: newDomain,
|
||
cnameTarget,
|
||
instructions: {
|
||
cname: { type: "CNAME", host: hostname, target: cnameTarget },
|
||
ownershipTxt: dcvTxt,
|
||
sslTxt,
|
||
},
|
||
warnings,
|
||
});
|
||
});
|
||
|
||
// Verification checks Cloudflare's own Custom Hostname SSL status — a
|
||
// plain DNS lookup can't see a proxied domain's real CNAME target, so
|
||
// that approach always failed for hostnames set up correctly (see
|
||
// docs/PROGRESS.md, 2026-08-20).
|
||
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" });
|
||
}
|
||
|
||
let cfHostname: Awaited<ReturnType<typeof getCustomHostnameByHostname>> = null;
|
||
try {
|
||
cfHostname = await getCustomHostnameByHostname(domain.hostname);
|
||
} catch (err) {
|
||
req.log.warn({ err, hostname: domain.hostname }, "Cloudflare status check failed");
|
||
}
|
||
|
||
const isVerified = cfHostname?.status === "active" && cfHostname?.ssl?.status === "active";
|
||
|
||
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ı doğrulandı ve menünüze bağlandı. 🎉`,
|
||
});
|
||
}
|
||
|
||
// Never downgrade an already-verified domain on an inconclusive re-check.
|
||
if (domain.status !== "verified") {
|
||
await supabase.from("domains").update({ status: "pending", verified_at: null }).eq("id", domainId);
|
||
}
|
||
|
||
return reply.send({
|
||
verified: domain.status === "verified",
|
||
domain: domain.status === "verified" ? domain : { ...domain, status: "pending", verified_at: null },
|
||
cloudflareStatus: cfHostname
|
||
? {
|
||
hostnameStatus: cfHostname.status,
|
||
sslStatus: cfHostname.ssl?.status,
|
||
ownershipTxt: cfHostname.ownership_verification
|
||
? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value }
|
||
: null,
|
||
sslTxt: (cfHostname.ssl?.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })),
|
||
}
|
||
: null,
|
||
message: cfHostname
|
||
? `Henüz aktif değil (hostname: ${cfHostname.status}, sertifika: ${cfHostname.ssl?.status}). DNS kayıtlarının işlenmesi birkaç dakika sürebilir.`
|
||
: `${domain.hostname} Cloudflare'de bulunamadı. Domain eklerken bir hata oluşmuş olabilir, tekrar eklemeyi deneyin.`,
|
||
});
|
||
});
|
||
|
||
// 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 });
|
||
});
|
||
};
|