diff --git a/cmd/report_list.go b/cmd/report_list.go index 8bc89611..68ecab34 100644 --- a/cmd/report_list.go +++ b/cmd/report_list.go @@ -20,8 +20,9 @@ import ( ) var listCmdFlags = struct { - DbURI string - JsonFile string + DbURI string + JsonFile string + Bookmarks bool }{} var listCmd = &cobra.Command{ Use: "list", @@ -87,7 +88,13 @@ lines file.`)), return } - if err := conn.Model(&models.Result{}).Preload(clause.Associations).Find(&results).Error; err != nil { + if listCmdFlags.Bookmarks { + err = conn.Model(&models.Result{}).Preload(clause.Associations).Where("bookmarked = true").Find(&results).Error + } else { + err = conn.Model(&models.Result{}).Preload(clause.Associations).Find(&results).Error + } + + if err != nil { log.Error("could not get list", "err", err) return } @@ -101,6 +108,7 @@ func init() { listCmd.Flags().StringVar(&listCmdFlags.DbURI, "db-uri", "sqlite://gowitness.sqlite3", "The location of a gowitness database") listCmd.Flags().StringVar(&listCmdFlags.JsonFile, "json-file", "", "The location of a JSON Lines results file (e.g., ./gowitness.jsonl). This flag takes precedence over --db-uri") + listCmd.Flags().BoolVar(&listCmdFlags.Bookmarks, "bookmarks", false, "Only list bookmarked results") } func renderTable(results []*models.Result) { diff --git a/pkg/models/models.go b/pkg/models/models.go index 0b5497a2..489fc2a7 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -28,6 +28,7 @@ type Result struct { PerceptionHash string `json:"perception_hash" gorm:"index"` PerceptionHashGroupId uint `json:"perception_hash_group_id" gorm:"index"` Screenshot string `json:"screenshot"` + Bookmarked bool `json:"bookmarked"` // Name of the screenshot file Filename string `json:"file_name"` diff --git a/web/api/bookmark.go b/web/api/bookmark.go new file mode 100644 index 00000000..c815e7c7 --- /dev/null +++ b/web/api/bookmark.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sensepost/gowitness/pkg/log" + "github.com/sensepost/gowitness/pkg/models" +) + +type bookmarkRequest struct { + ID int `json:"id"` +} + +// BookmarkHandler inverts the state of a bookmark +// +// @Summary Bookmark/Unbookmarks result +// @Description Inverts the bookmark status of a result, writing results to the database. +// @Tags Results +// @Accept json +// @Produce json +// @Param query body bookmarkRequest true "The bookmark request object" +// @Success 200 {string} string "bookmarked" +// @Router /results/bookmark [post] +func (h *ApiHandler) BookmarkHandler(w http.ResponseWriter, r *http.Request) { + var request bookmarkRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + log.Error("failed to read json request", "err", err) + http.Error(w, "Error reading JSON request", http.StatusInternalServerError) + return + } + + var bookmarked bool + if err := h.DB.Model(&models.Result{}).Where("id = ?", request.ID).Select("bookmarked").First(&bookmarked).Error; err != nil { + log.Error("failed to get bookmark status", "err", err) + http.Error(w, "Error getting result bookmark value", http.StatusInternalServerError) + return + } + + log.Info("inverting bookmark id", "id", request.ID) + if err := h.DB.Model(&models.Result{}).Where("id = ?", request.ID).Update("bookmarked", !bookmarked).Error; err != nil { + log.Error("failed to update result bookmark", "err", err) + http.Error(w, "Error updating result bookmark value", http.StatusInternalServerError) + return + } + + var response string + if bookmarked { + response = `removed bookmark` + } else { + response = `bookmarked` + } + + jsonData, err := json.Marshal(response) + if err != nil { + http.Error(w, "Error creating JSON response", http.StatusInternalServerError) + return + } + + w.Write(jsonData) +} diff --git a/web/api/gallery.go b/web/api/gallery.go index fd9ab0dd..3e91abb4 100644 --- a/web/api/gallery.go +++ b/web/api/gallery.go @@ -28,6 +28,7 @@ type galleryContent struct { Screenshot string `json:"screenshot"` Failed bool `json:"failed"` Technologies []string `json:"technologies"` + Bookmarked bool `json:"bookmarked"` } // GalleryHandler gets a paginated gallery @@ -43,6 +44,7 @@ type galleryContent struct { // @Param status query string false "A comma seperated list of HTTP status codes to filter by." // @Param perception query boolean false "Order the results by perception hash." // @Param failed query boolean false "Include failed screenshots in the results." +// @Param bookmarked query boolean false "Only return bookmarked items" // @Success 200 {object} galleryResponse // @Router /results/gallery [get] func (h *ApiHandler) GalleryHandler(w http.ResponseWriter, r *http.Request) { @@ -98,6 +100,13 @@ func (h *ApiHandler) GalleryHandler(w http.ResponseWriter, r *http.Request) { showFailed = true } + // bookmarked filtering + var bookmarked bool + bookmarked, err = strconv.ParseBool(r.URL.Query().Get("bookmarked")) + if err != nil { + bookmarked = false + } + // query the db var queryResults []*models.Result query := h.DB.Model(&models.Result{}).Limit(results.Limit). @@ -121,6 +130,10 @@ func (h *ApiHandler) GalleryHandler(w http.ResponseWriter, r *http.Request) { query.Where("failed = ?", showFailed) } + if bookmarked { + query.Where("bookmarked = ?", bookmarked) + } + // run the query if err := query.Find(&queryResults).Error; err != nil { log.Error("could not get gallery", "err", err) @@ -145,6 +158,7 @@ func (h *ApiHandler) GalleryHandler(w http.ResponseWriter, r *http.Request) { Screenshot: result.Screenshot, Failed: result.Failed, Technologies: technologies, + Bookmarked: result.Bookmarked, }) } diff --git a/web/api/list.go b/web/api/list.go index 2581e2c3..89d8c45b 100644 --- a/web/api/list.go +++ b/web/api/list.go @@ -18,6 +18,7 @@ type listResponse struct { Protocol string `json:"protocol"` ContentLength int64 `json:"content_length"` Title string `json:"title"` + Bookmarked bool `json:"bookmarked"` // Failed flag set if the result should be considered failed Failed bool `json:"failed"` diff --git a/web/api/search.go b/web/api/search.go index 52d994bd..ca33a10b 100644 --- a/web/api/search.go +++ b/web/api/search.go @@ -29,12 +29,13 @@ type searchResult struct { FailedReason string `json:"failed_reason"` Filename string `json:"file_name"` Screenshot string `json:"screenshot"` + Bookmarked bool `json:"bookmarked"` MatchedFields []string `json:"matched_fields"` } // searchOperators are the operators we support. everything else is // "free text" -var searchOperators = []string{"title", "body", "tech", "header", "p"} +var searchOperators = []string{"title", "body", "tech", "header", "p", "bookmarked"} // SearchHandler handles search // diff --git a/web/server.go b/web/server.go index f24d627e..10a438d6 100644 --- a/web/server.go +++ b/web/server.go @@ -79,6 +79,7 @@ func (s *Server) Run() { r.Get("/results/gallery", apih.GalleryHandler) r.Get("/results/list", apih.ListHandler) r.Get("/results/detail/{id}", apih.DetailHandler) + r.Post("/results/bookmark", apih.BookmarkHandler) r.Post("/results/delete", apih.DeleteResultHandler) r.Get("/results/technology", apih.TechnologyListHandler) }) diff --git a/web/ui/src/lib/api/api.ts b/web/ui/src/lib/api/api.ts index 5329ca7d..7f5ec374 100644 --- a/web/ui/src/lib/api/api.ts +++ b/web/ui/src/lib/api/api.ts @@ -47,6 +47,10 @@ const endpoints = { path: `/search`, returnas: {} as searchresult }, + bookmark: { + path: `/results/bookmark`, + returnas: "" as string + }, delete: { path: `/results/delete`, returnas: "" as string diff --git a/web/ui/src/lib/api/bookmark.ts b/web/ui/src/lib/api/bookmark.ts new file mode 100644 index 00000000..1eb49194 --- /dev/null +++ b/web/ui/src/lib/api/bookmark.ts @@ -0,0 +1,23 @@ +import { toast } from "@/hooks/use-toast"; +import * as api from "@/lib/api/api"; + +const bookmarkResult = async (id: number): Promise => { + try { + await api.post('bookmark', { id }); + } catch (error) { + toast({ + title: "API Error", + variant: "destructive", + description: `Failed to bookmark result: ${error}` + }); + + return false; + } + toast({ + description: "Result bookmark updated" + }); + + return true; +} + +export { bookmarkResult }; diff --git a/web/ui/src/lib/api/types.ts b/web/ui/src/lib/api/types.ts index 6bd7e883..b7adf3bc 100644 --- a/web/ui/src/lib/api/types.ts +++ b/web/ui/src/lib/api/types.ts @@ -36,6 +36,7 @@ type galleryResult = { screenshot: string; failed: boolean; technologies: string[]; + bookmarked: boolean; }; // list @@ -48,6 +49,7 @@ type list = { protocol: string; content_length: number; title: string; + bookmarked: boolean; failed: boolean; failed_reason: string; }; @@ -136,6 +138,7 @@ interface detail { html: string; title: string; perception_hash: string; + bookmarked: boolean; file_name: string; is_pdf: boolean; failed: boolean; @@ -159,6 +162,7 @@ interface searchresult { matched_fields: string[]; file_name: string; screenshot: string; + bookmarked: boolean; } interface technologylist { diff --git a/web/ui/src/pages/detail/Detail.tsx b/web/ui/src/pages/detail/Detail.tsx index fb306d51..8aff2a2b 100644 --- a/web/ui/src/pages/detail/Detail.tsx +++ b/web/ui/src/pages/detail/Detail.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ExternalLink, ChevronLeft, ChevronRight, Code, ClockIcon, Trash2Icon, DownloadIcon, ImagesIcon, ZoomInIcon, CopyIcon } from 'lucide-react'; +import { BookmarkIcon, BookmarkFilledIcon } from "@radix-ui/react-icons"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { WideSkeleton } from '@/components/loading'; import { Form, Link, useNavigate, useParams } from 'react-router-dom'; @@ -14,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { copyToClipboard, getIconUrl, getStatusColor } from '@/lib/common'; import * as api from "@/lib/api/api"; import * as apitypes from "@/lib/api/types"; +import { bookmarkResult } from "@/lib/api/bookmark"; import { getData } from './data'; @@ -93,6 +95,21 @@ const ScreenshotDetailPage = () => { } }; + const handleBookmarkClick = async () => { + const bookmarkUpdated = await bookmarkResult(currentId) + if (bookmarkUpdated) { + setDetail(prevDetail => { + if (!prevDetail) { + return prevDetail; + } + return { + ...prevDetail, + bookmarked: !prevDetail.bookmarked + }; + }); + } + } + if (loading) return ; if (!detail) return; @@ -135,6 +152,23 @@ const ScreenshotDetailPage = () => { + + + + + + +

Bookmark result

+
+
+
@@ -719,4 +753,4 @@ const ScreenshotDetailPage = () => { ); }; -export default ScreenshotDetailPage; \ No newline at end of file +export default ScreenshotDetailPage; diff --git a/web/ui/src/pages/gallery/Gallery.tsx b/web/ui/src/pages/gallery/Gallery.tsx index 0437a61c..11dd4756 100644 --- a/web/ui/src/pages/gallery/Gallery.tsx +++ b/web/ui/src/pages/gallery/Gallery.tsx @@ -8,6 +8,7 @@ import { AlertOctagonIcon, BanIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ExternalLinkIcon, FilterIcon, GroupIcon, ShieldCheckIcon, XIcon } from "lucide-react"; +import { BookmarkIcon, BookmarkFilledIcon } from "@radix-ui/react-icons"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -17,13 +18,14 @@ import { cn } from "@/lib/utils"; import * as api from "@/lib/api/api"; import * as apitypes from "@/lib/api/types"; import { getData, getWappalyzerData } from "./data"; +import { bookmarkResult } from "@/lib/api/bookmark"; import { getIconUrl, getStatusColor } from "@/lib/common"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; const GalleryPage = () => { - const [gallery, setGallery] = useState(); + const [gallery, setGallery] = useState([]); const [wappalyzer, setWappalyzer] = useState(); const [technology, setTechnology] = useState(); const [totalPages, setTotalPages] = useState(0); @@ -38,6 +40,7 @@ const GalleryPage = () => { const statusFilter = searchParams.get("status") || ""; // toggles const perceptionGroup = searchParams.get("perception") === "true"; + const showBookmarks = searchParams.get("bookmarked") === "true"; const showFailed = searchParams.get("failed") !== "false"; // Default to true useEffect(() => { @@ -47,9 +50,9 @@ const GalleryPage = () => { useEffect(() => { getData( setLoading, setGallery, setTotalPages, - page, limit, technologyFilter, statusFilter, perceptionGroup, showFailed + page, limit, technologyFilter, statusFilter, perceptionGroup, showFailed, showBookmarks ); - }, [page, limit, perceptionGroup, statusFilter, technologyFilter, showFailed]); + }, [page, limit, perceptionGroup, statusFilter, technologyFilter, showFailed, showBookmarks]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -124,6 +127,13 @@ const GalleryPage = () => { }); }; + const handleBookmarkFilter = () => { + setSearchParams(prev => { + prev.set("bookmarked", (!showBookmarks).toString()); + return prev + }) + } + const handleToggleShowFailed = () => { setSearchParams(prev => { prev.set("failed", (!showFailed).toString()); @@ -131,6 +141,17 @@ const GalleryPage = () => { }); }; + const handleBookmarkClick = async (id: number) => { + const bookmarkUpdated = await bookmarkResult(id) + if (bookmarkUpdated) { + setGallery((prevGallery) => + prevGallery.map((item) => + item.id === id ? { ...item, bookmarked: !item.bookmarked } : item + ) + ); + } + } + const sortedTechnologies = useMemo(() => { if (!technology) return []; const selectedTechnologies = technologyFilter.split(',').filter(Boolean); @@ -196,21 +217,32 @@ const GalleryPage = () => { -
- - - -
- {screenshot.title || "Untitled"} -
-
- -

{screenshot.title || "Untitled"}

-
-
-
-
- {screenshot.url} +
+
+ + + +
+ {screenshot.title || "Untitled"} +
+
+ +

{screenshot.title || "Untitled"}

+
+
+
+
+ {screenshot.url} +
+
+
{ + e.preventDefault(); + e.stopPropagation(); + handleBookmarkClick(screenshot.id); + }} + > + {screenshot.bookmarked ? : }
@@ -332,6 +364,13 @@ const GalleryPage = () => { Group by Similar +
>, - setGallery: React.Dispatch>, + setGallery: React.Dispatch>, setTotalPages: React.Dispatch>, page: number, limit: number, @@ -32,6 +32,7 @@ const getData = async ( statusFilter: string, perceptionGroup: boolean, showFailed: boolean, + showBookmarks: boolean, ) => { setLoading(true); try { @@ -42,6 +43,7 @@ const getData = async ( status: statusFilter, perception: perceptionGroup ? 'true' : 'false', failed: showFailed ? 'true' : 'false', + bookmarked: showBookmarks ? true : false, }); setGallery(s.results); setTotalPages(Math.ceil(s.total_count / limit)); diff --git a/web/ui/src/pages/table/Table.tsx b/web/ui/src/pages/table/Table.tsx index 4421d649..9db7db0c 100644 --- a/web/ui/src/pages/table/Table.tsx +++ b/web/ui/src/pages/table/Table.tsx @@ -7,7 +7,9 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ArrowUpDown, XIcon } from "lucide-react"; +import { BookmarkIcon, BookmarkFilledIcon } from "@radix-ui/react-icons"; import * as apitypes from "@/lib/api/types"; +import { bookmarkResult } from "@/lib/api/bookmark"; import { copyToClipboard, getStatusColor } from "@/lib/common"; import { getData } from "./data"; @@ -17,7 +19,7 @@ export default function TablePage() { const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState("id"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - const [filterStatus, setFilterStatus] = useState<"all" | "success" | "error">("all"); + const [filterStatus, setFilterStatus] = useState<"all" | "success" | "error" | "bookmarked">("all"); useEffect(() => { getData(setLoading, setList); @@ -42,7 +44,8 @@ export default function TablePage() { const matchesStatus = filterStatus === "all" || (filterStatus === "success" && item.response_code < 400) || - (filterStatus === "error" && item.response_code >= 400); + (filterStatus === "error" && item.response_code >= 400) || + (filterStatus === "bookmarked" && item.bookmarked === true); return matchesSearch && matchesStatus; }) .sort((a, b) => { @@ -52,6 +55,17 @@ export default function TablePage() { }); }, [list, searchTerm, sortColumn, sortDirection, filterStatus]); + const handleBookmarkClick = async (id: number) => { + const bookmarkUpdated = await bookmarkResult(id) + if (bookmarkUpdated) { + setList((prevList) => + prevList.map((item) => + item.id === id ? { ...item, bookmarked: !item.bookmarked } : item + ) + ); + } + } + if (loading) return ; return ( @@ -69,7 +83,7 @@ export default function TablePage() {
- setFilterStatus(value)}> @@ -77,6 +91,7 @@ export default function TablePage() { All Success Error + Bookmarked
@@ -99,6 +114,7 @@ export default function TablePage() { Size {sortColumn === "content_length" && } Protocol + Bookmark @@ -128,6 +144,11 @@ export default function TablePage() { {(item.content_length / 1024).toFixed(2)} KB {item.protocol} + handleBookmarkClick(item.id)} + > + {item.bookmarked ? : } + ))} @@ -138,4 +159,4 @@ export default function TablePage() {
); -} \ No newline at end of file +}