ソースを参照

dev: automated commit - 2026-03-08 11:46:32

Mariano Z. 2 週間 前
コミット
a093f35a31

+ 74 - 46
frontend/src/components/AddRecordDialog.vue

@@ -26,6 +26,7 @@ import { toast } from 'vue-sonner'
 import { api } from '@/api/client'
 import type { DnsRecord, Zone } from '@/api/types'
 import { ref, watch, onMounted } from 'vue'
+import { Locate, Loader2 } from 'lucide-vue-next'
 import SpinnerLoader from '@/components/SpinnerLoader.vue'
 
 const props = defineProps<{
@@ -39,13 +40,26 @@ const emit = defineEmits<{
 }>()
 
 const submitting = ref(false)
+const fetchingIp = ref(false)
 const zones = ref<Zone[]>([])
 const isEdit = () => !!props.record
 
+const useCurrentIp = async () => {
+  fetchingIp.value = true
+  try {
+    const res = await api.get<{ ip: string }>('/api/ip')
+    setFieldValue('content', res.ip)
+  } catch {
+    toast.error('Failed to fetch current IP')
+  } finally {
+    fetchingIp.value = false
+  }
+}
+
 const schema = toTypedSchema(
   z.object({
     zone_id: z.number({ required_error: 'Zone is required' }).min(1, 'Zone is required'),
-    cf_record_id: z.string().min(1, 'Cloudflare Record ID is required'),
+    cf_record_id: z.string().optional(),
     name: z.string().min(1, 'Name is required'),
     type: z.string().min(1, 'Type is required'),
     content: z.string().min(1, 'Content is required'),
@@ -54,7 +68,7 @@ const schema = toTypedSchema(
   }),
 )
 
-const { handleSubmit, resetForm, setValues } = useForm({
+const { handleSubmit, resetForm, setValues, setFieldValue } = useForm({
   validationSchema: schema,
   initialValues: {
     type: 'A',
@@ -79,6 +93,9 @@ watch(
       })
     } else {
       resetForm()
+      if (zones.value.length === 1) {
+        setFieldValue('zone_id', zones.value[0].id)
+      }
     }
   },
   { flush: 'post' },
@@ -128,53 +145,42 @@ const onSubmit = handleSubmit(async (values) => {
         </DialogDescription>
       </DialogHeader>
       <form @submit="onSubmit" class="space-y-4">
-        <FormField name="zone_id" v-slot="{ componentField }">
+        <FormField v-if="isEdit()" name="cf_record_id" v-slot="{ field }">
           <FormItem>
-            <FormLabel>Zone</FormLabel>
-            <Select v-bind="componentField" :disabled="isEdit()">
-              <FormControl>
-                <SelectTrigger>
-                  <SelectValue placeholder="Select a zone" />
-                </SelectTrigger>
-              </FormControl>
-              <SelectContent>
-                <SelectItem v-for="zone in zones" :key="zone.id" :value="zone.id">
-                  {{ zone.name }}
-                </SelectItem>
-              </SelectContent>
-            </Select>
-            <FormMessage />
-          </FormItem>
-        </FormField>
-
-        <FormField name="cf_record_id" v-slot="{ field }">
-          <FormItem>
-            <FormLabel>Cloudflare Record ID</FormLabel>
+            <FormLabel class="text-muted-foreground text-xs">Cloudflare Record ID</FormLabel>
             <FormControl>
-              <Input v-bind="field" placeholder="CF record ID" :disabled="isEdit()" />
+              <Input v-bind="field" disabled class="font-mono text-xs text-muted-foreground" />
             </FormControl>
-            <FormMessage />
           </FormItem>
         </FormField>
 
-        <FormField name="name" v-slot="{ field }">
-          <FormItem>
-            <FormLabel>Name</FormLabel>
-            <FormControl>
-              <Input v-bind="field" placeholder="subdomain.example.com" />
-            </FormControl>
-            <FormMessage />
-          </FormItem>
-        </FormField>
+        <div class="grid grid-cols-[1fr_auto] gap-4">
+          <FormField name="zone_id" v-slot="{ componentField }">
+            <FormItem>
+              <FormLabel>Zone</FormLabel>
+              <Select v-bind="componentField" :disabled="isEdit()">
+                <FormControl>
+                  <SelectTrigger>
+                    <SelectValue placeholder="Select a zone" />
+                  </SelectTrigger>
+                </FormControl>
+                <SelectContent>
+                  <SelectItem v-for="zone in zones" :key="zone.id" :value="zone.id">
+                    {{ zone.name }}
+                  </SelectItem>
+                </SelectContent>
+              </Select>
+              <FormMessage />
+            </FormItem>
+          </FormField>
 
-        <div class="grid grid-cols-2 gap-4">
           <FormField name="type" v-slot="{ componentField }">
             <FormItem>
               <FormLabel>Type</FormLabel>
               <Select v-bind="componentField">
                 <FormControl>
-                  <SelectTrigger>
-                    <SelectValue placeholder="Record type" />
+                  <SelectTrigger class="w-24">
+                    <SelectValue placeholder="Type" />
                   </SelectTrigger>
                 </FormControl>
                 <SelectContent>
@@ -186,17 +192,39 @@ const onSubmit = handleSubmit(async (values) => {
               <FormMessage />
             </FormItem>
           </FormField>
+        </div>
 
-          <FormField name="content" v-slot="{ field }">
-            <FormItem>
+        <FormField name="name" v-slot="{ field }">
+          <FormItem>
+            <FormLabel>Name</FormLabel>
+            <FormControl>
+              <Input v-bind="field" placeholder="subdomain.example.com" />
+            </FormControl>
+            <FormMessage />
+          </FormItem>
+        </FormField>
+
+        <FormField name="content" v-slot="{ field }">
+          <FormItem>
+            <div class="flex items-center justify-between">
               <FormLabel>Content</FormLabel>
-              <FormControl>
-                <Input v-bind="field" placeholder="IP or target" />
-              </FormControl>
-              <FormMessage />
-            </FormItem>
-          </FormField>
-        </div>
+              <button
+                type="button"
+                class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
+                :disabled="fetchingIp"
+                @click="useCurrentIp"
+              >
+                <Loader2 v-if="fetchingIp" class="h-3 w-3 animate-spin" />
+                <Locate v-else class="h-3 w-3" />
+                {{ fetchingIp ? 'Fetching…' : 'Use current IP' }}
+              </button>
+            </div>
+            <FormControl>
+              <Input v-bind="field" placeholder="IP or target" />
+            </FormControl>
+            <FormMessage />
+          </FormItem>
+        </FormField>
 
         <div class="flex items-center gap-6">
           <FormField name="proxied" v-slot="{ value, handleChange }">

+ 31 - 2
internal/cloudflare/client.go

@@ -52,7 +52,7 @@ func ListDNSRecords(ctx context.Context, apiKey, zoneID string) ([]DNSRecord, er
 	return records, nil
 }
 
-type updatePayload struct {
+type dnsPayload struct {
 	Type    string `json:"type"`
 	Name    string `json:"name"`
 	Content string `json:"content"`
@@ -60,10 +60,39 @@ type updatePayload struct {
 	TTL     int    `json:"ttl"`
 }
 
+func CreateDNSRecord(ctx context.Context, apiKey, zoneID string, rec DNSRecord) (DNSRecord, error) {
+	url := fmt.Sprintf("%s/zones/%s/dns_records", baseURL, zoneID)
+
+	payload := dnsPayload{
+		Type:    rec.Type,
+		Name:    rec.Name,
+		Content: rec.Content,
+		Proxied: rec.Proxied,
+		TTL:     1, // auto
+	}
+
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return DNSRecord{}, fmt.Errorf("marshal payload: %w", err)
+	}
+
+	body, err := doRequest(ctx, http.MethodPost, url, apiKey, bytes.NewReader(data))
+	if err != nil {
+		return DNSRecord{}, err
+	}
+
+	var created DNSRecord
+	if err := json.Unmarshal(body, &created); err != nil {
+		return DNSRecord{}, fmt.Errorf("unmarshal created record: %w", err)
+	}
+
+	return created, nil
+}
+
 func UpdateDNSRecord(ctx context.Context, apiKey, zoneID, recordID string, rec DNSRecord) error {
 	url := fmt.Sprintf("%s/zones/%s/dns_records/%s", baseURL, zoneID, recordID)
 
-	payload := updatePayload{
+	payload := dnsPayload{
 		Type:    rec.Type,
 		Name:    rec.Name,
 		Content: rec.Content,

+ 24 - 4
internal/handler/record.go

@@ -104,16 +104,36 @@ func (h *RecordHandler) Create(c echo.Context) error {
 	if err := c.Bind(&req); err != nil {
 		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
 	}
-	if req.ZoneID == 0 || req.CfRecordID == "" || req.Name == "" || req.Content == "" {
-		return c.JSON(http.StatusBadRequest, map[string]string{"error": "zone_id, cf_record_id, name, and content required"})
+	if req.ZoneID == 0 || req.Name == "" || req.Content == "" {
+		return c.JSON(http.StatusBadRequest, map[string]string{"error": "zone_id, name, and content required"})
 	}
 	if req.Type == "" {
 		req.Type = "A"
 	}
 
-	record, err := h.q.CreateRecord(c.Request().Context(), queries.CreateRecordParams{
+	ctx := c.Request().Context()
+
+	zone, err := h.q.GetZone(ctx, req.ZoneID)
+	if errors.Is(err, sql.ErrNoRows) {
+		return c.JSON(http.StatusBadRequest, map[string]string{"error": "zone not found"})
+	}
+	if err != nil {
+		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get zone"})
+	}
+
+	cfRecord, err := cf.CreateDNSRecord(ctx, zone.ApiKey, zone.ZoneID, cf.DNSRecord{
+		Type:    req.Type,
+		Name:    req.Name,
+		Content: req.Content,
+		Proxied: req.Proxied,
+	})
+	if err != nil {
+		return c.JSON(http.StatusBadGateway, map[string]string{"error": "failed to create record on Cloudflare: " + err.Error()})
+	}
+
+	record, err := h.q.CreateRecord(ctx, queries.CreateRecordParams{
 		ZoneID:     req.ZoneID,
-		CfRecordID: req.CfRecordID,
+		CfRecordID: cfRecord.ID,
 		Name:       req.Name,
 		Type:       req.Type,
 		Content:    req.Content,