@@ -124,11 +124,6 @@ type ScanStatusResponse struct {
124124 Error * string `json:"error,omitempty"`
125125}
126126
127- type ScanListResponse struct {
128- ActiveScans []ScanInfo `json:"active_scans"`
129- CompletedScans []ScanInfo `json:"completed_scans"`
130- }
131-
132127// reconcileStaleScansOnStartup marks DB scans that were still "running" as failed. In-memory
133128// workers are gone after restart; leaving them active confuses the dashboard.
134129func reconcileStaleScansOnStartup () {
@@ -170,6 +165,9 @@ func setupAPI() *gin.Engine {
170165 // Both /ui and /ui/* use the same handler — no redirect loops.
171166 r .GET ("/ui" , serveDashboardUI )
172167 r .GET ("/ui/*filepath" , serveDashboardUI )
168+ // Deep link: /scans/:scanId (same SPA; client router reads pathname)
169+ r .GET ("/scans" , serveDashboardUI )
170+ r .GET ("/scans/*filepath" , serveDashboardUI )
173171
174172 // Public: SPA reads this before login (no JWT).
175173 r .GET ("/api/config" , apiConfigHandler )
@@ -183,7 +181,11 @@ func setupAPI() *gin.Engine {
183181 apiGroup .DELETE ("/domains/:domain" , apiDeleteDomain )
184182 apiGroup .GET ("/domains/:domain/subdomains" , apiListSubdomains )
185183 apiGroup .GET ("/scans" , apiListScans )
184+ apiGroup .GET ("/scans/:id/results/summary" , apiScanResultsSummary )
185+ apiGroup .GET ("/scans/:id/results/files" , apiScanResultFiles )
186+ apiGroup .GET ("/scans/:id/results/file" , apiScanResultFileContent )
186187 apiGroup .GET ("/scans/:id/artifacts" , apiListScanArtifacts )
188+ apiGroup .GET ("/scans/:id" , apiGetScan )
187189 apiGroup .POST ("/scans/bulk-delete" , apiBulkDeleteScans )
188190 apiGroup .POST ("/scans/clear-all" , apiClearAllScans )
189191 apiGroup .DELETE ("/scans/:id" , apiDeleteScan )
@@ -204,6 +206,7 @@ func setupAPI() *gin.Engine {
204206 apiGroup .POST ("/monitor/subdomain-targets/:id/pause" , apiPauseMonitorSubdomainTarget )
205207 apiGroup .POST ("/monitor/subdomain-targets/:id/resume" , apiResumeMonitorSubdomainTarget )
206208 apiGroup .GET ("/r2/files" , apiR2Files )
209+ apiGroup .POST ("/r2/delete" , apiR2Delete )
207210 }
208211
209212 // Scan endpoints
@@ -255,9 +258,6 @@ func setupAPI() *gin.Engine {
255258 internal .POST ("/send-message" , sendMessageToDiscord )
256259 }
257260
258- // List all scans (legacy path)
259- r .GET ("/scans" , auth , listScans )
260-
261261 // Utility endpoints
262262 r .POST ("/cleanup" , auth , cleanupHandler )
263263
@@ -1643,41 +1643,6 @@ func downloadScanResults(c *gin.Context) {
16431643 c .File (tmpFile .Name ())
16441644}
16451645
1646- func listScans (c * gin.Context ) {
1647- // #9: Pull active from memory, historical from DB (newest first, deterministic).
1648- scansMutex .RLock ()
1649- active := make ([]ScanInfo , 0 , len (activeScans ))
1650- for _ , scan := range activeScans {
1651- active = append (active , * scan )
1652- }
1653- scansMutex .RUnlock ()
1654-
1655- // Fetch up to 50 recent scans from DB for the completed list.
1656- completed := make ([]ScanInfo , 0 , 50 )
1657- if dbScans , err := db .ListRecentScans (50 ); err == nil {
1658- for _ , s := range dbScans {
1659- if s .Status == "running" {
1660- continue // already in active list
1661- }
1662- completed = append (completed , ScanInfo {
1663- ScanID : s .ScanID ,
1664- Type : s .ScanType ,
1665- ScanType : s .ScanType ,
1666- Target : s .Target ,
1667- Status : s .Status ,
1668- StartTime : s .StartedAt ,
1669- StartedAt : s .StartedAt ,
1670- CompletedAt : s .CompletedAt ,
1671- })
1672- }
1673- }
1674-
1675- c .JSON (http .StatusOK , ScanListResponse {
1676- ActiveScans : active ,
1677- CompletedScans : completed ,
1678- })
1679- }
1680-
16811646// Helper functions
16821647
16831648// generateScanID returns a cryptographically random UUID v4 (#1).
@@ -1835,6 +1800,8 @@ func executeScan(scanID string, command []string, scanType string) {
18351800 }
18361801 // Index any final tool-generated artifacts (nuclei/ffuf/gf/tech/etc) that bypass wrappers.
18371802 indexScanArtifacts (scanID , scanType , target )
1803+ // domain_run / subdomain_run delete local results after upload — backfill from R2 for the UI table.
1804+ indexWorkflowArtifactsFromR2 (scanID , scanType , target )
18381805
18391806 scansMutex .Lock ()
18401807 delete (activeScans , scanID )
@@ -1934,6 +1901,117 @@ func indexScanArtifacts(scanID, scanType, target string) {
19341901 }
19351902}
19361903
1904+ // targetHostForR2Prefixes mirrors the UI r2PrefixesForScan hostname normalization (app.js).
1905+ func targetHostForR2Prefixes (target string ) string {
1906+ t := strings .TrimSpace (target )
1907+ t = strings .TrimPrefix (strings .TrimPrefix (t , "http://" ), "https://" )
1908+ if i := strings .Index (t , "/" ); i >= 0 {
1909+ t = t [:i ]
1910+ }
1911+ t = strings .TrimPrefix (strings .ToLower (t ), "www." )
1912+ return t
1913+ }
1914+
1915+ // workflowScanR2Prefixes returns R2 key prefixes used for domain_run / subdomain_run (matches app.js default branch).
1916+ func workflowScanR2Prefixes (target string ) []string {
1917+ h := targetHostForR2Prefixes (target )
1918+ if h == "" {
1919+ return nil
1920+ }
1921+ seen := map [string ]struct {}{}
1922+ var out []string
1923+ add := func (p string ) {
1924+ if p == "" {
1925+ return
1926+ }
1927+ if _ , ok := seen [p ]; ok {
1928+ return
1929+ }
1930+ seen [p ] = struct {}{}
1931+ out = append (out , p )
1932+ }
1933+ add ("new-results/" + h + "/" )
1934+ add ("results/" + h + "/" )
1935+ add ("lite/" + h + "/" )
1936+ add ("new-results/misconfig/" + h + "/" )
1937+ add ("misconfig/" + h + "/" )
1938+ return out
1939+ }
1940+
1941+ // isR2KeyIndexableArtifact matches shouldSkipArtifact extension rules for workflow backfill.
1942+ func isR2KeyIndexableArtifact (key string ) bool {
1943+ name := strings .ToLower (filepath .Base (key ))
1944+ if strings .HasPrefix (name , ".lite-uploads-" ) {
1945+ return false
1946+ }
1947+ if strings .HasPrefix (name , "temp-" ) || strings .Contains (name , "dangling-ip-temp" ) {
1948+ return false
1949+ }
1950+ if strings .EqualFold (name , "temp-url.txt" ) {
1951+ return false
1952+ }
1953+ ext := strings .ToLower (filepath .Ext (name ))
1954+ switch ext {
1955+ case ".txt" , ".json" , ".log" , ".csv" , ".html" , ".md" , ".bin" :
1956+ return true
1957+ default :
1958+ return false
1959+ }
1960+ }
1961+
1962+ // indexWorkflowArtifactsFromR2 populates scan_artifacts from R2 listings for domain_run / subdomain_run.
1963+ // Full-domain and single-subdomain workflows delete local result dirs on completion, so post-scan
1964+ // filesystem indexing finds nothing; uploads still land in R2. Backfilling here makes the scan modal
1965+ // use the same indexed-artifact table as other scans instead of the raw R2 fallback.
1966+ func indexWorkflowArtifactsFromR2 (scanID , scanType , target string ) {
1967+ st := strings .ToLower (strings .TrimSpace (scanType ))
1968+ if st != "domain_run" && st != "subdomain_run" {
1969+ return
1970+ }
1971+ if strings .TrimSpace (scanID ) == "" || ! r2storage .IsEnabled () {
1972+ return
1973+ }
1974+ prefixes := workflowScanR2Prefixes (target )
1975+ if len (prefixes ) == 0 {
1976+ return
1977+ }
1978+ seenKey := map [string ]struct {}{}
1979+ for _ , prefix := range prefixes {
1980+ objs , err := r2storage .ListObjectsRecursive (prefix )
1981+ if err != nil {
1982+ log .Printf ("[indexWorkflowArtifactsFromR2] list prefix %q: %v" , prefix , err )
1983+ continue
1984+ }
1985+ for _ , o := range objs {
1986+ if o .Size == 0 {
1987+ continue
1988+ }
1989+ if ! isR2KeyIndexableArtifact (o .Key ) {
1990+ continue
1991+ }
1992+ if _ , dup := seenKey [o .Key ]; dup {
1993+ continue
1994+ }
1995+ seenKey [o .Key ] = struct {}{}
1996+ pub := r2storage .PublicURLForKey (o .Key )
1997+ if pub == "" {
1998+ continue
1999+ }
2000+ art := & db.ScanArtifact {
2001+ ScanID : scanID ,
2002+ FileName : filepath .Base (o .Key ),
2003+ R2Key : o .Key ,
2004+ PublicURL : pub ,
2005+ SizeBytes : o .Size ,
2006+ CreatedAt : o .LastModified ,
2007+ }
2008+ if err := db .AppendScanArtifact (art ); err != nil {
2009+ log .Printf ("[indexWorkflowArtifactsFromR2] append %s: %v" , o .Key , err )
2010+ }
2011+ }
2012+ }
2013+ }
2014+
19372015func shouldSkipArtifact (path string ) bool {
19382016 name := strings .ToLower (filepath .Base (path ))
19392017 if strings .HasPrefix (name , ".lite-uploads-" ) {
@@ -1942,6 +2020,9 @@ func shouldSkipArtifact(path string) bool {
19422020 if strings .HasPrefix (name , "temp-" ) || strings .Contains (name , "dangling-ip-temp" ) {
19432021 return true
19442022 }
2023+ if strings .EqualFold (name , "temp-url.txt" ) {
2024+ return true
2025+ }
19452026 ext := strings .ToLower (filepath .Ext (name ))
19462027 switch ext {
19472028 case ".txt" , ".json" , ".log" , ".csv" , ".html" , ".md" , ".bin" :
0 commit comments