1. /json client IP (codex #1, Medium): HandleJson now validates
c.ClientIP() and returns 400 on a malformed value instead of letting
Query fail into a 500. HandleIpInfo gained a selfLookup flag so a valid
but DB-absent self-IP preserves the 9d07639 graceful 200 {"ip": ip}
default (private/loopback addresses are never in a public GeoIP DB),
while the explicit /:ip route still returns 404 on not-found.
2. updated_at=0 on initial load (codex #2, Low): stampDbUpdatedAt() now
records the canonical mmdb's mtime both on initial load (existing
files) and after each successful update, so /metrics never reports 0
while serving real data.
3. Merge corruption -> permanent outage: mmdbmeld.WriteMMDB truncates the
canonical mmdb before writing; a failed merge previously left it empty
and the saved etag made the next daily run 304-skip the retry, so a
restart failed to open the file and the service went 503 permanently.
fetchDbs now backs up the canonical file before merge, restores it on
merge/reload failure, and drops the etags so the next run retries.
4. Temp-file leaks: Reload removes the temp clone and restores d.dbFile
when reader.Open fails (it previously assigned d.dbFile before Open);
cloneDBFile removes the temp on a failed copy.
Verified: go build, go vet, gofmt -l, go test -race ./... all pass.
Co-Authored-By: Claude <noreply@anthropic.com>
74 lines
2.1 KiB
Go
74 lines
2.1 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/rs/zerolog/log"
|
|
"net"
|
|
"net/http"
|
|
"thuanle.me/ip-info/internal/data"
|
|
)
|
|
|
|
func HandleJson(c *gin.Context) {
|
|
ip := c.ClientIP()
|
|
// ClientIP can yield an unparseable value from malformed proxy headers;
|
|
// reject it explicitly instead of letting Query fail into a 500.
|
|
if net.ParseIP(ip) == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip", "ip": ip})
|
|
return
|
|
}
|
|
// selfLookup=true preserves the "default value when lookup internal ip"
|
|
// contract (commit 9d07639): a valid but DB-absent client IP (loopback /
|
|
// private addresses) returns 200 {"ip": ...} rather than a 404, since the
|
|
// caller did not ask for a specific lookup.
|
|
HandleIpInfo(c, ip, true)
|
|
}
|
|
|
|
func HandleOtherIp(c *gin.Context) {
|
|
ip := c.Param("ip")
|
|
if net.ParseIP(ip) == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip", "ip": ip})
|
|
return
|
|
}
|
|
// The caller explicitly requested this IP, so a not-found is a real 404.
|
|
HandleIpInfo(c, ip, false)
|
|
}
|
|
|
|
func HandleIpInfo(c *gin.Context, ip string, selfLookup bool) {
|
|
if !data.Ins().IsLoaded() {
|
|
log.Error().Msg("DB is not loaded")
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "service unavailable, db still loading",
|
|
"ip": ip,
|
|
})
|
|
return
|
|
}
|
|
|
|
ipData, _, err := data.Ins().Query(ip)
|
|
if err != nil {
|
|
// Distinguish "not found" (valid IP, absent from DB) from other errors.
|
|
if isNotFound(err) {
|
|
if selfLookup {
|
|
// Graceful default for the self-IP (/json) path: the client's
|
|
// address is valid but simply absent from the public GeoIP DB.
|
|
c.JSON(http.StatusOK, gin.H{"ip": ip})
|
|
return
|
|
}
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "ip": ip})
|
|
return
|
|
}
|
|
log.Err(err).Msg("Failed to query IP")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error", "ip": ip})
|
|
return
|
|
}
|
|
|
|
ipData["ip"] = ip
|
|
c.JSON(http.StatusOK, ipData)
|
|
}
|
|
|
|
// isNotFound reports whether the query error means the IP was not present in
|
|
// the DB (as opposed to an invalid IP or an internal lookup failure).
|
|
func isNotFound(err error) bool {
|
|
return errors.Is(err, data.ErrNotFound)
|
|
}
|