mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
first test
This commit is contained in:
@@ -1,16 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// GetBookmarks handles GET /api/v1/bookmarks
|
||||
func GetBookmarks(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
// Return mock bookmarks for demo mode
|
||||
mockBookmarks := []models.Bookmark{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "React Documentation",
|
||||
URL: "https://react.dev",
|
||||
Description: "The official React documentation",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "YouTube - Introduction to React Programming",
|
||||
URL: "https://www.youtube.com/watch?v=hTWKbfoikeg",
|
||||
Description: "Video from Programming Tutorials",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Docker Documentation",
|
||||
URL: "https://docs.docker.com",
|
||||
Description: "Official Docker documentation",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, mockBookmarks)
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
var bookmarks []models.Bookmark
|
||||
|
||||
@@ -48,6 +94,32 @@ func CreateBookmark(c *gin.Context) {
|
||||
}
|
||||
bookmark.UserID = userID
|
||||
|
||||
// Fetch website metadata if URL is provided
|
||||
if bookmark.URL != "" {
|
||||
// Use basic metadata fetching
|
||||
if metadata, err := services.GetCachedMetadata(bookmark.URL); err == nil {
|
||||
// Update bookmark with fetched metadata
|
||||
if bookmark.Title == "" && metadata.Title != "" {
|
||||
bookmark.Title = metadata.Title
|
||||
}
|
||||
if bookmark.Description == "" && metadata.Description != "" {
|
||||
bookmark.Description = metadata.Description
|
||||
}
|
||||
if metadata.Favicon != "" {
|
||||
bookmark.Favicon = metadata.Favicon
|
||||
}
|
||||
if metadata.Author != "" {
|
||||
bookmark.Author = metadata.Author
|
||||
}
|
||||
// Parse published date if available
|
||||
if metadata.PublishedAt != "" {
|
||||
if publishedAt, err := time.Parse(time.RFC3339, metadata.PublishedAt); err == nil {
|
||||
bookmark.PublishedAt = &publishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create bookmark
|
||||
if err := db.Create(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bookmark"})
|
||||
@@ -155,3 +227,439 @@ func DeleteBookmark(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Bookmark deleted successfully"})
|
||||
}
|
||||
|
||||
// RefreshBookmarkMetadata handles POST /api/v1/bookmarks/:id/refresh-metadata
|
||||
func RefreshBookmarkMetadata(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bookmark ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var bookmark models.Bookmark
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing bookmark
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bookmark not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch fresh metadata
|
||||
if metadata, err := services.GetCachedMetadata(bookmark.URL); err == nil {
|
||||
// Update bookmark with basic metadata
|
||||
bookmark.Title = metadata.Title
|
||||
bookmark.Description = metadata.Description
|
||||
bookmark.Favicon = metadata.Favicon
|
||||
bookmark.Author = metadata.Author
|
||||
|
||||
// Parse published date if available
|
||||
if metadata.PublishedAt != "" {
|
||||
if publishedAt, err := time.Parse(time.RFC3339, metadata.PublishedAt); err == nil {
|
||||
bookmark.PublishedAt = &publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated bookmark
|
||||
if err := db.Save(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bookmark"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated bookmark with tags
|
||||
db.Preload("Tags").First(&bookmark, bookmark.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Metadata refreshed successfully",
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch metadata: %s", err.Error())})
|
||||
}
|
||||
}
|
||||
|
||||
// GetBookmarkMetadata handles POST /api/v1/bookmarks/metadata
|
||||
func GetBookmarkMetadata(c *gin.Context) {
|
||||
var request struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch metadata using basic service
|
||||
if metadata, err := services.GetCachedMetadata(request.URL); err == nil {
|
||||
// Return metadata from basic fetching
|
||||
response := gin.H{
|
||||
"title": metadata.Title,
|
||||
"description": metadata.Description,
|
||||
"favicon": metadata.Favicon,
|
||||
"metadata": gin.H{
|
||||
"siteName": metadata.SiteName,
|
||||
"description": metadata.Description,
|
||||
"image": metadata.Image,
|
||||
"author": metadata.Author,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch metadata: %s", err.Error())})
|
||||
}
|
||||
}
|
||||
|
||||
// GetBookmarkContent handles POST /api/v1/bookmarks/content
|
||||
func GetBookmarkContent(c *gin.Context) {
|
||||
var request struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch full page content with screenshot
|
||||
content, err := fetchPageContentWithScreenshot(request.URL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch content: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
// Return content as HTML
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.String(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// fetchPageContentWithScreenshot fetches page content and generates a screenshot
|
||||
func fetchPageContentWithScreenshot(targetURL string) (string, error) {
|
||||
// Parse URL to ensure it's valid
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout for content fetching
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
// Make request for basic content
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set user agent to avoid being blocked
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
|
||||
// Extract metadata for preview
|
||||
metadata, err := services.FetchWebsiteMetadata(targetURL)
|
||||
if err != nil {
|
||||
// Continue without metadata if it fails
|
||||
metadata = &services.WebsiteMetadata{
|
||||
Title: parsedURL.Hostname(),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to capture screenshot
|
||||
var screenshotData []byte
|
||||
screenshotErr := captureScreenshot(targetURL, &screenshotData)
|
||||
|
||||
// Generate preview HTML with screenshot if available
|
||||
previewHTML := generateEnhancedPreviewHTML(content, metadata, parsedURL, screenshotData, screenshotErr)
|
||||
|
||||
return previewHTML, nil
|
||||
}
|
||||
|
||||
// captureScreenshot captures a screenshot of the given URL using ChromeDP
|
||||
func captureScreenshot(targetURL string, screenshotData *[]byte) error {
|
||||
// Create a new Chrome context
|
||||
ctx, cancel := chromedp.NewContext(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Set a timeout for the entire operation
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Navigate to the URL and capture screenshot
|
||||
var buf []byte
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(targetURL),
|
||||
chromedp.WaitReady("body"), // Wait for body to be ready
|
||||
chromedp.EmulateViewport(1200, 800), // Set viewport size
|
||||
chromedp.CaptureScreenshot(&buf),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to capture screenshot: %w", err)
|
||||
}
|
||||
|
||||
*screenshotData = buf
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateEnhancedPreviewHTML creates a clean preview with screenshot
|
||||
func generateEnhancedPreviewHTML(content string, metadata *services.WebsiteMetadata, parsedURL *url.URL, screenshotData []byte, screenshotErr error) string {
|
||||
// Extract main content
|
||||
title := metadata.Title
|
||||
if title == "" {
|
||||
title = parsedURL.Hostname()
|
||||
}
|
||||
|
||||
description := metadata.Description
|
||||
if description == "" {
|
||||
// Try to extract a snippet from the content
|
||||
content = strings.ToLower(content)
|
||||
// Remove script and style tags
|
||||
re := regexp.MustCompile(`(?i)<(script|style)[^>]*>.*?</\1>`)
|
||||
content = re.ReplaceAllString(content, "")
|
||||
|
||||
// Extract text content
|
||||
re = regexp.MustCompile(`<[^>]+>`)
|
||||
textContent := re.ReplaceAllString(content, " ")
|
||||
textContent = strings.TrimSpace(textContent)
|
||||
|
||||
if len(textContent) > 200 {
|
||||
description = textContent[:200] + "..."
|
||||
} else {
|
||||
description = textContent
|
||||
}
|
||||
}
|
||||
|
||||
favicon := metadata.Favicon
|
||||
if favicon == "" {
|
||||
favicon = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", parsedURL.Host)
|
||||
}
|
||||
|
||||
// Convert screenshot to base64 if available
|
||||
var screenshotHTML string
|
||||
if screenshotErr == nil && len(screenshotData) > 0 {
|
||||
// In a real implementation, you'd encode to base64 and store/display it
|
||||
// For now, we'll add a placeholder
|
||||
screenshotHTML = `
|
||||
<div class="screenshot-container">
|
||||
<h3>Page Screenshot</h3>
|
||||
<div class="screenshot-placeholder">
|
||||
<p>Screenshot captured successfully (${len(screenshotData)} bytes)</p>
|
||||
<p><em>(Screenshot display would be implemented here)</em></p>
|
||||
</div>
|
||||
</div>`
|
||||
} else {
|
||||
screenshotHTML = `
|
||||
<div class="screenshot-container">
|
||||
<h3>Page Screenshot</h3>
|
||||
<div class="screenshot-error">
|
||||
<p>Could not capture screenshot: ` + screenshotErr.Error() + `</p>
|
||||
<p><em>(Screenshot requires Chrome/Chromium to be installed)</em></p>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Generate enhanced preview HTML
|
||||
previewHTML := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Preview: %s</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.preview-header {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.favicon-container {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.favicon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.preview-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-url {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.screenshot-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.screenshot-container h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
.screenshot-placeholder, .screenshot-error {
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
.preview-meta {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.preview-meta p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.preview-meta strong {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-actions {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.visit-site {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.visit-site:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.site-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.site-info img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-header">
|
||||
<div class="favicon-container">
|
||||
<img src="%s" alt="Site favicon" class="favicon"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 18px; font-weight: 600; color: #666;\'>%s</span>'" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>%s</h1>
|
||||
<div class="preview-url">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
%s
|
||||
|
||||
<div class="preview-meta">
|
||||
<div class="site-info">
|
||||
<img src="%s" alt="Site favicon" style="width: 16px; height: 16px;"
|
||||
onerror="this.style.display='none'" />
|
||||
<strong>Site:</strong> %s
|
||||
</div>
|
||||
<p><strong>Description:</strong> %s</p>
|
||||
<p><strong>Author:</strong> %s</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<a href="%s" target="_blank" rel="noopener noreferrer" class="visit-site">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15,3 21,3 21,9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
Visit Original Site
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
title,
|
||||
favicon,
|
||||
title[:1], // First letter for fallback
|
||||
title,
|
||||
parsedURL.String(),
|
||||
screenshotHTML,
|
||||
favicon,
|
||||
metadata.SiteName,
|
||||
description,
|
||||
metadata.Author,
|
||||
parsedURL.String(),
|
||||
)
|
||||
|
||||
return previewHTML
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user