Compare commits

...

5 commits

Author SHA1 Message Date
1e6a5e28ca
enable ip2location unconditionally as it can work without an api key
All checks were successful
Generic zig build / build (push) Successful in 1m18s
Generic zig build / deploy (push) Successful in 14s
2026-01-06 12:13:45 -08:00
ce958837e0
add pre tags for html 2026-01-06 10:45:37 -08:00
06b4a6057a
implement ansi/text and user-agent based html output 2026-01-06 10:15:33 -08:00
b466acb70a
implement CacheEntry deinit to clean up code a bit 2026-01-06 10:08:30 -08:00
95ce25919f
add favicon 2026-01-06 09:10:00 -08:00
10 changed files with 169 additions and 106 deletions

19
src/cache/Cache.zig vendored
View file

@ -49,8 +49,7 @@ pub fn get(self: *Cache, key: []const u8) ?[]const u8 {
// Check L2 (disk) // Check L2 (disk)
const cached = self.loadFromFile(key) catch return null; const cached = self.loadFromFile(key) catch return null;
defer self.allocator.free(cached.key); defer cached.deinit(self.allocator);
defer self.allocator.free(cached.value);
// L2 exists - promote to L1 // L2 exists - promote to L1
self.lru.put(key, cached.value, cached.expires) catch return null; self.lru.put(key, cached.value, cached.expires) catch return null;
@ -86,6 +85,11 @@ const CacheEntry = struct {
key: []const u8, key: []const u8,
value: []const u8, value: []const u8,
expires: i64, expires: i64,
pub fn deinit(self: CacheEntry, allocator: std.mem.Allocator) void {
allocator.free(self.key);
allocator.free(self.value);
}
}; };
/// Loads cached value from file (path calculated by the key). An error will be thrown /// Loads cached value from file (path calculated by the key). An error will be thrown
@ -113,7 +117,7 @@ fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const reader = &file_reader.interface; const reader = &file_reader.interface;
const cached = try deserialize(self.allocator, reader); const cached = try deserialize(self.allocator, reader);
errdefer self.allocator.free(cached.value); errdefer cached.deinit(self.allocator);
// Check if expired // Check if expired
const now = std.time.milliTimestamp(); const now = std.time.milliTimestamp();
@ -179,8 +183,7 @@ fn loadFromDir(self: *Cache) !void {
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
const cached = self.loadFromFilePath(file_path) catch continue; const cached = self.loadFromFilePath(file_path) catch continue;
defer self.allocator.free(cached.key); defer cached.deinit(self.allocator);
defer self.allocator.free(cached.value);
// Populate L1 cache from L2 // Populate L1 cache from L2
self.lru.put(cached.key, cached.value, cached.expires) catch continue; self.lru.put(cached.key, cached.value, cached.expires) catch continue;
@ -218,8 +221,7 @@ test "serialize and deserialize" {
var fixed_reader = std.Io.Reader.fixed(serialized); var fixed_reader = std.Io.Reader.fixed(serialized);
const cached = try deserialize(allocator, &fixed_reader); const cached = try deserialize(allocator, &fixed_reader);
defer allocator.free(cached.key); defer cached.deinit(allocator);
defer allocator.free(cached.value);
try std.testing.expectEqualStrings(key, cached.key); try std.testing.expectEqualStrings(key, cached.key);
try std.testing.expectEqualStrings(value, cached.value); try std.testing.expectEqualStrings(value, cached.value);
@ -234,8 +236,7 @@ test "deserialize handles integer expires" {
var fixed_reader = std.Io.Reader.fixed(json); var fixed_reader = std.Io.Reader.fixed(json);
const cached = try deserialize(allocator, &fixed_reader); const cached = try deserialize(allocator, &fixed_reader);
defer allocator.free(cached.key); defer cached.deinit(allocator);
defer allocator.free(cached.value);
try std.testing.expectEqualStrings("k", cached.key); try std.testing.expectEqualStrings("k", cached.key);
try std.testing.expectEqualStrings("v", cached.value); try std.testing.expectEqualStrings("v", cached.value);

View file

@ -121,10 +121,8 @@ const MockHarness = struct {
const geoip = try allocator.create(GeoIp); const geoip = try allocator.create(GeoIp);
errdefer allocator.destroy(geoip); errdefer allocator.destroy(geoip);
geoip.* = GeoIp.init(allocator, config.geolite_path, null, null) catch { geoip.* = GeoIp.init(allocator, config.geolite_path, null, config.ip2location_cache_file) catch
allocator.destroy(geoip);
return error.SkipZigTest; return error.SkipZigTest;
};
errdefer geoip.deinit(); errdefer geoip.deinit();
var geocache = try allocator.create(GeoCache); var geocache = try allocator.create(GeoCache);

BIN
src/http/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

View file

@ -48,7 +48,11 @@ pub fn handleWeather(
break :blk loc; break :blk loc;
} else break :blk client_ip; // no location, just use client ip instead } else break :blk client_ip; // no location, just use client ip instead
}; };
if (std.mem.eql(u8, "favicon.ico", location)) {
res.header("Content-Type", "image/x-icon");
res.body = @embedFile("favicon.ico");
return;
}
log.debug("location = {s}, client_ip = {s}", .{ location, client_ip }); log.debug("location = {s}, client_ip = {s}", .{ location, client_ip });
if (location.len == 0) { if (location.len == 0) {
res.content_type = .TEXT; res.content_type = .TEXT;
@ -164,27 +168,64 @@ fn handleWeatherInternal(
break :blk false; break :blk false;
}; };
const output = if (params.format) |fmt| blk: { // Add coordinates header using response allocator
const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude });
res.headers.add("X-Location-Coordinates", coords_header);
res.body = blk: {
if (params.format) |fmt| {
// Anything except the json will be plain text
res.content_type = .TEXT;
if (std.mem.eql(u8, fmt, "j1")) { if (std.mem.eql(u8, fmt, "j1")) {
res.content_type = .JSON; res.content_type = .JSON; // reset to json
break :blk try json.render(req_alloc, weather); break :blk try json.render(req_alloc, weather);
} }
if (std.mem.eql(u8, fmt, "v2")) if (std.mem.eql(u8, fmt, "v2"))
break :blk try v2.render(req_alloc, weather, use_imperial); break :blk try v2.render(req_alloc, weather, use_imperial);
if (std.mem.startsWith(u8, fmt, "%")) if (std.mem.startsWith(u8, fmt, "%"))
break :blk try custom.render(req_alloc, weather, fmt, use_imperial); break :blk try custom.render(req_alloc, weather, fmt, use_imperial);
// fall back to line if we don't understant the format parameter // fall back to line if we don't understand the format parameter
break :blk try line.render(req_alloc, weather, fmt, use_imperial); break :blk try line.render(req_alloc, weather, fmt, use_imperial);
} else try formatted.render(req_alloc, weather, .{ .use_imperial = use_imperial }); } else {
const format: formatted.Format = determineFormat(params, req.headers.get("user-agent"));
log.debug(
"Format: {}. params.ansi {}, params.text {}, user agent: {?s}",
.{ format, params.ansi, params.text_only, req.headers.get("user-agent") },
);
if (format != .html) res.content_type = .TEXT else res.content_type = .HTML;
break :blk try formatted.render(req_alloc, weather, .{ .use_imperial = use_imperial, .format = format });
}
};
}
// Add coordinates header using response allocator fn determineFormat(params: QueryParams, user_agent: ?[]const u8) formatted.Format {
const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude }); if (params.ansi or params.text_only) {
res.headers.add("X-Location-Coordinates", coords_header); // user explicitly requested something. If both are set, text will win
if (params.text_only) return .plain_text;
if (res.content_type != .JSON) return .ansi;
res.content_type = .TEXT; }
const ua = user_agent orelse "";
res.body = output; // https://github.com/chubin/wttr.in/blob/master/lib/globals.py#L82C1-L97C2
const plain_text_agents = &[_][]const u8{
"curl",
"httpie",
"lwp-request",
"wget",
"python-requests",
"python-httpx",
"openbsd ftp",
"powershell",
"fetch",
"aiohttp",
"http_get",
"xh",
"nushell",
"zig",
};
for (plain_text_agents) |agent|
if (std.mem.indexOf(u8, ua, agent)) |_|
return .ansi;
return .html;
} }
test "parseXForwardedFor extracts first IP" { test "parseXForwardedFor extracts first IP" {

View file

@ -1,11 +1,33 @@
const std = @import("std"); const std = @import("std");
///Units:
///
/// m # metric (SI) (used by default everywhere except US)
/// u # USCS (used by default in US)
/// M # show wind speed in m/s
///
///View options:
///
/// 0 # only current weather
/// 1 # current weather + today's forecast
/// 2 # current weather + today's + tomorrow's forecast
/// A # ignore User-Agent and force ANSI output format (terminal)
/// d # restrict output to standard console font glyphs
/// F # do not show the "Follow" line
/// n # narrow version (only day and night)
/// q # quiet version (no "Weather report" text)
/// Q # superquiet version (no "Weather report", no city name)
/// T # switch terminal sequences off (no colors)
pub const QueryParams = struct { pub const QueryParams = struct {
format: ?[]const u8 = null, format: ?[]const u8 = null,
lang: ?[]const u8 = null, lang: ?[]const u8 = null,
location: ?[]const u8 = null, location: ?[]const u8 = null,
units: ?Units = null, units: ?Units = null,
transparency: ?u8 = null, transparency: ?u8 = null,
/// A: Ignore user agent and force ansi mode
ansi: bool = false,
/// T: Avoid terminal sequences and just output plain text
text_only: bool = false,
pub const Units = enum { pub const Units = enum {
metric, metric,
@ -23,16 +45,22 @@ pub const QueryParams = struct {
const key = kv.next() orelse continue; const key = kv.next() orelse continue;
const value = kv.next(); const value = kv.next();
if (key.len == 1) {
switch (key[0]) {
'u' => params.units = .uscs,
'm' => params.units = .metric,
'A' => params.ansi = true,
'T' => params.text_only = true,
't' => params.transparency = 150,
else => continue,
}
}
if (std.mem.eql(u8, key, "format")) { if (std.mem.eql(u8, key, "format")) {
params.format = if (value) |v| try allocator.dupe(u8, v) else null; params.format = if (value) |v| try allocator.dupe(u8, v) else null;
} else if (std.mem.eql(u8, key, "lang")) { } else if (std.mem.eql(u8, key, "lang")) {
params.lang = if (value) |v| try allocator.dupe(u8, v) else null; params.lang = if (value) |v| try allocator.dupe(u8, v) else null;
} else if (std.mem.eql(u8, key, "location")) { } else if (std.mem.eql(u8, key, "location")) {
params.location = if (value) |v| try allocator.dupe(u8, v) else null; params.location = if (value) |v| try allocator.dupe(u8, v) else null;
} else if (std.mem.eql(u8, key, "u")) {
params.units = .uscs;
} else if (std.mem.eql(u8, key, "m")) {
params.units = .metric;
} else if (std.mem.eql(u8, key, "use_imperial")) { } else if (std.mem.eql(u8, key, "use_imperial")) {
params.units = .uscs; params.units = .uscs;
} else if (std.mem.eql(u8, key, "use_metric")) { } else if (std.mem.eql(u8, key, "use_metric")) {
@ -41,8 +69,6 @@ pub const QueryParams = struct {
if (value) |v| { if (value) |v| {
params.transparency = try std.fmt.parseInt(u8, v, 10); params.transparency = try std.fmt.parseInt(u8, v, 10);
} }
} else if (std.mem.eql(u8, key, "t")) {
params.transparency = 150;
} }
} }
@ -108,6 +134,7 @@ test "parse multiple parameters" {
test "parse transparency" { test "parse transparency" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const params_t = try QueryParams.parse(allocator, "t"); const params_t = try QueryParams.parse(allocator, "t");
try std.testing.expect(params_t.transparency != null);
try std.testing.expectEqual(@as(u8, 150), params_t.transparency.?); try std.testing.expectEqual(@as(u8, 150), params_t.transparency.?);
const params_custom = try QueryParams.parse(allocator, "transparency=200"); const params_custom = try QueryParams.parse(allocator, "transparency=200");

View file

@ -10,11 +10,10 @@ const GeoIP = @This();
const log = std.log.scoped(.geoip); const log = std.log.scoped(.geoip);
mmdb: *c.MMDB_s, mmdb: *c.MMDB_s,
ip2location_client: ?*Ip2location, ip2location_client: *Ip2location,
ip2location_cache: ?*Ip2location.Cache,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const u8, cache_path: ?[]const u8) !GeoIP { pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const u8, cache_path: []const u8) !GeoIP {
const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); const path_z = try std.heap.c_allocator.dupeZ(u8, db_path);
defer std.heap.c_allocator.free(path_z); defer std.heap.c_allocator.free(path_z);
@ -22,32 +21,25 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const
errdefer allocator.destroy(mmdb); errdefer allocator.destroy(mmdb);
const status = c.MMDB_open(path_z.ptr, c.MMDB_MODE_MMAP, mmdb); const status = c.MMDB_open(path_z.ptr, c.MMDB_MODE_MMAP, mmdb);
if (status != c.MMDB_SUCCESS) { if (status != c.MMDB_SUCCESS)
return error.CannotOpenDatabase; return error.CannotOpenDatabase;
const client: *Ip2location = try allocator.create(Ip2location);
errdefer allocator.destroy(client);
client.* = try Ip2location.init(allocator, api_key, cache_path);
errdefer {
client.deinit();
allocator.destroy(client);
} }
var client: ?*Ip2location = null; std.log.info(
var cache: ?*Ip2location.Cache = null; "IP2Location fallback: {s} (cache: {s})",
.{ if (api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", cache_path },
if (api_key) |key| { );
client = try allocator.create(Ip2location);
client.?.* = try Ip2location.init(allocator, key);
if (cache_path) |path| {
cache = try allocator.create(Ip2location.Cache);
cache.?.* = try Ip2location.Cache.init(allocator, path);
std.log.info("IP2Location fallback: enabled (cache: {s})", .{path});
} else {
std.log.info("IP2Location fallback: enabled (no cache)", .{});
}
} else {
std.log.info("IP2Location fallback: disabled (no API key configured)", .{});
}
return GeoIP{ return GeoIP{
.mmdb = mmdb, .mmdb = mmdb,
.ip2location_client = client, .ip2location_client = client,
.ip2location_cache = cache,
.allocator = allocator, .allocator = allocator,
}; };
} }
@ -55,14 +47,9 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const
pub fn deinit(self: *GeoIP) void { pub fn deinit(self: *GeoIP) void {
c.MMDB_close(self.mmdb); c.MMDB_close(self.mmdb);
self.allocator.destroy(self.mmdb); self.allocator.destroy(self.mmdb);
if (self.ip2location_client) |client| { self.ip2location_client.deinit();
client.deinit(); log.debug("destroying client", .{});
self.allocator.destroy(client); self.allocator.destroy(self.ip2location_client);
}
if (self.ip2location_cache) |cache| {
cache.deinit();
self.allocator.destroy(cache);
}
} }
pub fn lookup(self: *GeoIP, ip: []const u8) ?Coordinates { pub fn lookup(self: *GeoIP, ip: []const u8) ?Coordinates {
@ -74,11 +61,8 @@ pub fn lookup(self: *GeoIP, ip: []const u8) ?Coordinates {
if (self.extractCoordinates(ip, result)) |coords| if (self.extractCoordinates(ip, result)) |coords|
return coords; return coords;
// Fallback to IP2Location if configured // Fallback to IP2Location
if (self.ip2location_client) |client| return self.ip2location_client.lookup(ip);
return client.lookupWithCache(ip, self.ip2location_cache);
return null;
} }
fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s { fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s {
@ -161,11 +145,11 @@ test "MMDB functions are callable" {
} }
test "GeoIP init with invalid path fails" { test "GeoIP init with invalid path fails" {
const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", null, null); const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", null, "");
try std.testing.expectError(error.CannotOpenDatabase, result); try std.testing.expectError(error.CannotOpenDatabase, result);
} }
test "isUSIP detects US IPs" { test "isUSIp detects US IPs" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const config = try Config.load(allocator); const config = try Config.load(allocator);
@ -178,9 +162,8 @@ test "isUSIP detects US IPs" {
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, null, null) catch { var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) catch
return error.SkipZigTest; return error.SkipZigTest;
};
defer geoip.deinit(); defer geoip.deinit();
// Test that the function doesn't crash with various IPs // Test that the function doesn't crash with various IPs
@ -203,9 +186,8 @@ test "lookup works" {
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, null, null) catch { var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) catch
return error.SkipZigTest; return error.SkipZigTest;
};
defer geoip.deinit(); defer geoip.deinit();
// Test that the function doesn't crash with various IPs // Test that the function doesn't crash with various IPs

View file

@ -7,23 +7,31 @@ const Self = @This();
const log = std.log.scoped(.ip2location); const log = std.log.scoped(.ip2location);
allocator: Allocator, allocator: Allocator,
api_key: []const u8, api_key: ?[]const u8,
http_client: std.http.Client, http_client: std.http.Client,
cache: *Cache,
pub fn init(allocator: Allocator, api_key: []const u8) !Self { pub fn init(allocator: Allocator, api_key: ?[]const u8, cache_path: []const u8) !Self {
const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path);
return .{ return .{
.allocator = allocator, .allocator = allocator,
.api_key = try allocator.dupe(u8, api_key), .api_key = if (api_key) |k| try allocator.dupe(u8, k) else null,
.http_client = std.http.Client{ .allocator = allocator }, .http_client = std.http.Client{ .allocator = allocator },
.cache = cache,
}; };
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.cache.deinit();
self.allocator.destroy(self.cache);
self.http_client.deinit(); self.http_client.deinit();
self.allocator.free(self.api_key); if (self.api_key) |k|
self.allocator.free(k);
} }
pub fn lookupWithCache(self: *Self, ip_str: []const u8, cache: ?*Cache) ?Coordinates { pub fn lookup(self: *Self, ip_str: []const u8) ?Coordinates {
// Parse IP to u128 for cache lookup // Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null; const addr = std.net.Address.parseIp(ip_str, 0) catch return null;
const ip_u128: u128 = switch (addr.any.family) { const ip_u128: u128 = switch (addr.any.family) {
@ -34,46 +42,39 @@ pub fn lookupWithCache(self: *Self, ip_str: []const u8, cache: ?*Cache) ?Coordin
const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6; const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6;
// Check cache first // Check cache first
if (cache) |c| { if (self.cache.get(ip_u128)) |coords|
if (c.get(ip_u128)) |coords| {
return coords; return coords;
}
}
// Fetch from API // Fetch from API
const coords = self.lookup(ip_str) catch |err| { const coords = self.fetch(ip_str) catch |err| {
log.err("API lookup failed: {}", .{err}); log.err("API lookup failed: {}", .{err});
return null; return null;
}; };
// Store in cache // Store in cache
if (cache) |c| { self.cache.put(ip_u128, family, coords) catch |err| {
c.put(ip_u128, family, coords) catch |err| {
log.warn("Failed to cache result: {}", .{err}); log.warn("Failed to cache result: {}", .{err});
}; };
}
return coords; return coords;
} }
pub fn lookup(self: *Self, ip_str: []const u8) !Coordinates { fn fetch(self: *Self, ip_str: []const u8) !Coordinates {
log.info("Fetching geolocation for IP {s}", .{ip_str}); log.info("Fetching geolocation for IP {s}", .{ip_str});
if (@import("builtin").is_test) return error.LookupUnavailableInUnitTest; if (@import("builtin").is_test) return error.LookupUnavailableInUnitTest;
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
// Build URL: https://api.ip2location.io/?key=XXX&ip=1.2.3.4 // Build URL: https://api.ip2location.io/?key=XXX&ip=1.2.3.4
const url = try std.fmt.allocPrint( try w.writeAll("https://api.ip2location.io/?ip=");
self.allocator, try w.writeAll(ip_str);
"https://api.ip2location.io/?key={s}&ip={s}", if (self.api_key) |key|
.{ self.api_key, ip_str }, try w.print("&key={s}", .{key});
);
defer self.allocator.free(url);
const uri = try std.Uri.parse(url);
var response_buf: [4096]u8 = undefined; var response_buf: [4096]u8 = undefined;
var writer = std.io.Writer.fixed(&response_buf); var writer = std.io.Writer.fixed(&response_buf);
const result = try self.http_client.fetch(.{ const result = try self.http_client.fetch(.{
.location = .{ .uri = uri }, .location = .{ .url = w.buffered() },
.method = .GET, .method = .GET,
.response_writer = &writer, .response_writer = &writer,
}); });
@ -97,7 +98,18 @@ pub fn lookup(self: *Self, ip_str: []const u8) !Coordinates {
const obj = parsed.value.object; const obj = parsed.value.object;
const lat = obj.get("latitude") orelse return error.MissingLatitude; const lat = obj.get("latitude") orelse return error.MissingLatitude;
const lon = obj.get("longitude") orelse return error.MissingLongitude; const lon = obj.get("longitude") orelse return error.MissingLongitude;
if (lat == .null) return error.MissingLatitude;
if (lat != .float)
log.err(
"Latitude returned from ip2location.io for ip {s} is not a float: {f}",
.{ ip_str, std.json.fmt(lat, .{}) },
);
if (lon == .null) return error.MissingLongitude;
if (lon != .float)
log.err(
"Longitude returned from ip2location.io for ip {s} is not a float: {f}",
.{ ip_str, std.json.fmt(lon, .{}) },
);
return Coordinates{ return Coordinates{
.latitude = @floatCast(lat.float), .latitude = @floatCast(lat.float),
.longitude = @floatCast(lon.float), .longitude = @floatCast(lon.float),
@ -131,6 +143,7 @@ pub const Cache = struct {
.entries = std.AutoHashMap(u128, Coordinates).init(allocator), .entries = std.AutoHashMap(u128, Coordinates).init(allocator),
.file = null, .file = null,
}; };
errdefer allocator.free(cache.path);
// Try to open existing cache file // Try to open existing cache file
if (std.fs.openFileAbsolute(path, .{ .mode = .read_write })) |file| { if (std.fs.openFileAbsolute(path, .{ .mode = .read_write })) |file| {

View file

@ -254,9 +254,8 @@ test "resolve IP address with GeoIP" {
try GeoLite2.ensureDatabase(allocator, config.geolite_path); try GeoLite2.ensureDatabase(allocator, config.geolite_path);
} }
var geoip = GeoIp.init(allocator, config.geolite_path, null, null) catch { var geoip = GeoIp.init(allocator, config.geolite_path, null, config.ip2location_cache_file) catch
return error.SkipZigTest; return error.SkipZigTest;
};
defer geoip.deinit(); defer geoip.deinit();
var geocache = try GeoCache.init(allocator, null); var geocache = try GeoCache.init(allocator, null);

View file

@ -43,7 +43,7 @@ pub fn main() !u8 {
allocator, allocator,
cfg.geolite_path, cfg.geolite_path,
cfg.ip2location_api_key, cfg.ip2location_api_key,
if (cfg.ip2location_api_key != null) cfg.ip2location_cache_file else null, cfg.ip2location_cache_file,
) catch |err| { ) catch |err| {
std.log.err("Failed to load GeoIP database from {s}: {}", .{ cfg.geolite_path, err }); std.log.err("Failed to load GeoIP database from {s}: {}", .{ cfg.geolite_path, err });
return err; return err;

View file

@ -110,6 +110,7 @@ pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: Re
defer output.deinit(); defer output.deinit();
const w = &output.writer; const w = &output.writer;
if (options.format == .html) try w.writeAll("<pre>");
if (!options.no_caption) if (!options.no_caption)
try w.print("Weather report: {s}\n\n", .{data.locationDisplayName()}); try w.print("Weather report: {s}\n\n", .{data.locationDisplayName()});
@ -122,6 +123,7 @@ pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: Re
try renderForecastDay(w, day, options); try renderForecastDay(w, day, options);
} }
} }
if (options.format == .html) try w.writeAll("</pre>");
return output.toOwnedSlice(); return output.toOwnedSlice();
} }