Skip to content

Commit 58a70a6

Browse files
feat: add lite command for interactive/headless browser-based evidence collection with screenshot metadata overlay.
1 parent 50f57cc commit 58a70a6

File tree

1 file changed

+81
-49
lines changed

1 file changed

+81
-49
lines changed

cmd/lite.go

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package cmd
55

66
import (
77
"bytes"
8+
"encoding/base64"
89
"fmt"
910
"image"
1011
"image/color"
@@ -63,7 +64,10 @@ Supports interactive mode for pages requiring manual interaction (e.g., CAPTCHA,
6364
// 3. Create Base Report Directory (Common)
6465
reportID := cfg.ReportID
6566
if reportID == "" {
66-
reportID = fmt.Sprintf("report-%s", time.Now().Format("20060102-150405"))
67+
// User requested Base64 representation of the date value
68+
ts := time.Now().Format("20060102-150405")
69+
encoded := base64.RawURLEncoding.EncodeToString([]byte(ts))
70+
reportID = fmt.Sprintf("report-%s", encoded)
6771
}
6872
if err := os.MkdirAll(reportID, 0755); err != nil {
6973
log.Fatalf("Failed to create report directory: %v", err)
@@ -242,108 +246,137 @@ func saveScreenshot(data []byte, targetURL string, userAgent string, outputDir s
242246
}
243247

244248
func addHeaderToImage(baseImg image.Image, info *utils.AccessInfo, TargetURL *string, userAgent string) (image.Image, error) {
249+
// Fixed width for standard output
250+
const fixedWidth = 1920
251+
245252
bounds := baseImg.Bounds()
246-
width := bounds.Dx()
247-
height := bounds.Dy()
253+
screenshotWidth := bounds.Dx()
254+
screenshotHeight := bounds.Dy()
255+
256+
// Create final image with fixed width.
257+
// The height will be determined later, but width is always 1920.
258+
// We need to calculate text wrapping based on 1920px.
248259

249-
// Calculate available width for text (with margins)
250260
textMarginX := 10
251-
availableWidth := width - (textMarginX * 2)
261+
availableWidth := fixedWidth - (textMarginX * 2)
252262
charWidth := 8 // inconsolata.Regular8x16 character width
253263

254-
// Wrap User-Agent to fit width without truncation
264+
// Wrap User-Agent to fit fixed width
255265
maxCharsPerLine := availableWidth / charWidth
256266
if maxCharsPerLine < 20 {
257267
maxCharsPerLine = 20
258-
} // Safety
268+
}
269+
270+
// We can use simple wrapping now because we have plenty of space (1920px)
259271
uaLines := wrapText(userAgent, maxCharsPerLine)
260272

261273
now := info.Timestamp.UTC().Format(time.RFC3339)
262274

263-
// Parse and format URL with Punycode/IDN handling
275+
// URL parsing
264276
targetURL := *TargetURL
265277
parsedURL, err := url.Parse(targetURL)
266278
if err != nil {
267279
log.Warnf("Failed to parse target URL: %v", err)
268280
parsedURL = &url.URL{Host: "invalid-url"}
269281
}
270-
271-
// Convert host to Punycode (ASCII) form for display
272282
punycodeHost := parsedURL.Host
273283
if parsedURL.Host != "" {
274-
asciiHost, err := idna.ToASCII(parsedURL.Host)
275-
if err != nil {
276-
log.Warnf("IDNA ToASCII conversion failed for host %s: %v", parsedURL.Host, err)
277-
} else {
284+
if asciiHost, err := idna.ToASCII(parsedURL.Host); err == nil {
278285
punycodeHost = asciiHost
279286
}
280287
}
281-
282-
// Build origin (scheme + host + port in Punycode)
283288
origin := fmt.Sprintf("%s://%s", parsedURL.Scheme, punycodeHost)
284-
285-
// Build full URL with Punycode host
286289
fullURLCopy := *parsedURL
287290
fullURLCopy.Host = punycodeHost
288291
fullURL := fullURLCopy.String()
289292

290-
// Truncate URLs if needed
291-
maxOriginLen := maxCharsPerLine - 14 // "Origin: " length
293+
// Truncate URLs (using fixed width context, so usually no truncation needed)
294+
maxOriginLen := maxCharsPerLine - 14
292295
maxFullURLLen := maxCharsPerLine - 14
293296
truncatedOrigin := truncateString(origin, maxOriginLen)
294297
truncatedFullURL := truncateString(fullURL, maxFullURLLen)
295298

296299
// Build lines array
297300
lines := []string{
298301
fmt.Sprintf("Access Information (down-force demo) - %s", now),
299-
"--------------------------------------------------------------------------------",
302+
string(bytes.Repeat([]byte("-"), maxCharsPerLine)), // Dynamic separator for 1920px
300303
fmt.Sprintf("Origin: %s", truncatedOrigin),
301304
fmt.Sprintf("Full URL: %s", truncatedFullURL),
302305
}
303306

304-
// Add User-Agent with wrapping (no truncation)
305307
if len(uaLines) > 0 {
306308
lines = append(lines, "User-Agent: "+uaLines[0])
307309
for i := 1; i < len(uaLines); i++ {
308-
lines = append(lines, " "+uaLines[i]) // Indent continuation lines
310+
lines = append(lines, " "+uaLines[i])
309311
}
310312
} else {
311313
lines = append(lines, "User-Agent: "+userAgent)
312314
}
313315

314-
lines = append(lines,
315-
"--------------------------------------------------------------------------------",
316-
fmt.Sprintf("IPv4 Address: %s", info.FromIPv4),
317-
)
316+
lines = append(lines, string(bytes.Repeat([]byte("-"), maxCharsPerLine)))
317+
318+
// Helper to wrap generic text (even with 1920px, very long strings might need wrapping)
319+
wrapField := func(label, value string) {
320+
fullLine := fmt.Sprintf("%s%s", label, value)
321+
if len([]rune(fullLine)) <= maxCharsPerLine {
322+
lines = append(lines, fullLine)
323+
} else {
324+
lines = append(lines, wrapText(fullLine, maxCharsPerLine)...)
325+
}
326+
}
327+
328+
wrapField("IPv4 Address: ", info.FromIPv4)
318329
if info.FromIPv6 != "" {
319-
lines = append(lines, fmt.Sprintf("IPv6 Address: %s", info.FromIPv6))
330+
wrapField("IPv6 Address: ", info.FromIPv6)
320331
}
321-
lines = append(lines,
322-
fmt.Sprintf("Location: %s", info.Country),
323-
fmt.Sprintf("ISP: %s", info.ISP),
324-
fmt.Sprintf("ASN: %s", info.ASN),
325-
"--------------------------------------------------------------------------------",
326-
)
327-
328-
// Calculate header height based on line count
332+
wrapField("Location: ", info.Country)
333+
wrapField("ISP: ", info.ISP)
334+
wrapField("ASN: ", info.ASN)
335+
336+
lines = append(lines, string(bytes.Repeat([]byte("-"), maxCharsPerLine)))
337+
338+
// Calculate dimensions
329339
lineHeight := 16
330340
startY := 20
331341
headerHeight := startY + (len(lines) * lineHeight) + 10
332342

333-
// Create new image with calculated header height
334-
newImg := image.NewRGBA(image.Rect(0, 0, width, height+headerHeight))
343+
// Validate minimal width (screenshot might be wider than 1920? unlikely for mobile but possible for 4k desktop)
344+
// User said "Always fixed 1920px". If screenshot > 1920, we should probably crop or resize or expand.
345+
// Usually "Standard Evidence" implies 1920 is the canvas. If screenshot is bigger, the canvas grows?
346+
// But user said "Always fix width to 1920px". So if screenshot > 1920, we might need to downscale or let it crop?
347+
// Safer assumption: Canvas Width = Max(1920, ScreenshotWidth). But user said "Fixed 1920".
348+
// Let's stick to 1920. If screenshot is larger, it gets cropped. But mobile is usually smaller.
349+
350+
finalWidth := fixedWidth
351+
352+
newImg := image.NewRGBA(image.Rect(0, 0, finalWidth, headerHeight+screenshotHeight))
335353

336-
headerRect := image.Rect(0, 0, width, headerHeight)
337-
draw.Draw(newImg, headerRect, image.NewUniform(color.Black), image.Point{}, draw.Src)
354+
// 1. Draw Header Background (Black) - Full Width
355+
draw.Draw(newImg, image.Rect(0, 0, finalWidth, headerHeight), image.NewUniform(color.Black), image.Point{}, draw.Src)
338356

339-
draw.Draw(
340-
newImg,
341-
image.Rect(0, headerHeight, width, headerHeight+height),
342-
baseImg,
343-
image.Point{},
344-
draw.Src,
345-
)
357+
// 2. Draw Screenshot - Centered horizontally at the bottom
358+
// Calculate center X
359+
// If screenshotWidth < 1920: (1920 - w) / 2
360+
// If screenshotWidth > 1920: (1920 - w) / 2 (result is negative, drawing starts at negative, effectively cropping center)
346361

362+
var dstX int
363+
if screenshotWidth < finalWidth {
364+
dstX = (finalWidth - screenshotWidth) / 2
365+
} else {
366+
// Center crop behavior implies dstX is negative?
367+
// Wait, user wants "padding" for mobile (narrow).
368+
// For wide, if I fix 1920, I must crop or resize. I'll center.
369+
dstX = (finalWidth - screenshotWidth) / 2
370+
}
371+
372+
// Draw screenshot
373+
// Transparent padding is achieved because newImg is zero-initialized (transparent distinct from black).
374+
// We only filled the header rect with Black. The rest is transparent.
375+
376+
screenshotRect := image.Rect(dstX, headerHeight, dstX+screenshotWidth, headerHeight+screenshotHeight)
377+
draw.Draw(newImg, screenshotRect, baseImg, image.Point{}, draw.Src)
378+
379+
// 3. Draw Header Text
347380
face := inconsolata.Regular8x16
348381
d := &font.Drawer{
349382
Dst: newImg,
@@ -352,7 +385,6 @@ func addHeaderToImage(baseImg image.Image, info *utils.AccessInfo, TargetURL *st
352385
}
353386

354387
startX := textMarginX
355-
356388
for i, line := range lines {
357389
d.Dot = fixed.Point26_6{
358390
X: fixed.I(startX),
@@ -361,7 +393,7 @@ func addHeaderToImage(baseImg image.Image, info *utils.AccessInfo, TargetURL *st
361393
d.DrawString(line)
362394
}
363395

364-
log.Infof("Header added to screenshot: origin=%s, full_url=%s, ua=%s", truncatedOrigin, truncatedFullURL, userAgent)
396+
log.Infof("Header added with fixed width %d: origin=%s", finalWidth, truncatedOrigin)
365397

366398
return newImg, nil
367399
}

0 commit comments

Comments
 (0)