625 lines
17 KiB
Go
625 lines
17 KiB
Go
package internal
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// getUserDB is a helper function to get the user's database connection from the context
|
|
func getUserDB(c echo.Context) (*sql.DB, error) {
|
|
userID, ok := c.Get("user_id").(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("user_id not found in context")
|
|
}
|
|
username, ok := c.Get("username").(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("username not found in context")
|
|
}
|
|
return GetUserDB(userID, username)
|
|
}
|
|
|
|
func HandleUpload(c echo.Context) error {
|
|
// Use a smaller memory limit for the form parsing itself (32 MB)
|
|
// Large files will be streamed directly to disk
|
|
err := c.Request().ParseMultipartForm(32 << 20) // 32 MB max in memory
|
|
if err != nil {
|
|
slog.Error("Error parsing form", "error", err)
|
|
return c.JSON(http.StatusBadRequest, UploadResponse{
|
|
Success: false,
|
|
Error: "Failed to parse form data. File may be too large or corrupted.",
|
|
})
|
|
}
|
|
|
|
file, header, err := c.Request().FormFile("file")
|
|
if err != nil {
|
|
slog.Error("Error getting file", "error", err)
|
|
return c.JSON(http.StatusBadRequest, UploadResponse{
|
|
Success: false,
|
|
Error: "Failed to get file from form",
|
|
})
|
|
}
|
|
defer file.Close()
|
|
|
|
slog.Info("Receiving file", "filename", header.Filename, "size", header.Size)
|
|
|
|
// Save uploaded file to temporary location first
|
|
tempFilePath, err := SaveUploadedFile(file, header.Filename)
|
|
if err != nil {
|
|
slog.Error("Error saving file", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, UploadResponse{
|
|
Success: false,
|
|
Error: "Failed to save uploaded file: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
slog.Info("File saved", "path", tempFilePath)
|
|
|
|
// Get user ID from context
|
|
userID, ok := c.Get("user_id").(string)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, UploadResponse{
|
|
Success: false,
|
|
Error: "User not authenticated",
|
|
})
|
|
}
|
|
|
|
// Get username from context
|
|
username, ok := c.Get("username").(string)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, UploadResponse{
|
|
Success: false,
|
|
Error: "User not authenticated",
|
|
})
|
|
}
|
|
|
|
// Start background processing with user context
|
|
go ProcessUploadedFile(userID, username, tempFilePath)
|
|
|
|
// Return immediately - client will poll /api/progress for status
|
|
return c.JSON(http.StatusOK, UploadResponse{
|
|
Success: true,
|
|
MessageCount: 0,
|
|
CallLogCount: 0,
|
|
Processing: true,
|
|
})
|
|
}
|
|
|
|
func HandleConversations(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
conversations, err := GetConversations(userDB, startDate, endDate)
|
|
if err != nil {
|
|
slog.Error("Error getting conversations", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get conversations",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, conversations)
|
|
}
|
|
|
|
func HandleMessages(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
address := c.QueryParam("address")
|
|
convType := c.QueryParam("type")
|
|
if address == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Address parameter required",
|
|
})
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
// If type is "call", return call logs instead of messages
|
|
if convType == "call" {
|
|
calls, err := GetCallLogs(userDB, address, startDate, endDate)
|
|
if err != nil {
|
|
slog.Error("Error getting call logs", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get call logs",
|
|
})
|
|
}
|
|
return c.JSON(http.StatusOK, calls)
|
|
}
|
|
|
|
// If type is "conversation", return combined messages and calls
|
|
if convType == "conversation" {
|
|
// Get user ID from context to fetch settings
|
|
userID, ok := c.Get("user_id").(string)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
|
"error": "User not authenticated",
|
|
})
|
|
}
|
|
|
|
// Fetch user settings
|
|
settings, err := GetUserSettings(userID)
|
|
if err != nil {
|
|
slog.Error("Error getting user settings", "error", err)
|
|
settings = GetDefaultSettings()
|
|
}
|
|
|
|
// Use user's configured limit as default, allow query param override
|
|
limit := settings.Conversations.MessageLimit
|
|
if limit <= 0 {
|
|
limit = 100000
|
|
}
|
|
offset := 0
|
|
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
|
limit = parsedLimit
|
|
}
|
|
}
|
|
|
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 {
|
|
offset = parsedOffset
|
|
}
|
|
}
|
|
|
|
activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset)
|
|
if err != nil {
|
|
slog.Error("Error getting activity", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get activity",
|
|
})
|
|
}
|
|
|
|
// Filter out calls if show_calls setting is false
|
|
if !settings.Conversations.ShowCalls {
|
|
filteredActivities := []ActivityItem{}
|
|
for _, activity := range activities {
|
|
if activity.Type != "call" {
|
|
filteredActivities = append(filteredActivities, activity)
|
|
}
|
|
}
|
|
activities = filteredActivities
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, activities)
|
|
}
|
|
|
|
messages, err := GetMessages(userDB, address, startDate, endDate)
|
|
if err != nil {
|
|
slog.Error("Error getting messages", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get messages",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, messages)
|
|
}
|
|
|
|
// HandleMediaItems returns only media (images/videos) for a conversation
|
|
func HandleMediaItems(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
address := c.QueryParam("address")
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
mediaItems, err := GetMediaByAddress(userDB, address, startDate, endDate)
|
|
if err != nil {
|
|
slog.Error("Error getting media items", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get media items",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, mediaItems)
|
|
}
|
|
|
|
func HandleActivity(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
// Parse pagination parameters
|
|
limit := 50 // default limit
|
|
offset := 0 // default offset
|
|
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if val, err := strconv.Atoi(limitStr); err == nil {
|
|
limit = val
|
|
}
|
|
}
|
|
|
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
if val, err := strconv.Atoi(offsetStr); err == nil {
|
|
offset = val
|
|
}
|
|
}
|
|
|
|
activities, err := GetActivity(userDB, startDate, endDate, limit, offset)
|
|
if err != nil {
|
|
slog.Error("Error getting activity", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get activity",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, activities)
|
|
}
|
|
|
|
func HandleCalls(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
// Parse pagination parameters
|
|
limit := 50 // default limit
|
|
offset := 0 // default offset
|
|
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if val, err := strconv.Atoi(limitStr); err == nil {
|
|
limit = val
|
|
}
|
|
}
|
|
|
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
if val, err := strconv.Atoi(offsetStr); err == nil {
|
|
offset = val
|
|
}
|
|
}
|
|
|
|
calls, err := GetAllCalls(userDB, startDate, endDate, limit, offset)
|
|
if err != nil {
|
|
slog.Error("Error getting calls", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get calls",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, calls)
|
|
}
|
|
|
|
func HandleDateRange(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
minDate, maxDate, err := GetDateRange(userDB)
|
|
if err != nil {
|
|
slog.Error("Error getting date range", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get date range",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"min_date": minDate,
|
|
"max_date": maxDate,
|
|
})
|
|
}
|
|
|
|
func HandleProgress(c echo.Context) error {
|
|
progress := GetUploadProgress()
|
|
if progress == nil {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "no_upload",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, progress)
|
|
}
|
|
|
|
func HandleMedia(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
// Get message ID from query parameter
|
|
messageID := c.QueryParam("id")
|
|
if messageID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Message ID required",
|
|
})
|
|
}
|
|
|
|
// Check if transcode is requested (for videos that browser can't play)
|
|
forceTranscode := c.QueryParam("transcode") == "true"
|
|
|
|
// Fetch media from database
|
|
media, contentType, err := GetMessageMedia(userDB, messageID)
|
|
if err != nil {
|
|
slog.Error("Error getting media", "error", err)
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": "Media not found",
|
|
})
|
|
}
|
|
|
|
if len(media) == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": "No media for this message",
|
|
})
|
|
}
|
|
|
|
// If transcode is requested and this is a video, try to convert it
|
|
if forceTranscode && strings.HasPrefix(contentType, "video/") {
|
|
slog.Info("Transcode requested for video", "messageID", messageID, "contentType", contentType)
|
|
convertedData, err := convertVideoToMP4(media)
|
|
if err != nil {
|
|
slog.Error("Failed to transcode video", "messageID", messageID, "error", err)
|
|
// Continue with original video if conversion fails
|
|
} else {
|
|
slog.Info("Successfully transcoded video", "messageID", messageID)
|
|
media = convertedData
|
|
contentType = "video/mp4"
|
|
}
|
|
}
|
|
|
|
slog.Debug("Serving media", "messageID", messageID, "contentType", contentType, "size", len(media))
|
|
|
|
// Set appropriate headers
|
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
|
c.Response().Header().Set("Accept-Ranges", "bytes") // Enable range requests for video streaming
|
|
|
|
// Check for Range header (needed for video playback)
|
|
rangeHeader := c.Request().Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
contentLength := int64(len(media))
|
|
var start, end int64 = 0, contentLength - 1
|
|
|
|
slog.Debug("Range request received", "messageID", messageID, "range", rangeHeader, "contentType", contentType, "contentLength", contentLength)
|
|
|
|
// Parse range header (e.g., "bytes=0-1023" or "bytes=0-")
|
|
n, _ := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
|
|
if n == 1 {
|
|
// Only start was specified (e.g., "bytes=0-")
|
|
end = contentLength - 1
|
|
} else if n == 0 {
|
|
// Invalid range, return 416 Range Not Satisfiable
|
|
slog.Warn("Invalid range header", "range", rangeHeader)
|
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
|
|
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
|
|
}
|
|
|
|
// Ensure valid range
|
|
if start < 0 || start >= contentLength || end >= contentLength || start > end {
|
|
slog.Warn("Range out of bounds", "start", start, "end", end, "contentLength", contentLength)
|
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
|
|
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
|
|
}
|
|
|
|
slog.Debug("Serving range", "start", start, "end", end, "size", end-start+1)
|
|
|
|
// Set response headers for partial content
|
|
c.Response().Header().Set("Content-Type", contentType)
|
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, contentLength))
|
|
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
|
c.Response().WriteHeader(http.StatusPartialContent)
|
|
|
|
// Write the requested range
|
|
_, writeErr := c.Response().Write(media[start : end+1])
|
|
return writeErr
|
|
}
|
|
|
|
// No range request - serve full content
|
|
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
|
|
return c.Blob(http.StatusOK, contentType, media)
|
|
}
|
|
|
|
func HandleSearch(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
// Get search query from query parameter
|
|
query := c.QueryParam("q")
|
|
if query == "" {
|
|
return c.JSON(http.StatusOK, []SearchResult{})
|
|
}
|
|
|
|
// Get limit from query parameter, default to 100
|
|
limit := 100
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
|
limit = parsedLimit
|
|
}
|
|
}
|
|
|
|
// Perform search
|
|
results, err := SearchMessages(userDB, query, limit)
|
|
if err != nil {
|
|
slog.Error("Error searching messages", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Search failed: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
// HandleAnalytics returns analytics data for the Summary tab
|
|
func HandleAnalytics(c echo.Context) error {
|
|
userDB, err := getUserDB(c)
|
|
if err != nil {
|
|
slog.Error("Error getting user database", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get user database",
|
|
})
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
|
|
if startStr := c.QueryParam("start"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err == nil {
|
|
startDate = &t
|
|
}
|
|
}
|
|
|
|
if endStr := c.QueryParam("end"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err == nil {
|
|
endDate = &t
|
|
}
|
|
}
|
|
|
|
// Default to top 10 contacts
|
|
topN := 10
|
|
if topStr := c.QueryParam("top"); topStr != "" {
|
|
if val, err := strconv.Atoi(topStr); err == nil && val > 0 && val <= 50 {
|
|
topN = val
|
|
}
|
|
}
|
|
|
|
// Timezone offset in minutes from UTC (e.g. -300 for UTC-5, 330 for UTC+5:30)
|
|
tzOffsetMinutes := 0
|
|
if tzStr := c.QueryParam("tz_offset"); tzStr != "" {
|
|
if val, err := strconv.Atoi(tzStr); err == nil && val >= -840 && val <= 840 {
|
|
tzOffsetMinutes = val
|
|
}
|
|
}
|
|
|
|
analytics, err := GetAnalytics(userDB, startDate, endDate, topN, tzOffsetMinutes)
|
|
if err != nil {
|
|
slog.Error("Error getting analytics", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get analytics",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, analytics)
|
|
}
|
|
|
|
// HandleVersion returns the application version
|
|
func HandleVersion(c echo.Context) error {
|
|
// Try to read version from version.json file first (Docker builds)
|
|
versionFile := "/app/version.json"
|
|
if data, err := os.ReadFile(versionFile); err == nil {
|
|
var versionData map[string]string
|
|
if err := json.Unmarshal(data, &versionData); err == nil {
|
|
return c.JSON(http.StatusOK, versionData)
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"version": "dev",
|
|
})
|
|
}
|