Initial commit
@@ -0,0 +1,44 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "backend/testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -tags 'fts5 heic' -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "backend", "frontend"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
frontend/node_modules
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
sbv
|
||||||
|
frontend/dist
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.air.toml
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
backend/testdata
|
||||||
|
*_test.go
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Backend
|
||||||
|
tmp/
|
||||||
|
*.db
|
||||||
|
messages.db
|
||||||
|
build-errors.log
|
||||||
|
sbv
|
||||||
|
sbv-server
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.env.local
|
||||||
|
frontend/.env
|
||||||
|
|
||||||
|
# General
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
._.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## Development
|
||||||
|
|
||||||
|
- Go 1.25 or higher
|
||||||
|
- Node.js 22 or higher
|
||||||
|
- Air (for Go hot reload): `go install github.com/air-verse/air@latest`
|
||||||
|
- SQLite built with FTS5 support (for full-text search)
|
||||||
|
- libheif, for conversion of images
|
||||||
|
- If not installed, HEIC images will display as placeholder images
|
||||||
|
- ffmpeg, for conversion of videos
|
||||||
|
- If not installed, 3gp videos won't play
|
||||||
|
|
||||||
|
Build with `go build -tags "fts5 heic"`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
go test -tags "fts5 heic"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Setup
|
||||||
|
|
||||||
|
1. Install Go dependencies:
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the backend with hot reload:
|
||||||
|
```bash
|
||||||
|
air
|
||||||
|
```
|
||||||
|
|
||||||
|
Or without hot reload:
|
||||||
|
```bash
|
||||||
|
go run -tags "fts5 heic" .
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will start on `http://localhost:8081`
|
||||||
|
|
||||||
|
## Frontend Setup
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install npm dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will start on `http://localhost:5173`
|
||||||
|
|
||||||
|
## Backend Hot Reload
|
||||||
|
|
||||||
|
The backend uses [Air](https://github.com/air-verse/air) for hot reload. When you save a `.go` file, Air will automatically rebuild and restart the server.
|
||||||
|
|
||||||
|
## Frontend Hot Reload
|
||||||
|
|
||||||
|
The frontend uses Vite's built-in hot module replacement (HMR). Changes to React components will be reflected instantly without a full page reload.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
1. **Start both servers** - Run the backend and frontend development servers
|
||||||
|
2. **Open the app** - Navigate to `http://localhost:5173` in your browser
|
||||||
|
3. **Upload backup** - Click "Upload Backup" and select your XML file
|
||||||
|
4. **Browse messages** - Click on conversations to view message threads
|
||||||
|
5. **Filter by date** - Use the date pickers to filter messages by date range
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy frontend package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build backend
|
||||||
|
FROM golang:bookworm AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for libheif and SQLite FTS5
|
||||||
|
# Using bookworm-backports to get a newer version of libheif
|
||||||
|
RUN echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list && \
|
||||||
|
apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
&& apt-get install -y -t bookworm-backports libheif-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY *.go ./
|
||||||
|
|
||||||
|
# Build with FTS5 support
|
||||||
|
RUN go build -tags "fts5 heic" -o sbv .
|
||||||
|
|
||||||
|
# Stage 3: Final runtime image
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
# Using bookworm-backports to get matching runtime library
|
||||||
|
RUN echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list && \
|
||||||
|
apt-get update && apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
wget \
|
||||||
|
ffmpeg \
|
||||||
|
&& apt-get install -y -t bookworm-backports libheif1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy backend binary
|
||||||
|
COPY --from=backend-builder /app/sbv .
|
||||||
|
|
||||||
|
# Copy frontend build
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
# Create data directory for database
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PORT=8081
|
||||||
|
ENV DB_PATH_PREFIX=/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["./sbv"]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SMS Backup Viewer (SBV)
|
||||||
|
|
||||||
|
A modern web application for viewing SMS and MMS message backups. Import your messages from "SMS Backup & Restore" XML files and browse them in a texting app-like interface.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
docker:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 8081:8081 \
|
||||||
|
-v $(pwd)/data:/data \
|
||||||
|
-e DB_PATH_PREFIX=/data \
|
||||||
|
lowcarbdev/sbv
|
||||||
|
```
|
||||||
|
|
||||||
|
docker-compose:
|
||||||
|
```
|
||||||
|
services:
|
||||||
|
sbv:
|
||||||
|
image: lowcarbdev/sbv
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
# Mount data directory for persistent database storage
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- PORT=8081
|
||||||
|
- DB_PATH_PREFIX=/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-user** - Create a username/password to log in
|
||||||
|
- **Import SMS Backup & Restore XML** - Upload XML files from the web interface.
|
||||||
|
- **Tested with large backups** - Works with multi-GB backups
|
||||||
|
- **SMS, MMS, and call logs support** - Read all types of call and message records.
|
||||||
|
- **Inline image and video** - View images or watch videos as you browse. Even works with Apple HEIC and 3gp videos.
|
||||||
|
- **Fast conversation filtering** - Skip to the right conversation.
|
||||||
|
- **Full-text search** - Find what you want fast.
|
||||||
|
- **Activity view** - See it as it happened.
|
||||||
|
- **vCard preview** - Preview the contents of contact cards (vCards)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Go with SQLite database
|
||||||
|
- **Frontend**: React with Vite and Bootstrap CSS
|
||||||
|
- **Database**: SQLite (stores messages, including media as BLOBs)
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
The Docker setup uses a bind mount to persist the database:
|
||||||
|
- Host path: `./data/sbv*.db`
|
||||||
|
- Container path: `/data/sbv*.db`
|
||||||
|
|
||||||
|
This ensures your data survives container restarts and updates.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build script for SMS/MMS Backup Viewer
|
||||||
|
# Builds with FTS5 (Full-Text Search) support enabled
|
||||||
|
# Optional: Build with HEIC support by setting HEIC=1 or passing 'heic' as an argument
|
||||||
|
# Usage: ./build.sh heic
|
||||||
|
# Or: HEIC=1 ./build.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if HEIC support should be enabled
|
||||||
|
BUILD_TAGS="fts5"
|
||||||
|
if [ "$1" = "heic" ] || [ "$HEIC" = "1" ]; then
|
||||||
|
BUILD_TAGS="fts5 heic"
|
||||||
|
echo "Building SMS/MMS Backup Viewer with FTS5 and HEIC support..."
|
||||||
|
echo "Note: This requires libheif library to be installed"
|
||||||
|
else
|
||||||
|
echo "Building SMS/MMS Backup Viewer with FTS5 support..."
|
||||||
|
echo "Note: HEIC images will use placeholders. Build with HEIC=1 ./build.sh or ./build.sh heic to enable HEIC conversion"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build -tags "$BUILD_TAGS" -o sbv .
|
||||||
|
|
||||||
|
echo "Build complete! Binary: ./sbv"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
sbv:
|
||||||
|
build: .
|
||||||
|
container_name: sbv
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
# Mount data directory for persistent database storage
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- PORT=8081
|
||||||
|
- DB_PATH_PREFIX=/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SMS Backup Viewer</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="SMS Backup Viewer" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
|
"react-datepicker": "^8.8.0",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width: 2.5rem; height: 2.5rem;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.6); } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "MyWebSite",
|
||||||
|
"short_name": "MySite",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||||
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from './contexts/AuthContext'
|
||||||
|
import ConversationList from './components/ConversationList'
|
||||||
|
import MessageThread from './components/MessageThread'
|
||||||
|
import Activity from './components/Activity'
|
||||||
|
import DateFilter from './components/DateFilter'
|
||||||
|
import Upload from './components/Upload'
|
||||||
|
import Search from './components/Search'
|
||||||
|
import ChangePasswordModal from './components/ChangePasswordModal'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [conversations, setConversations] = useState([])
|
||||||
|
const [conversationsLoading, setConversationsLoading] = useState(false)
|
||||||
|
const [selectedConversation, setSelectedConversation] = useState(null)
|
||||||
|
const [startDate, setStartDate] = useState(null)
|
||||||
|
const [endDate, setEndDate] = useState(null)
|
||||||
|
const [dateRange, setDateRange] = useState({ min: null, max: null })
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||||
|
const [searchFilter, setSearchFilter] = useState('')
|
||||||
|
|
||||||
|
// Search state (persisted across tab switches)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
const [searchExecuted, setSearchExecuted] = useState(false)
|
||||||
|
const [searchScrollPosition, setSearchScrollPosition] = useState(0)
|
||||||
|
|
||||||
|
// Derive activeView from URL
|
||||||
|
const activeView = location.pathname.startsWith('/activity')
|
||||||
|
? 'activity'
|
||||||
|
: location.pathname.startsWith('/search')
|
||||||
|
? 'search'
|
||||||
|
: 'conversations'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDateRange()
|
||||||
|
fetchConversations()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConversations()
|
||||||
|
}, [startDate, endDate])
|
||||||
|
|
||||||
|
// Sync selected conversation from URL
|
||||||
|
useEffect(() => {
|
||||||
|
const match = location.pathname.match(/^\/conversation\/(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
const address = decodeURIComponent(match[1])
|
||||||
|
// Find conversation by address
|
||||||
|
const conversation = conversations.find(c => c.address === address)
|
||||||
|
if (conversation) {
|
||||||
|
setSelectedConversation(conversation)
|
||||||
|
} else if (conversations.length > 0) {
|
||||||
|
// If conversation not found in list, create a minimal conversation object
|
||||||
|
setSelectedConversation({ address, contact_name: address, type: 'message' })
|
||||||
|
}
|
||||||
|
} else if (location.pathname === '/' || location.pathname === '/conversations') {
|
||||||
|
setSelectedConversation(null)
|
||||||
|
}
|
||||||
|
}, [location.pathname, conversations])
|
||||||
|
|
||||||
|
const fetchDateRange = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/daterange`)
|
||||||
|
setDateRange({
|
||||||
|
min: new Date(response.data.min_date),
|
||||||
|
max: new Date(response.data.max_date)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching date range:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConversations = async () => {
|
||||||
|
setConversationsLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (startDate) params.start = startDate.toISOString()
|
||||||
|
if (endDate) params.end = endDate.toISOString()
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/conversations`, { params })
|
||||||
|
setConversations(response.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching conversations:', error)
|
||||||
|
} finally {
|
||||||
|
setConversationsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadSuccess = () => {
|
||||||
|
setShowUpload(false)
|
||||||
|
fetchDateRange()
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectConversation = (conversation) => {
|
||||||
|
if (conversation) {
|
||||||
|
navigate(`/conversation/${encodeURIComponent(conversation.address)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewChange = (view) => {
|
||||||
|
if (view === 'activity') {
|
||||||
|
navigate('/activity')
|
||||||
|
} else if (view === 'search') {
|
||||||
|
navigate('/search')
|
||||||
|
} else {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter conversations based on search text
|
||||||
|
const filteredConversations = conversations.filter(conv => {
|
||||||
|
if (!searchFilter) return true
|
||||||
|
|
||||||
|
const searchLower = searchFilter.toLowerCase()
|
||||||
|
const nameMatch = conv.contact_name && conv.contact_name.toLowerCase().includes(searchLower)
|
||||||
|
const addressMatch = conv.address && conv.address.toLowerCase().includes(searchLower)
|
||||||
|
|
||||||
|
return nameMatch || addressMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vh-100 d-flex flex-column bg-light">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-primary bg-gradient text-white p-2 shadow-lg">
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<svg style={{width: '2.5rem', height: '2.5rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1 className="h2 mb-1 fw-bold">SMS Backup Viewer</h1>
|
||||||
|
<p className="mb-0 small opacity-75">View and browse your message history</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<div className="text-end">
|
||||||
|
<div className="small opacity-75">Logged in as</div>
|
||||||
|
<div className="fw-bold">{user?.username}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
className="btn btn-light btn-lg shadow d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
Upload Backup
|
||||||
|
</button>
|
||||||
|
<Dropdown align="end">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
variant="outline-light"
|
||||||
|
className="d-flex align-items-center gap-2"
|
||||||
|
style={{ backgroundColor: 'transparent', borderColor: 'rgba(255, 255, 255, 0.5)' }}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1.5rem', height: '1.5rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item onClick={() => setShowPasswordModal(true)}>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
Change Password
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Divider />
|
||||||
|
<Dropdown.Item onClick={logout}>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* View Switcher */}
|
||||||
|
<div className="bg-white border-bottom shadow-sm">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<ul className="nav nav-tabs border-0">
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeView === 'conversations' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewChange('conversations')}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
Conversations
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeView === 'search' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewChange('search')}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeView === 'activity' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewChange('activity')}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Filter */}
|
||||||
|
<div className="bg-white border-bottom shadow-sm">
|
||||||
|
<DateFilter
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
minDate={dateRange.min}
|
||||||
|
maxDate={dateRange.max}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-fill d-flex overflow-hidden gap-2 p-2">
|
||||||
|
{activeView === 'conversations' ? (
|
||||||
|
<>
|
||||||
|
{/* Conversation List */}
|
||||||
|
<div style={{width: '380px', minWidth: '380px', maxWidth: '380px', flexShrink: 0}} className="bg-white rounded-3 shadow overflow-hidden border">
|
||||||
|
<div className="bg-light border-bottom p-2">
|
||||||
|
<h2 className="h5 mb-2 d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
Conversations
|
||||||
|
</h2>
|
||||||
|
<div className="position-relative">
|
||||||
|
<svg style={{width: '1rem', height: '1rem', position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)'}} className="text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm ps-5"
|
||||||
|
placeholder="Search by name or number..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto" style={{height: 'calc(100% - 7rem)'}}>
|
||||||
|
<ConversationList
|
||||||
|
conversations={filteredConversations}
|
||||||
|
selectedConversation={selectedConversation}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
loading={conversationsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Thread */}
|
||||||
|
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||||
|
<MessageThread
|
||||||
|
conversation={selectedConversation}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : activeView === 'search' ? (
|
||||||
|
/* Search View */
|
||||||
|
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||||
|
<Search
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
results={searchResults}
|
||||||
|
setResults={setSearchResults}
|
||||||
|
loading={searchLoading}
|
||||||
|
setLoading={setSearchLoading}
|
||||||
|
searched={searchExecuted}
|
||||||
|
setSearched={setSearchExecuted}
|
||||||
|
scrollPosition={searchScrollPosition}
|
||||||
|
setScrollPosition={setSearchScrollPosition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Activity View */
|
||||||
|
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||||
|
<Activity
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
{showUpload && (
|
||||||
|
<Upload
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onSuccess={handleUploadSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Change Password Modal */}
|
||||||
|
{showPasswordModal && (
|
||||||
|
<ChangePasswordModal
|
||||||
|
onClose={() => setShowPasswordModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Password changed successfully
|
||||||
|
console.log('Password changed successfully')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,437 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import LazyMedia from './LazyMedia'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
function Activity({ startDate, endDate }) {
|
||||||
|
const [activities, setActivities] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const observerTarget = useRef(null)
|
||||||
|
const scrollContainerRef = useRef(null)
|
||||||
|
|
||||||
|
// Reset when date range changes
|
||||||
|
useEffect(() => {
|
||||||
|
setActivities([])
|
||||||
|
setOffset(0)
|
||||||
|
setHasMore(true)
|
||||||
|
fetchActivity(0, false)
|
||||||
|
}, [startDate, endDate])
|
||||||
|
|
||||||
|
const fetchActivity = async (currentOffset, append = false) => {
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(true)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: currentOffset
|
||||||
|
}
|
||||||
|
if (startDate) params.start = startDate.toISOString()
|
||||||
|
if (endDate) params.end = endDate.toISOString()
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/activity`, { params })
|
||||||
|
const newActivities = response.data || []
|
||||||
|
|
||||||
|
// If we got fewer items than the page size, we've reached the end
|
||||||
|
if (newActivities.length < PAGE_SIZE) {
|
||||||
|
setHasMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setActivities(prev => [...prev, ...newActivities])
|
||||||
|
} else {
|
||||||
|
setActivities(newActivities)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activity:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
console.log('loadMore called:', { loadingMore, hasMore, offset })
|
||||||
|
if (!loadingMore && hasMore) {
|
||||||
|
const newOffset = offset + PAGE_SIZE
|
||||||
|
setOffset(newOffset)
|
||||||
|
fetchActivity(newOffset, true)
|
||||||
|
}
|
||||||
|
}, [offset, loadingMore, hasMore])
|
||||||
|
|
||||||
|
// Set up intersection observer for infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
// Make sure both refs are available
|
||||||
|
if (!scrollContainerRef.current || !observerTarget.current) {
|
||||||
|
console.log('Refs not ready:', { scroll: !!scrollContainerRef.current, target: !!observerTarget.current })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Setting up IntersectionObserver', { hasMore, loadingMore, activitiesCount: activities.length })
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
console.log('Observer callback fired', {
|
||||||
|
isIntersecting: entries[0].isIntersecting,
|
||||||
|
hasMore,
|
||||||
|
loadingMore
|
||||||
|
})
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loadingMore) {
|
||||||
|
console.log('Intersection detected, loading more...')
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollContainerRef.current,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(observerTarget.current)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [loadMore, hasMore, loadingMore, activities])
|
||||||
|
|
||||||
|
const formatCallType = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 1: return { label: 'Incoming call', icon: '📞', color: 'success' }
|
||||||
|
case 2: return { label: 'Outgoing call', icon: '📱', color: 'primary' }
|
||||||
|
case 3: return { label: 'Missed call', icon: '📵', color: 'danger' }
|
||||||
|
case 4: return { label: 'Voicemail', icon: '🎙️', color: 'info' }
|
||||||
|
case 5: return { label: 'Rejected call', icon: '🚫', color: 'warning' }
|
||||||
|
case 6: return { label: 'Refused call', icon: '❌', color: 'danger' }
|
||||||
|
default: return { label: 'Call', icon: '📞', color: 'secondary' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${minutes}m ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhoneNumber = (phoneNumber) => {
|
||||||
|
if (!phoneNumber) return ''
|
||||||
|
|
||||||
|
// Handle comma-separated numbers (group conversations)
|
||||||
|
if (phoneNumber.includes(',')) {
|
||||||
|
const numbers = phoneNumber.split(',').map(n => n.trim())
|
||||||
|
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSinglePhoneNumber(phoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSinglePhoneNumber = (phoneNumber) => {
|
||||||
|
if (!phoneNumber) return ''
|
||||||
|
|
||||||
|
// Remove any non-numeric characters except leading +
|
||||||
|
let cleaned = phoneNumber.replace(/[^\d+]/g, '')
|
||||||
|
|
||||||
|
// Handle +1 prefix (US numbers)
|
||||||
|
if (cleaned.startsWith('+1') && cleaned.length === 12) {
|
||||||
|
// Format as +1 (XXX) XXX-XXXX
|
||||||
|
return `+1 (${cleaned.slice(2, 5)}) ${cleaned.slice(5, 8)}-${cleaned.slice(8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle numbers with + country code
|
||||||
|
if (cleaned.startsWith('+')) {
|
||||||
|
return cleaned // Return international numbers as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 11-digit numbers starting with 1 (US numbers without +)
|
||||||
|
if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||||
|
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 10-digit US numbers
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
return `+1 (${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is if format doesn't match
|
||||||
|
return phoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now - date
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (diffDays === 0) return `Today at ${timeStr}`
|
||||||
|
if (diffDays === 1) return `Yesterday at ${timeStr}`
|
||||||
|
if (diffDays < 7) return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageTypeLabel = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 1: return { label: 'Received', color: 'primary' }
|
||||||
|
case 2: return { label: 'Sent', color: 'success' }
|
||||||
|
case 3: return { label: 'Draft', color: 'secondary' }
|
||||||
|
case 4: return { label: 'Outbox', color: 'warning' }
|
||||||
|
case 5: return { label: 'Failed', color: 'danger' }
|
||||||
|
case 6: return { label: 'Queued', color: 'info' }
|
||||||
|
default: return { label: 'Message', color: 'secondary' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDisplaySubject = (subject) => {
|
||||||
|
if (!subject) return false
|
||||||
|
// Filter out protocol buffer/RCS subjects
|
||||||
|
if (subject.startsWith('proto:')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sender display name for a message in group conversations
|
||||||
|
const getSenderDisplayName = (message) => {
|
||||||
|
// For received messages, use the sender field if available
|
||||||
|
let senderPhone = message.sender
|
||||||
|
|
||||||
|
// If sender is empty, try to extract from addresses array
|
||||||
|
if (!senderPhone && message.addresses && message.addresses.length > 0) {
|
||||||
|
// Use the first address as the sender
|
||||||
|
senderPhone = message.addresses[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sender contains comma-separated numbers (shouldn't happen, but handle it),
|
||||||
|
// extract only the first one
|
||||||
|
if (senderPhone && senderPhone.includes(',')) {
|
||||||
|
senderPhone = senderPhone.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderPhone) return 'Unknown'
|
||||||
|
|
||||||
|
// Format as a single phone number (not as a group)
|
||||||
|
return formatSinglePhoneNumber(senderPhone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted">Loading activity...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg style={{width: '4rem', height: '4rem'}} className="text-muted mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-muted">No activity found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-100 d-flex flex-column">
|
||||||
|
<div className="bg-light border-bottom p-3">
|
||||||
|
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Activity Timeline
|
||||||
|
<span className="badge bg-primary ms-auto">{activities.length} items</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-3">
|
||||||
|
<div className="container-fluid">
|
||||||
|
{activities.map((activity, index) => {
|
||||||
|
if (activity.type === 'message' && activity.message) {
|
||||||
|
const msg = activity.message
|
||||||
|
const msgType = getMessageTypeLabel(msg.type)
|
||||||
|
|
||||||
|
// For MMS with multiple recipients, use the addresses array; otherwise use the single address
|
||||||
|
let displayAddress
|
||||||
|
if (msg.addresses && msg.addresses.length > 0) {
|
||||||
|
// Format each address and join with commas
|
||||||
|
displayAddress = msg.addresses.map(addr => formatPhoneNumber(addr)).join(', ')
|
||||||
|
} else {
|
||||||
|
// Fall back to the single address field
|
||||||
|
displayAddress = formatPhoneNumber(activity.address)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = activity.contact_name || displayAddress
|
||||||
|
|
||||||
|
// Check if this is a group conversation
|
||||||
|
const isGroupConversation = msg.addresses && msg.addresses.length > 1
|
||||||
|
const isSent = msg.type === 2
|
||||||
|
const showSenderLabel = isGroupConversation && !isSent
|
||||||
|
|
||||||
|
// Debug logging for ALL messages to understand what we're receiving
|
||||||
|
console.log('Message received:', {
|
||||||
|
id: msg.id,
|
||||||
|
addresses: msg.addresses,
|
||||||
|
addressesType: typeof msg.addresses,
|
||||||
|
addressesLength: msg.addresses?.length,
|
||||||
|
sender: msg.sender,
|
||||||
|
address: msg.address,
|
||||||
|
type: msg.type,
|
||||||
|
isSent,
|
||||||
|
isGroupConversation,
|
||||||
|
showSenderLabel,
|
||||||
|
body: msg.body?.substring(0, 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`msg-${msg.id}`} className="card mb-2 shadow-sm" style={{padding: '0.5rem'}}>
|
||||||
|
<div className="card-body py-2 px-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h6 className="mb-0">
|
||||||
|
{displayName}
|
||||||
|
</h6>
|
||||||
|
<small className="text-muted">{displayAddress}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-end">
|
||||||
|
<span className={`badge bg-${msgType.color}`}>{msgType.label}</span>
|
||||||
|
<br />
|
||||||
|
<small className="text-muted">{formatDate(activity.date)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sender label for received messages in group conversations */}
|
||||||
|
{showSenderLabel && (
|
||||||
|
<div className="mb-1 ps-2">
|
||||||
|
<small className="text-muted fw-semibold">
|
||||||
|
From: {getSenderDisplayName(msg)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldDisplaySubject(msg.subject) && (
|
||||||
|
<div className="mb-1">
|
||||||
|
<strong>Subject:</strong> {msg.subject}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg.body && (
|
||||||
|
<p className="card-text mb-1">{msg.body}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg.media_type && (
|
||||||
|
<LazyMedia
|
||||||
|
messageId={msg.id}
|
||||||
|
mediaType={msg.media_type}
|
||||||
|
className="mt-1"
|
||||||
|
alt="MMS attachment"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (activity.type === 'call' && activity.call) {
|
||||||
|
const call = activity.call
|
||||||
|
const callType = formatCallType(call.type)
|
||||||
|
const formattedAddress = formatPhoneNumber(activity.address)
|
||||||
|
const displayName = activity.contact_name || formattedAddress
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`call-${call.id}`} className="card mb-2 shadow-sm border-start border-4" style={{borderLeftColor: `var(--bs-${callType.color})`}}>
|
||||||
|
<div className="card-body py-2 px-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-start">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<div style={{fontSize: '1.25rem'}}>{callType.icon}</div>
|
||||||
|
<div>
|
||||||
|
<h6 className="mb-0">
|
||||||
|
{displayName}
|
||||||
|
</h6>
|
||||||
|
<small className="text-muted">{formattedAddress}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-end">
|
||||||
|
<span className={`badge bg-${callType.color}`}>{callType.label}</span>
|
||||||
|
<br />
|
||||||
|
<small className="text-muted">{formatDate(activity.date)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{call.duration > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<small className="text-muted">
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Duration: {formatDuration(call.duration)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Infinite scroll trigger */}
|
||||||
|
<div ref={observerTarget} style={{ height: '20px' }} />
|
||||||
|
|
||||||
|
{/* Loading more indicator */}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading more...</span>
|
||||||
|
</div>
|
||||||
|
<p className="small text-muted mt-2 mb-0">Loading more activities...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of results indicator */}
|
||||||
|
{!hasMore && activities.length > 0 && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<small className="text-muted">No more activities to load</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Activity
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal, Button, Form, Alert } from 'react-bootstrap'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
function ChangePasswordModal({ onClose, onSuccess }) {
|
||||||
|
const { changePassword } = useAuth()
|
||||||
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Validate fields
|
||||||
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||||
|
setError('All fields are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError('New password must be at least 6 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await changePassword(oldPassword, newPassword, confirmPassword)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to change password')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to change password. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!loading}>
|
||||||
|
<Modal.Header closeButton={!loading}>
|
||||||
|
<Modal.Title className="h4 fw-bold">Change Password</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" dismissible onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Current Password</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>New Password</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Must be at least 6 characters
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Confirm New Password</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !oldPassword || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangePasswordModal
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
function ConversationList({ conversations, selectedConversation, onSelectConversation, loading }) {
|
||||||
|
const formatDate = (date) => {
|
||||||
|
try {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateMessage = (message, maxLength = 50) => {
|
||||||
|
if (!message) return ''
|
||||||
|
if (message.length <= maxLength) return message
|
||||||
|
return message.substring(0, maxLength).trim() + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhoneNumber = (number) => {
|
||||||
|
if (!number) return 'Unknown'
|
||||||
|
|
||||||
|
// Handle comma-separated numbers (group conversations)
|
||||||
|
if (number.includes(',')) {
|
||||||
|
const numbers = number.split(',').map(n => n.trim())
|
||||||
|
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSinglePhoneNumber(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSinglePhoneNumber = (number) => {
|
||||||
|
if (!number) return 'Unknown'
|
||||||
|
|
||||||
|
// Remove all non-digit characters
|
||||||
|
const cleaned = number.replace(/\D/g, '')
|
||||||
|
|
||||||
|
// Handle 11-digit numbers (e.g., +1 country code)
|
||||||
|
if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||||
|
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 10-digit numbers (US format)
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other formats - try to format with spaces
|
||||||
|
if (cleaned.length > 10) {
|
||||||
|
// International format: +XX XXX XXX XXXX
|
||||||
|
return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return original if we can't format it nicely
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDisplaySubject = (subject) => {
|
||||||
|
if (!subject) return false
|
||||||
|
// Filter out protocol buffer/RCS subjects
|
||||||
|
if (subject.startsWith('proto:')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayName = (conv) => {
|
||||||
|
// If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number
|
||||||
|
if (conv.subject && shouldDisplaySubject(conv.subject)) {
|
||||||
|
if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) {
|
||||||
|
return conv.subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If contact_name is empty, null, or "(Unknown)", use formatted phone number
|
||||||
|
if (!conv.contact_name || conv.contact_name === '(Unknown)') {
|
||||||
|
return formatPhoneNumber(conv.address)
|
||||||
|
}
|
||||||
|
return conv.contact_name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConversationIcon = (type) => {
|
||||||
|
if (type === 'call') {
|
||||||
|
return (
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted fw-medium">Loading conversations...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100 text-muted p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg style={{width: '4rem', height: '4rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<p className="fw-medium text-dark">No conversations found</p>
|
||||||
|
<p className="small mt-2">Upload a backup file to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{conversations.map((conv, index) => {
|
||||||
|
const isSelected = selectedConversation &&
|
||||||
|
selectedConversation.address === conv.address &&
|
||||||
|
selectedConversation.type === conv.type
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${conv.type}-${conv.address}-${index}`}
|
||||||
|
onClick={() => onSelectConversation(conv)}
|
||||||
|
className={`list-group-item list-group-item-action ${
|
||||||
|
isSelected ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
style={{cursor: 'pointer'}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start gap-2">
|
||||||
|
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-white shadow-sm">
|
||||||
|
{getConversationIcon(conv.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
|
||||||
|
<div className="d-flex justify-content-between align-items-baseline mb-1 gap-2">
|
||||||
|
<h6 className="fw-semibold mb-0 text-truncate" style={{flex: '1 1 auto', minWidth: 0}}>
|
||||||
|
{getDisplayName(conv)}
|
||||||
|
</h6>
|
||||||
|
<small className="text-nowrap flex-shrink-0" style={{fontSize: '0.75rem'}}>
|
||||||
|
{formatDate(conv.last_date)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<p className="small mb-1 text-muted" style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}>
|
||||||
|
{truncateMessage(conv.last_message, 50)}
|
||||||
|
</p>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<span className="badge bg-secondary" style={{fontSize: '0.7rem'}}>
|
||||||
|
{conv.message_count} {conv.type === 'call' ? 'call' : 'message'}{conv.message_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConversationList
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import DatePicker from 'react-datepicker'
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
|
|
||||||
|
function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, onEndDateChange }) {
|
||||||
|
const clearDates = () => {
|
||||||
|
onStartDateChange(null)
|
||||||
|
onEndDateChange(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2 bg-light">
|
||||||
|
<div className="d-flex align-items-center gap-4 flex-wrap">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<label className="small fw-semibold mb-0">From:</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={startDate}
|
||||||
|
onChange={onStartDateChange}
|
||||||
|
selectsStart
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
minDate={minDate}
|
||||||
|
maxDate={maxDate}
|
||||||
|
dateFormat="MMM d, yyyy"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholderText="Select start date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<label className="small fw-semibold mb-0">To:</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={endDate}
|
||||||
|
onChange={onEndDateChange}
|
||||||
|
selectsEnd
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
minDate={startDate || minDate}
|
||||||
|
maxDate={maxDate}
|
||||||
|
dateFormat="MMM d, yyyy"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholderText="Select end date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(startDate || endDate) && (
|
||||||
|
<button
|
||||||
|
onClick={clearDates}
|
||||||
|
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Clear dates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DateFilter
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import VCardPreview from './VCardPreview'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" }) {
|
||||||
|
const [src, setSrc] = useState(null)
|
||||||
|
const [vcfData, setVcfData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const imgRef = useRef(null)
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
const observerRef = useRef(null)
|
||||||
|
const hasLoadedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset loaded state when messageId changes
|
||||||
|
hasLoadedRef.current = false
|
||||||
|
|
||||||
|
// Set up Intersection Observer for lazy loading
|
||||||
|
observerRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !hasLoadedRef.current) {
|
||||||
|
hasLoadedRef.current = true
|
||||||
|
loadMedia()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Only load images below viewport (not above) to prevent scroll jump
|
||||||
|
// rootMargin: top right bottom left
|
||||||
|
rootMargin: '50px 0px 200px 0px'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imgRef.current) {
|
||||||
|
observerRef.current.observe(imgRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messageId])
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// Check if this is a VCF file - fetch as text instead of blob
|
||||||
|
const isVCard = mediaType === 'text/x-vcard' ||
|
||||||
|
mediaType === 'text/vcard' ||
|
||||||
|
mediaType === 'text/directory'
|
||||||
|
|
||||||
|
if (isVCard) {
|
||||||
|
// Fetch VCF as text
|
||||||
|
const response = await axios.get(`${API_BASE}/media`, {
|
||||||
|
params: { id: messageId },
|
||||||
|
responseType: 'text'
|
||||||
|
})
|
||||||
|
setVcfData(response.data)
|
||||||
|
} else {
|
||||||
|
// Fetch other media as blob
|
||||||
|
const response = await axios.get(`${API_BASE}/media`, {
|
||||||
|
params: { id: messageId },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = response.data
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
setSrc(objectUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop observing once loaded - we don't need to track this element anymore
|
||||||
|
if (observerRef.current && imgRef.current) {
|
||||||
|
observerRef.current.unobserve(imgRef.current)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load media:', err)
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup object URL when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (src) {
|
||||||
|
URL.revokeObjectURL(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
// Handle ESC key to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e) => {
|
||||||
|
if (e.key === 'Escape' && showModal) {
|
||||||
|
setShowModal(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showModal) {
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [showModal])
|
||||||
|
|
||||||
|
// Pause original video when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal && videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
}
|
||||||
|
}, [showModal])
|
||||||
|
|
||||||
|
if (!mediaType) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = mediaType.startsWith('image/')
|
||||||
|
const isVideo = mediaType.startsWith('video/')
|
||||||
|
const isVCard = mediaType === 'text/x-vcard' ||
|
||||||
|
mediaType === 'text/vcard' ||
|
||||||
|
mediaType === 'text/directory'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={imgRef} className={className}>
|
||||||
|
{/* Placeholder shown before loading or while loading */}
|
||||||
|
{!src && !vcfData && !error && (
|
||||||
|
<div
|
||||||
|
className="bg-light rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: isVideo ? '16/9' : '3/4', // Common phone camera ratio
|
||||||
|
minHeight: isVideo ? '200px' : '300px', // Larger to prevent layout shift
|
||||||
|
maxHeight: '400px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
backgroundImage: 'linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef), linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef)',
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
backgroundPosition: '0 0, 10px 10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="spinner-border spinner-border-sm text-secondary mb-2" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div className="small text-muted">Loading {isImage ? 'image' : isVideo ? 'video' : isVCard ? 'contact' : 'media'}...</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted d-flex flex-column align-items-center">
|
||||||
|
{isImage && (
|
||||||
|
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isVideo && (
|
||||||
|
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isVCard && (
|
||||||
|
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!isImage && !isVideo && !isVCard && (
|
||||||
|
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<small className="text-muted">
|
||||||
|
{isImage ? 'Image' : isVideo ? 'Video' : isVCard ? 'Contact' : 'Attachment'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-warning mb-0 small">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
Failed to load media
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(src || vcfData) && !loading && !error && (
|
||||||
|
<>
|
||||||
|
{isImage && src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="img-fluid rounded shadow"
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '400px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
animation: 'fadeIn 0.3s ease-in',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isVideo && src && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
className="img-fluid rounded shadow"
|
||||||
|
src={src}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
}
|
||||||
|
setShowModal(true)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '400px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
animation: 'fadeIn 0.3s ease-in',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isVCard && vcfData && (
|
||||||
|
<VCardPreview vcfText={vcfData} messageId={messageId} />
|
||||||
|
)}
|
||||||
|
{!isImage && !isVideo && !isVCard && (
|
||||||
|
<div className="small p-2 rounded bg-light d-flex align-items-center gap-1">
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||||
|
</svg>
|
||||||
|
Attachment: {mediaType}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-screen modal */}
|
||||||
|
{showModal && (isImage || isVideo) && src && (
|
||||||
|
<div
|
||||||
|
className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '2rem'
|
||||||
|
}}
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
className="btn btn-light position-absolute top-0 end-0 m-3"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
style={{
|
||||||
|
zIndex: 10000,
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1.5rem', height: '1.5rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Media content - stop propagation to prevent closing when clicking on media */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="d-flex align-items-center justify-content-center"
|
||||||
|
style={{
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '95vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="rounded shadow-lg"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '95vh',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isVideo && (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="rounded shadow-lg"
|
||||||
|
src={src}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '95vh',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LazyMedia
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const [isLogin, setIsLogin] = useState(true)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { login, register } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError('Username and password are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLogin) {
|
||||||
|
if (username.trim().length < 3) {
|
||||||
|
setError('Username must be at least 3 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = isLogin
|
||||||
|
? await login(username.trim(), password)
|
||||||
|
: await register(username.trim(), password)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Authentication failed')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('An unexpected error occurred')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
setIsLogin(!isLogin)
|
||||||
|
setError('')
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6 col-lg-4">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h2 className="h4 mb-2">
|
||||||
|
<svg style={{width: '2rem', height: '2rem'}} className="text-primary me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
SMS Backup Viewer
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
{isLogin ? 'Sign in to your account' : 'Create a new account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="username" className="form-label">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="password" className="form-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLogin && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="confirmPassword" className="form-label">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
id="confirmPassword"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100 mb-3"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
{isLogin ? 'Signing in...' : 'Creating account...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{isLogin ? 'Sign In' : 'Create Account'}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link text-decoration-none"
|
||||||
|
onClick={toggleMode}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{isLogin
|
||||||
|
? "Don't have an account? Sign up"
|
||||||
|
: 'Already have an account? Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import LazyMedia from './LazyMedia'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
function MessageThread({ conversation, startDate, endDate }) {
|
||||||
|
const location = useLocation()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
||||||
|
const messageRefs = useRef({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversation) {
|
||||||
|
fetchItems()
|
||||||
|
} else {
|
||||||
|
setItems([])
|
||||||
|
}
|
||||||
|
}, [conversation, startDate, endDate])
|
||||||
|
|
||||||
|
// Scroll to specific message if messageId is in URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const messageId = params.get('messageId')
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
setHighlightedMessageId(messageId)
|
||||||
|
if (messageRefs.current[messageId]) {
|
||||||
|
const element = messageRefs.current[messageId]
|
||||||
|
|
||||||
|
// Function to wait for media in an element to load
|
||||||
|
const waitForMediaInElement = (elem) => {
|
||||||
|
const images = Array.from(elem.querySelectorAll('img'))
|
||||||
|
const videos = Array.from(elem.querySelectorAll('video'))
|
||||||
|
const media = [...images, ...videos]
|
||||||
|
|
||||||
|
if (media.length === 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPromises = media.map(mediaElement => {
|
||||||
|
if (mediaElement.complete || mediaElement.readyState >= 2) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
mediaElement.addEventListener('load', resolve, { once: true })
|
||||||
|
mediaElement.addEventListener('loadeddata', resolve, { once: true })
|
||||||
|
mediaElement.addEventListener('error', resolve, { once: true })
|
||||||
|
setTimeout(resolve, 3000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(mediaPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to perform the scroll
|
||||||
|
const scrollToElement = () => {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-stage scroll approach:
|
||||||
|
// 1. Initial scroll to get element near viewport (triggers lazy loading)
|
||||||
|
// 2. Wait for lazy-loaded media
|
||||||
|
// 3. Final scroll to correct position
|
||||||
|
setTimeout(() => {
|
||||||
|
// First scroll - instant to trigger lazy loading
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'instant',
|
||||||
|
block: 'center'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait a bit for lazy loading to trigger
|
||||||
|
setTimeout(() => {
|
||||||
|
// Wait for media to load
|
||||||
|
waitForMediaInElement(element).then(() => {
|
||||||
|
// Final smooth scroll to correct position
|
||||||
|
scrollToElement()
|
||||||
|
|
||||||
|
// Re-scroll after a delay to handle any late-loading media
|
||||||
|
setTimeout(scrollToElement, 500)
|
||||||
|
setTimeout(scrollToElement, 1500)
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHighlightedMessageId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items, location.search])
|
||||||
|
|
||||||
|
// Automatically scroll to the last message when opening a conversation
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const messageId = params.get('messageId')
|
||||||
|
|
||||||
|
// Only auto-scroll if there's no specific messageId in the URL
|
||||||
|
if (!messageId) {
|
||||||
|
// Find the last message (not a call) to scroll to
|
||||||
|
const lastItem = items[items.length - 1]
|
||||||
|
let lastMessageId = null
|
||||||
|
|
||||||
|
// Handle ActivityItem format vs direct Message format
|
||||||
|
if (lastItem.type === 'message' && lastItem.message) {
|
||||||
|
lastMessageId = lastItem.message.id
|
||||||
|
} else if (lastItem.type === 'call') {
|
||||||
|
// If last item is a call, find the last message before it
|
||||||
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
|
if (items[i].type === 'message' && items[i].message) {
|
||||||
|
lastMessageId = items[i].message.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (lastItem.id) {
|
||||||
|
// Direct message format
|
||||||
|
lastMessageId = lastItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMessageId && messageRefs.current[lastMessageId]) {
|
||||||
|
const element = messageRefs.current[lastMessageId]
|
||||||
|
|
||||||
|
// Function to wait for media in an element to load
|
||||||
|
const waitForMediaInElement = (elem) => {
|
||||||
|
const images = Array.from(elem.querySelectorAll('img'))
|
||||||
|
const videos = Array.from(elem.querySelectorAll('video'))
|
||||||
|
const media = [...images, ...videos]
|
||||||
|
|
||||||
|
if (media.length === 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPromises = media.map(mediaElement => {
|
||||||
|
if (mediaElement.complete || mediaElement.readyState >= 2) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
mediaElement.addEventListener('load', resolve, { once: true })
|
||||||
|
mediaElement.addEventListener('loadeddata', resolve, { once: true })
|
||||||
|
mediaElement.addEventListener('error', resolve, { once: true })
|
||||||
|
setTimeout(resolve, 3000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(mediaPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to perform the scroll
|
||||||
|
const scrollToElement = () => {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'instant',
|
||||||
|
block: 'end'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to last message after a short delay to ensure rendering is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
// First scroll to trigger lazy loading if needed
|
||||||
|
scrollToElement()
|
||||||
|
|
||||||
|
// Wait for media to load, then scroll again
|
||||||
|
setTimeout(() => {
|
||||||
|
waitForMediaInElement(element).then(() => {
|
||||||
|
scrollToElement()
|
||||||
|
// Re-scroll after a delay to handle any late-loading media
|
||||||
|
setTimeout(scrollToElement, 300)
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items, location.search])
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
address: conversation.address,
|
||||||
|
type: conversation.type
|
||||||
|
}
|
||||||
|
if (startDate) params.start = startDate.toISOString()
|
||||||
|
if (endDate) params.end = endDate.toISOString()
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/messages`, { params })
|
||||||
|
setItems(response.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching items:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (date) => {
|
||||||
|
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhoneNumber = (number) => {
|
||||||
|
if (!number) return 'Unknown'
|
||||||
|
|
||||||
|
// Handle comma-separated numbers (group conversations)
|
||||||
|
if (number.includes(',')) {
|
||||||
|
const numbers = number.split(',').map(n => n.trim())
|
||||||
|
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSinglePhoneNumber(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSinglePhoneNumber = (number) => {
|
||||||
|
if (!number) return 'Unknown'
|
||||||
|
|
||||||
|
// Remove all non-digit characters
|
||||||
|
const cleaned = number.replace(/\D/g, '')
|
||||||
|
|
||||||
|
// Handle 11-digit numbers (e.g., +1 country code)
|
||||||
|
if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||||
|
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 10-digit numbers (US format)
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other formats - try to format with spaces
|
||||||
|
if (cleaned.length > 10) {
|
||||||
|
// International format: +XX XXX XXX XXXX
|
||||||
|
return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return original if we can't format it nicely
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayName = (conv) => {
|
||||||
|
// If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number
|
||||||
|
if (conv.subject && shouldDisplaySubject(conv.subject)) {
|
||||||
|
if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) {
|
||||||
|
return conv.subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If contact_name is empty, null, or "(Unknown)", use formatted phone number
|
||||||
|
if (!conv.contact_name || conv.contact_name === '(Unknown)') {
|
||||||
|
return formatPhoneNumber(conv.address)
|
||||||
|
}
|
||||||
|
return conv.contact_name
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDisplaySubject = (subject) => {
|
||||||
|
if (!subject) return false
|
||||||
|
// Filter out protocol buffer/RCS subjects
|
||||||
|
if (subject.startsWith('proto:')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCallTypeInfo = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 1: return { label: 'Incoming', color: 'text-success', bgColor: 'bg-success', icon: '↓' }
|
||||||
|
case 2: return { label: 'Outgoing', color: 'text-primary', bgColor: 'bg-primary', icon: '↑' }
|
||||||
|
case 3: return { label: 'Missed', color: 'text-danger', bgColor: 'bg-danger', icon: '✕' }
|
||||||
|
case 4: return { label: 'Voicemail', color: 'text-info', bgColor: 'bg-info', icon: '⊙' }
|
||||||
|
case 5: return { label: 'Rejected', color: 'text-warning', bgColor: 'bg-warning', icon: '✕' }
|
||||||
|
case 6: return { label: 'Refused', color: 'text-secondary', bgColor: 'bg-secondary', icon: '✕' }
|
||||||
|
default: return { label: 'Call', color: 'text-secondary', bgColor: 'bg-secondary', icon: '○' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if conversation is a group conversation
|
||||||
|
// Handle both ActivityItem format (items[0].message) and direct Message format (items[0])
|
||||||
|
const isGroupConversation = items.length > 0 && (() => {
|
||||||
|
const firstItem = items[0]
|
||||||
|
// ActivityItem format: check message.addresses
|
||||||
|
if (firstItem.type === 'message' && firstItem.message) {
|
||||||
|
return firstItem.message.addresses && firstItem.message.addresses.length > 1
|
||||||
|
}
|
||||||
|
// Direct Message format: check addresses directly
|
||||||
|
return firstItem.addresses && firstItem.addresses.length > 1
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Get sender display name for a message
|
||||||
|
const getSenderDisplayName = (message) => {
|
||||||
|
// For received messages, use the sender field if available
|
||||||
|
let senderPhone = message.sender
|
||||||
|
|
||||||
|
// If sender is empty, try to extract from addresses array
|
||||||
|
// (exclude any number that might be "me" - this is a received message so sender is someone else)
|
||||||
|
if (!senderPhone && message.addresses && message.addresses.length > 0) {
|
||||||
|
// For now, use the first address as the sender
|
||||||
|
// In the future, we could exclude the current user's number
|
||||||
|
senderPhone = message.addresses[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sender contains comma-separated numbers (shouldn't happen, but handle it),
|
||||||
|
// extract only the first one
|
||||||
|
if (senderPhone && senderPhone.includes(',')) {
|
||||||
|
senderPhone = senderPhone.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderPhone) return 'Unknown'
|
||||||
|
|
||||||
|
// Format as a single phone number (not as a group)
|
||||||
|
return formatSinglePhoneNumber(senderPhone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg style={{width: '5rem', height: '5rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<p className="h5 text-dark">Select a conversation</p>
|
||||||
|
<p className="small mt-2">Choose a conversation from the list to view messages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted fw-medium">Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCallLog = conversation.type === 'call'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-column h-100">
|
||||||
|
{/* Thread Header */}
|
||||||
|
<div className="bg-light border-bottom p-4 shadow-sm">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<div className="p-3 rounded-circle bg-primary bg-gradient shadow">
|
||||||
|
{isCallLog ? (
|
||||||
|
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="h4 fw-bold mb-1">
|
||||||
|
{getDisplayName(conversation)}
|
||||||
|
</h2>
|
||||||
|
{/* Display phone numbers for conversations with addresses */}
|
||||||
|
{!isCallLog && items.length > 0 && (() => {
|
||||||
|
const firstItem = items[0]
|
||||||
|
// Get addresses from either ActivityItem.message or direct Message
|
||||||
|
const addresses = (firstItem.type === 'message' && firstItem.message)
|
||||||
|
? firstItem.message.addresses
|
||||||
|
: firstItem.addresses
|
||||||
|
return addresses && addresses.length > 0 && (
|
||||||
|
<div className="small text-muted mb-2">
|
||||||
|
{addresses.map((addr, idx) => (
|
||||||
|
<span key={idx}>
|
||||||
|
{formatPhoneNumber(addr)}
|
||||||
|
{idx < addresses.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div>
|
||||||
|
<span className="badge bg-primary">
|
||||||
|
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-fill overflow-auto p-4 bg-light">
|
||||||
|
{isCallLog ? (
|
||||||
|
// Call Log View
|
||||||
|
<div className="d-flex flex-column gap-3">
|
||||||
|
{items.map((call) => {
|
||||||
|
const typeInfo = getCallTypeInfo(call.type)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={call.id}
|
||||||
|
className="card shadow-sm border-2"
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<div className={`p-3 rounded-circle ${typeInfo.bgColor} bg-opacity-10`}>
|
||||||
|
<span className={`fs-4 ${typeInfo.color}`}>
|
||||||
|
{typeInfo.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`fw-semibold ${typeInfo.color}`}>
|
||||||
|
{typeInfo.label} Call
|
||||||
|
</div>
|
||||||
|
<div className="small text-muted mt-1 d-flex align-items-center gap-1">
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{formatTime(call.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-end">
|
||||||
|
<div className="h5 fw-bold mb-0">
|
||||||
|
{formatDuration(call.duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Unified Message and Call View
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
// Check if this is an ActivityItem (has type field) or a direct Message
|
||||||
|
const isActivityItem = item.type === 'message' || item.type === 'call'
|
||||||
|
const isCall = isActivityItem && item.type === 'call'
|
||||||
|
const message = isActivityItem ? item.message : item
|
||||||
|
const call = isActivityItem ? item.call : null
|
||||||
|
|
||||||
|
if (isCall && call) {
|
||||||
|
// Compact call representation - inline with messages
|
||||||
|
const typeInfo = getCallTypeInfo(call.type)
|
||||||
|
return (
|
||||||
|
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1">
|
||||||
|
<div className="badge bg-light text-dark border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||||
|
<span className={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
|
||||||
|
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
|
||||||
|
<span className="text-muted">·</span>
|
||||||
|
<span className="text-muted">{formatTime(call.date)}</span>
|
||||||
|
{call.duration > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted">·</span>
|
||||||
|
<span className="text-muted">{formatDuration(call.duration)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message rendering
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
const isSent = message.type === 2
|
||||||
|
const isHighlighted = highlightedMessageId === String(message.id)
|
||||||
|
const showSenderLabel = isGroupConversation && !isSent
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`d-flex ${isSent ? 'justify-content-end' : 'justify-content-start'}`}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '70%' }}>
|
||||||
|
{/* Sender label for received messages in group conversations */}
|
||||||
|
{showSenderLabel && (
|
||||||
|
<div className="small text-muted mb-1 ms-2" style={{ fontSize: '0.7rem' }}>
|
||||||
|
{getSenderDisplayName(message)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={(el) => (messageRefs.current[message.id] = el)}
|
||||||
|
className={`card shadow-sm ${
|
||||||
|
isSent
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white'
|
||||||
|
} ${
|
||||||
|
isHighlighted
|
||||||
|
? 'border-warning border-3'
|
||||||
|
: 'border-2'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
padding: '0.5em',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body py-1 px-2">
|
||||||
|
{message.body && (
|
||||||
|
<div style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem', lineHeight: '1.3'}}>
|
||||||
|
{message.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.media_type && (
|
||||||
|
<LazyMedia
|
||||||
|
messageId={message.id}
|
||||||
|
mediaType={message.media_type}
|
||||||
|
className="mt-1"
|
||||||
|
alt="MMS attachment"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`mt-1 d-flex align-items-center gap-1 ${
|
||||||
|
isSent ? 'text-white-50' : 'text-muted'
|
||||||
|
}`}
|
||||||
|
style={{fontSize: '0.75rem'}}
|
||||||
|
>
|
||||||
|
<svg style={{width: '0.7rem', height: '0.7rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{formatTime(message.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageThread
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-vh-100 d-flex align-items-center justify-content-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
function Search({ searchQuery, setSearchQuery, results, setResults, loading, setLoading, searched, setSearched, scrollPosition, setScrollPosition }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const scrollContainerRef = useRef(null)
|
||||||
|
|
||||||
|
const handleSearch = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!searchQuery.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setSearched(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/search`, {
|
||||||
|
params: { q: searchQuery, limit: 1000 }
|
||||||
|
})
|
||||||
|
setResults(response.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching:', error)
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultClick = (result) => {
|
||||||
|
// Navigate to the conversation with the message ID as a query parameter
|
||||||
|
navigate(`/conversation/${encodeURIComponent(result.address)}?messageId=${result.message_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhoneNumber = (phoneNumber) => {
|
||||||
|
if (!phoneNumber) return ''
|
||||||
|
|
||||||
|
// Handle comma-separated numbers (group conversations)
|
||||||
|
if (phoneNumber.includes(',')) {
|
||||||
|
const numbers = phoneNumber.split(',').map(n => n.trim())
|
||||||
|
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSinglePhoneNumber(phoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSinglePhoneNumber = (phoneNumber) => {
|
||||||
|
if (!phoneNumber) return ''
|
||||||
|
|
||||||
|
// Remove +1 prefix if present
|
||||||
|
const cleaned = phoneNumber.replace(/^\+1/, '')
|
||||||
|
|
||||||
|
// Format as (XXX) XXX-XXXX
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return phoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll position when component mounts or results change
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current && scrollPosition > 0) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollPosition
|
||||||
|
}
|
||||||
|
}, [scrollPosition])
|
||||||
|
|
||||||
|
// Save scroll position when user scrolls
|
||||||
|
const handleScroll = (e) => {
|
||||||
|
if (e.target.scrollTop !== scrollPosition) {
|
||||||
|
setScrollPosition(e.target.scrollTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-100 d-flex flex-column">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-light border-bottom p-3">
|
||||||
|
<h2 className="h5 mb-3 d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Search Messages
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch}>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search message contents..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading || !searchQuery.trim()}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Search'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{searched && !loading && (
|
||||||
|
<div className="mt-2 small text-muted">
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
Found <strong>{results.length.toLocaleString()}</strong> result{results.length !== 1 ? 's' : ''}
|
||||||
|
{results.length >= 1000 && ' (limited to first 1000)'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'No results found'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-fill overflow-auto p-3">
|
||||||
|
{!searched ? (
|
||||||
|
<div className="text-center text-muted py-5">
|
||||||
|
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="lead">Search for messages</p>
|
||||||
|
<p className="small">Enter a search term to find messages across all conversations</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted">Searching...</p>
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="text-center text-muted py-5">
|
||||||
|
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="lead">No results found</p>
|
||||||
|
<p className="small">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-2">
|
||||||
|
{results.map((result) => (
|
||||||
|
<div key={result.message_id} className="col-12">
|
||||||
|
<div
|
||||||
|
className="card h-100 shadow-sm"
|
||||||
|
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
|
||||||
|
onClick={() => handleResultClick(result)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = '0 0.5rem 1rem rgba(0,0,0,0.15)'
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = ''
|
||||||
|
e.currentTarget.style.transform = ''
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div className="flex-fill">
|
||||||
|
<h6 className="card-title mb-1 fw-bold">
|
||||||
|
{result.contact_name || formatPhoneNumber(result.address) || 'Unknown'}
|
||||||
|
</h6>
|
||||||
|
{result.contact_name && (
|
||||||
|
<div className="small text-muted">
|
||||||
|
{formatPhoneNumber(result.address)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<small className="text-muted text-nowrap ms-2">
|
||||||
|
{format(new Date(result.date), 'MMM d, yyyy')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="card-text small text-muted"
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||||
|
style={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Search
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Modal, Button, Form, Alert, Spinner, ProgressBar } from 'react-bootstrap'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
function Upload({ onClose, onSuccess }) {
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
const [progress, setProgress] = useState(null)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const [currentStep, setCurrentStep] = useState(1) // 1 = upload, 2 = processing
|
||||||
|
const [currentFileIndex, setCurrentFileIndex] = useState(0)
|
||||||
|
const [totalFiles, setTotalFiles] = useState(0)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files)
|
||||||
|
setFiles(selectedFiles)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnter = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!uploading) {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
// Only set dragging to false if we're leaving the drop zone itself
|
||||||
|
if (e.currentTarget === e.target) {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (uploading) return
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
|
// Filter to only accept XML files
|
||||||
|
const xmlFiles = droppedFiles.filter(file => file.name.toLowerCase().endsWith('.xml'))
|
||||||
|
|
||||||
|
if (xmlFiles.length === 0) {
|
||||||
|
setError('Please drop only XML files')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmlFiles.length < droppedFiles.length) {
|
||||||
|
setError(`Only ${xmlFiles.length} of ${droppedFiles.length} files are XML files. Non-XML files were ignored.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(xmlFiles)
|
||||||
|
setSuccess(null)
|
||||||
|
if (xmlFiles.length === droppedFiles.length) {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setError('Please select at least one file')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
setProgress(null)
|
||||||
|
setTotalFiles(files.length)
|
||||||
|
setCurrentFileIndex(0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process each file sequentially
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
setCurrentFileIndex(i + 1)
|
||||||
|
|
||||||
|
await uploadSingleFile(file)
|
||||||
|
|
||||||
|
// If this was the last file, show success and close
|
||||||
|
if (i === files.length - 1) {
|
||||||
|
setSuccess(`Successfully imported all ${files.length} file${files.length !== 1 ? 's' : ''}`)
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploading(false)
|
||||||
|
onSuccess()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload error:', err)
|
||||||
|
if (err.code === 'ECONNABORTED') {
|
||||||
|
setError('Upload timeout. The file may be too large.')
|
||||||
|
} else {
|
||||||
|
setError(err.response?.data?.error || err.message || 'Upload failed')
|
||||||
|
}
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSingleFile = async (file) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
setUploadProgress(0)
|
||||||
|
setCurrentStep(1)
|
||||||
|
|
||||||
|
// Step 1: Upload file to server
|
||||||
|
const response = await axios.post(`${API_BASE}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
timeout: 300000, // 5 minute timeout for file upload
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
// This tracks the HTTP upload progress (file transfer to disk)
|
||||||
|
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
setUploadProgress(percentCompleted) // Show actual upload progress 0-100%
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// File uploaded successfully, move to step 2
|
||||||
|
setUploadProgress(0) // Reset for processing step
|
||||||
|
setCurrentStep(2)
|
||||||
|
|
||||||
|
// Wait for processing to complete
|
||||||
|
await waitForProcessingComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForProcessingComplete = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const checkProgress = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/progress`)
|
||||||
|
const data = response.data
|
||||||
|
|
||||||
|
if (!data || data.status === 'no_upload') {
|
||||||
|
clearInterval(checkProgress)
|
||||||
|
// Reject instead of resolve so the error is caught by handleUpload
|
||||||
|
reject(new Error('Processing status unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(data)
|
||||||
|
|
||||||
|
// Calculate processing progress (0-100% for step 2)
|
||||||
|
const total = data.total_messages || 1
|
||||||
|
const processed = data.processed_messages || 0
|
||||||
|
const processingPercent = Math.min(Math.round((processed / total) * 100), 100)
|
||||||
|
setUploadProgress(processingPercent)
|
||||||
|
|
||||||
|
// Check if completed
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
clearInterval(checkProgress)
|
||||||
|
setUploadProgress(100)
|
||||||
|
// Just resolve - don't call onSuccess() here since we're processing multiple files
|
||||||
|
// The main handleUpload() function will handle success after all files are done
|
||||||
|
resolve()
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(checkProgress)
|
||||||
|
// Reject instead of resolve so the error is caught by handleUpload
|
||||||
|
reject(new Error(data.error_message || 'Processing failed'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking progress:', err)
|
||||||
|
}
|
||||||
|
}, 500) // Check every 500ms for more responsive updates
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!uploading}>
|
||||||
|
<Modal.Header closeButton={!uploading}>
|
||||||
|
<Modal.Title className="h4 fw-bold">Upload Backup</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex align-items-center gap-2 text-muted mb-3">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<small>Select or drag and drop one or more XML files from SMS Backup & Restore app</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Group>
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
style={{
|
||||||
|
border: isDragging ? '2px dashed #0d6efd' : '2px dashed #dee2e6',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '2rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: isDragging ? '#f0f7ff' : '#f8f9fa',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: uploading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-3">
|
||||||
|
<svg
|
||||||
|
style={{width: '3rem', height: '3rem'}}
|
||||||
|
className={isDragging ? "text-primary" : "text-muted"}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={isDragging ? "text-primary fw-semibold" : "text-muted"}>
|
||||||
|
{isDragging ? (
|
||||||
|
<div>Drop XML files here</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">Drag and drop XML files here</div>
|
||||||
|
<div className="text-muted small">or</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
accept=".xml"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={uploading}
|
||||||
|
multiple
|
||||||
|
style={{
|
||||||
|
maxWidth: '250px',
|
||||||
|
margin: '0 auto'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{files.length > 0 && !uploading && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Form.Text className="text-success d-flex align-items-center gap-1">
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{files.length} file{files.length !== 1 ? 's' : ''} selected
|
||||||
|
</Form.Text>
|
||||||
|
<div className="mt-2" style={{maxHeight: '150px', overflowY: 'auto'}}>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={index} className="small text-muted">
|
||||||
|
{index + 1}. {file.name} ({(file.size / (1024 * 1024)).toFixed(2)} MB)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<div className="mb-3">
|
||||||
|
{totalFiles > 1 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<small className="text-muted fw-semibold">
|
||||||
|
Processing file {currentFileIndex} of {totalFiles}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<small className="text-muted fw-semibold">
|
||||||
|
Step {currentStep} of 2: {currentStep === 1 ? 'Uploading file' : 'Processing messages'}
|
||||||
|
</small>
|
||||||
|
<small className="text-muted fw-bold">{uploadProgress}%</small>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
now={uploadProgress}
|
||||||
|
variant={uploadProgress === 100 && currentStep === 2 ? "success" : "primary"}
|
||||||
|
striped={!(uploadProgress === 100 && currentStep === 2)}
|
||||||
|
animated={!(uploadProgress === 100 && currentStep === 2)}
|
||||||
|
/>
|
||||||
|
{currentStep === 1 && files[currentFileIndex - 1] && (
|
||||||
|
<small className="text-muted mt-2 d-block">
|
||||||
|
Uploading {files[currentFileIndex - 1].name} ({(files[currentFileIndex - 1].size / (1024 * 1024)).toFixed(2)} MB) to server...
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && progress && (
|
||||||
|
<small className="text-muted mt-2 d-block">
|
||||||
|
{progress.processed_messages?.toLocaleString() || 0} / {progress.total_messages?.toLocaleString() || '?'} messages imported
|
||||||
|
{progress.processed_calls > 0 && `, ${progress.processed_calls?.toLocaleString()} calls`}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && !progress && (
|
||||||
|
<small className="text-muted mt-2 d-block">
|
||||||
|
Starting import process...
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert variant="success" className="d-flex align-items-center gap-2">
|
||||||
|
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={uploading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleUpload} disabled={uploading || files.length === 0}>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : 'Upload'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Upload
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { parseVCard, formatAddress, formatBirthday } from '../utils/vcfParser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VCardPreview component for displaying vCard (contact) files
|
||||||
|
*/
|
||||||
|
function VCardPreview({ vcfText, messageId }) {
|
||||||
|
const [contact, setContact] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadVCard = () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!vcfText) {
|
||||||
|
throw new Error('No VCF data provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContact = parseVCard(vcfText)
|
||||||
|
setContact(parsedContact)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading vCard:', err)
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vcfText) {
|
||||||
|
loadVCard()
|
||||||
|
}
|
||||||
|
}, [vcfText])
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
try {
|
||||||
|
// Create blob from VCF text
|
||||||
|
const blob = new Blob([vcfText], { type: 'text/vcard' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${contact?.name || 'contact'}.vcf`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading vCard:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted small mb-0 mt-2">Loading contact...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm border-danger" style={{ maxWidth: '400px' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="text-danger small">
|
||||||
|
<svg className="bi me-1" width="16" height="16" fill="currentColor">
|
||||||
|
<use xlinkHref="#exclamation-triangle-fill" />
|
||||||
|
</svg>
|
||||||
|
Error loading contact: {error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Header with photo and name */}
|
||||||
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
{contact.photo ? (
|
||||||
|
<img
|
||||||
|
src={contact.photo}
|
||||||
|
alt={contact.name}
|
||||||
|
className="rounded-circle me-3"
|
||||||
|
style={{ width: '64px', height: '64px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3"
|
||||||
|
style={{ width: '64px', height: '64px', fontSize: '24px', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{contact.name ? contact.name.charAt(0).toUpperCase() : '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<h5 className="card-title mb-1">{contact.name || contact.formattedName || 'Unknown Contact'}</h5>
|
||||||
|
{contact.title && <p className="text-muted small mb-0">{contact.title}</p>}
|
||||||
|
{contact.organization && <p className="text-muted small mb-0">{contact.organization}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Details */}
|
||||||
|
<div className="vcard-details">
|
||||||
|
{/* Phone Numbers */}
|
||||||
|
{contact.phoneNumbers.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
|
||||||
|
</svg>
|
||||||
|
Phone
|
||||||
|
</div>
|
||||||
|
{contact.phoneNumbers.map((phone, index) => (
|
||||||
|
<div key={index} className="small mb-1">
|
||||||
|
<span className="text-muted">{phone.type}:</span>{' '}
|
||||||
|
<a href={`tel:${phone.number}`} className="text-decoration-none">
|
||||||
|
{phone.number}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Addresses */}
|
||||||
|
{contact.emails.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||||
|
</svg>
|
||||||
|
Email
|
||||||
|
</div>
|
||||||
|
{contact.emails.map((email, index) => (
|
||||||
|
<div key={index} className="small mb-1">
|
||||||
|
<span className="text-muted">{email.type}:</span>{' '}
|
||||||
|
<a href={`mailto:${email.address}`} className="text-decoration-none">
|
||||||
|
{email.address}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
{contact.addresses.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5ZM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5 5 5Z"/>
|
||||||
|
</svg>
|
||||||
|
Address
|
||||||
|
</div>
|
||||||
|
{contact.addresses.map((addr, index) => {
|
||||||
|
const formatted = formatAddress(addr)
|
||||||
|
return formatted ? (
|
||||||
|
<div key={index} className="small mb-1">
|
||||||
|
<span className="text-muted">{addr.type}:</span> {formatted}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Birthday */}
|
||||||
|
{contact.birthday && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4V.5zM16 14V5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zm-3.5-7h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
</svg>
|
||||||
|
Birthday
|
||||||
|
</div>
|
||||||
|
<div className="small">{formatBirthday(contact.birthday)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
{contact.url && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
Website
|
||||||
|
</div>
|
||||||
|
<div className="small">
|
||||||
|
<a href={contact.url} target="_blank" rel="noopener noreferrer" className="text-decoration-none">
|
||||||
|
{contact.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{contact.note && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="small text-muted fw-bold mb-1">
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M5 0h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2 2 2 0 0 1-2 2H3a2 2 0 0 1-2-2h1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1H1a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1H3a2 2 0 0 1 2-2z"/>
|
||||||
|
<path d="M1 6v-.5a.5.5 0 0 1 1 0V6h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V9h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 2.5v.5H.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1H2v-.5a.5.5 0 0 0-1 0z"/>
|
||||||
|
</svg>
|
||||||
|
Note
|
||||||
|
</div>
|
||||||
|
<div className="small text-muted">{contact.note}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Button */}
|
||||||
|
<div className="d-grid">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
|
</svg>
|
||||||
|
Download Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VCardPreview
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// Check if user is already authenticated on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/auth/me`, {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
setUser(response.data.user)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Not authenticated, that's okay
|
||||||
|
setUser(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE}/auth/login`,
|
||||||
|
{ username, password },
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setUser(response.data.user)
|
||||||
|
return { success: true }
|
||||||
|
} else {
|
||||||
|
setError(response.data.error || 'Login failed')
|
||||||
|
return { success: false, error: response.data.error }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error.response?.data?.error || 'Login failed. Please try again.'
|
||||||
|
setError(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (username, password) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE}/auth/register`,
|
||||||
|
{ username, password },
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setUser(response.data.user)
|
||||||
|
return { success: true }
|
||||||
|
} else {
|
||||||
|
setError(response.data.error || 'Registration failed')
|
||||||
|
return { success: false, error: response.data.error }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error.response?.data?.error || 'Registration failed. Please try again.'
|
||||||
|
setError(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/auth/logout`, {}, { withCredentials: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePassword = async (oldPassword, newPassword, confirmPassword) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE}/auth/change-password`,
|
||||||
|
{ old_password: oldPassword, new_password: newPassword, confirm_password: confirmPassword },
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
return { success: true }
|
||||||
|
} else {
|
||||||
|
setError(response.data.error || 'Password change failed')
|
||||||
|
return { success: false, error: response.data.error }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error.response?.data?.error || 'Password change failed. Please try again.'
|
||||||
|
setError(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
changePassword,
|
||||||
|
isAuthenticated: !!user
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import axios from 'axios'
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
import './index.css'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import Login from './components/Login.jsx'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||||
|
|
||||||
|
// Configure axios to include credentials with all requests
|
||||||
|
axios.defaults.withCredentials = true
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/*" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<App />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* Parse vCard (VCF) format and extract contact information
|
||||||
|
* Supports vCard 2.1, 3.0, and 4.0 formats
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a vCard file and return structured contact data
|
||||||
|
* @param {string} vcfText - The raw vCard text content
|
||||||
|
* @returns {Object} Parsed contact information
|
||||||
|
*/
|
||||||
|
export function parseVCard(vcfText) {
|
||||||
|
const contact = {
|
||||||
|
version: '',
|
||||||
|
name: '',
|
||||||
|
formattedName: '',
|
||||||
|
phoneNumbers: [],
|
||||||
|
emails: [],
|
||||||
|
addresses: [],
|
||||||
|
organization: '',
|
||||||
|
title: '',
|
||||||
|
photo: null,
|
||||||
|
birthday: '',
|
||||||
|
url: '',
|
||||||
|
note: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into lines and handle line folding (continuation lines starting with space/tab)
|
||||||
|
const lines = unfoldLines(vcfText)
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const [property, value] = parseVCardLine(line)
|
||||||
|
|
||||||
|
if (!property) continue
|
||||||
|
|
||||||
|
const { name, params } = parseProperty(property)
|
||||||
|
|
||||||
|
switch (name.toUpperCase()) {
|
||||||
|
case 'VERSION':
|
||||||
|
contact.version = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'FN':
|
||||||
|
contact.formattedName = decodeValue(value, params)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'N':
|
||||||
|
// N:LastName;FirstName;MiddleName;Prefix;Suffix
|
||||||
|
const nameParts = value.split(';').map(p => decodeValue(p, params))
|
||||||
|
if (!contact.name) {
|
||||||
|
contact.name = [nameParts[3], nameParts[1], nameParts[2], nameParts[0], nameParts[4]]
|
||||||
|
.filter(p => p)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'TEL':
|
||||||
|
contact.phoneNumbers.push({
|
||||||
|
type: getTypeLabel(params, 'phone'),
|
||||||
|
number: value
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EMAIL':
|
||||||
|
contact.emails.push({
|
||||||
|
type: getTypeLabel(params, 'email'),
|
||||||
|
address: value
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ADR':
|
||||||
|
// ADR:;;Street;City;State;ZIP;Country
|
||||||
|
const adrParts = value.split(';').map(p => decodeValue(p, params))
|
||||||
|
const address = {
|
||||||
|
type: getTypeLabel(params, 'address'),
|
||||||
|
street: adrParts[2],
|
||||||
|
city: adrParts[3],
|
||||||
|
state: adrParts[4],
|
||||||
|
zip: adrParts[5],
|
||||||
|
country: adrParts[6]
|
||||||
|
}
|
||||||
|
contact.addresses.push(address)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ORG':
|
||||||
|
contact.organization = decodeValue(value, params)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'TITLE':
|
||||||
|
contact.title = decodeValue(value, params)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'PHOTO':
|
||||||
|
contact.photo = parsePhoto(value, params)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'BDAY':
|
||||||
|
contact.birthday = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'URL':
|
||||||
|
contact.url = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'NOTE':
|
||||||
|
contact.note = decodeValue(value, params)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use formatted name if name is empty
|
||||||
|
if (!contact.name && contact.formattedName) {
|
||||||
|
contact.name = contact.formattedName
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unfold lines (handle line continuation in vCard format)
|
||||||
|
*/
|
||||||
|
function unfoldLines(text) {
|
||||||
|
const lines = text.split(/\r?\n/)
|
||||||
|
const unfolded = []
|
||||||
|
let current = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Line continuation: starts with space or tab
|
||||||
|
if (line.startsWith(' ') || line.startsWith('\t')) {
|
||||||
|
current += line.substring(1)
|
||||||
|
} else {
|
||||||
|
if (current) {
|
||||||
|
unfolded.push(current)
|
||||||
|
}
|
||||||
|
current = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
unfolded.push(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return unfolded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a vCard line into property and value
|
||||||
|
*/
|
||||||
|
function parseVCardLine(line) {
|
||||||
|
const colonIndex = line.indexOf(':')
|
||||||
|
if (colonIndex === -1) return [null, null]
|
||||||
|
|
||||||
|
const property = line.substring(0, colonIndex)
|
||||||
|
const value = line.substring(colonIndex + 1)
|
||||||
|
|
||||||
|
return [property, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse property name and parameters
|
||||||
|
* Example: "TEL;TYPE=CELL;PREF=1" => { name: "TEL", params: { TYPE: "CELL", PREF: "1" } }
|
||||||
|
*/
|
||||||
|
function parseProperty(property) {
|
||||||
|
const parts = property.split(';')
|
||||||
|
const name = parts[0]
|
||||||
|
const params = {}
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const param = parts[i]
|
||||||
|
const eqIndex = param.indexOf('=')
|
||||||
|
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
// vCard 2.1 style: TYPE without =
|
||||||
|
params['TYPE'] = params['TYPE'] ? params['TYPE'] + ',' + param : param
|
||||||
|
} else {
|
||||||
|
const paramName = param.substring(0, eqIndex)
|
||||||
|
const paramValue = param.substring(eqIndex + 1).replace(/^"(.*)"$/, '$1')
|
||||||
|
params[paramName.toUpperCase()] = paramValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable type label from parameters
|
||||||
|
*/
|
||||||
|
function getTypeLabel(params, context) {
|
||||||
|
if (!params.TYPE) {
|
||||||
|
return context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address'
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = params.TYPE.split(',').map(t => t.toUpperCase())
|
||||||
|
|
||||||
|
// Common type mappings
|
||||||
|
const typeMap = {
|
||||||
|
'CELL': 'Mobile',
|
||||||
|
'HOME': 'Home',
|
||||||
|
'WORK': 'Work',
|
||||||
|
'VOICE': 'Phone',
|
||||||
|
'FAX': 'Fax',
|
||||||
|
'PAGER': 'Pager',
|
||||||
|
'MSG': 'Message',
|
||||||
|
'PREF': 'Preferred',
|
||||||
|
'INTERNET': 'Email'
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = types
|
||||||
|
.map(t => typeMap[t] || t.charAt(0) + t.substring(1).toLowerCase())
|
||||||
|
.filter(l => l !== 'Internet') // Remove generic Internet label
|
||||||
|
|
||||||
|
return labels.join(', ') || (context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode value based on encoding parameter
|
||||||
|
*/
|
||||||
|
function decodeValue(value, params) {
|
||||||
|
if (!params.ENCODING) return value
|
||||||
|
|
||||||
|
const encoding = params.ENCODING.toUpperCase()
|
||||||
|
|
||||||
|
if (encoding === 'QUOTED-PRINTABLE') {
|
||||||
|
return decodeQuotedPrintable(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode quoted-printable encoding
|
||||||
|
*/
|
||||||
|
function decodeQuotedPrintable(str) {
|
||||||
|
return str
|
||||||
|
.replace(/=\r?\n/g, '') // Remove soft line breaks
|
||||||
|
.replace(/=([0-9A-F]{2})/gi, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse photo data from vCard
|
||||||
|
*/
|
||||||
|
function parsePhoto(value, params) {
|
||||||
|
const encoding = params.ENCODING ? params.ENCODING.toUpperCase() : ''
|
||||||
|
const type = params.TYPE || params.MEDIATYPE || 'JPEG'
|
||||||
|
|
||||||
|
if (encoding === 'BASE64' || encoding === 'B') {
|
||||||
|
// Remove whitespace from base64 data
|
||||||
|
const base64Data = value.replace(/\s/g, '')
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
let mimeType = 'image/jpeg'
|
||||||
|
const typeUpper = type.toUpperCase()
|
||||||
|
|
||||||
|
if (typeUpper.includes('PNG')) {
|
||||||
|
mimeType = 'image/png'
|
||||||
|
} else if (typeUpper.includes('GIF')) {
|
||||||
|
mimeType = 'image/gif'
|
||||||
|
} else if (typeUpper.includes('BMP')) {
|
||||||
|
mimeType = 'image/bmp'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data:${mimeType};base64,${base64Data}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL reference
|
||||||
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format address object to string
|
||||||
|
*/
|
||||||
|
export function formatAddress(address) {
|
||||||
|
const parts = [
|
||||||
|
address.street,
|
||||||
|
address.city,
|
||||||
|
address.state && address.zip ? `${address.state} ${address.zip}` : address.state || address.zip,
|
||||||
|
address.country
|
||||||
|
].filter(p => p)
|
||||||
|
|
||||||
|
return parts.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format birthday to readable format
|
||||||
|
*/
|
||||||
|
export function formatBirthday(birthday) {
|
||||||
|
if (!birthday) return ''
|
||||||
|
|
||||||
|
// Handle different date formats
|
||||||
|
// YYYYMMDD, YYYY-MM-DD, or --MMDD (no year)
|
||||||
|
if (birthday.startsWith('--')) {
|
||||||
|
const month = birthday.substring(2, 4)
|
||||||
|
const day = birthday.substring(4, 6)
|
||||||
|
return `${month}/${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (birthday.includes('-')) {
|
||||||
|
const [year, month, day] = birthday.split('-')
|
||||||
|
return `${month}/${day}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (birthday.length === 8) {
|
||||||
|
const year = birthday.substring(0, 4)
|
||||||
|
const month = birthday.substring(4, 6)
|
||||||
|
const day = birthday.substring(6, 8)
|
||||||
|
return `${month}/${day}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return birthday
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
module github.com/lowcarbdev/sbv
|
||||||
|
|
||||||
|
go 1.24.7
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.32
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15
|
||||||
|
golang.org/x/crypto v0.44.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15 h1:aFa2PvtQulG5uVQ8adH84JCwRZ2rjiZnRUU/mWxJRG8=
|
||||||
|
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15/go.mod h1:ZW0m/zWIvFqFSpPdiWRje8xdwyWJqt3Cnt6bVlDti8g=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var authDB *sql.DB
|
||||||
|
|
||||||
|
// InitAuthDB initializes the authentication database
|
||||||
|
func InitAuthDB(filepath string) error {
|
||||||
|
var err error
|
||||||
|
authDB, err = sql.Open("sqlite3", filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = authDB.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableSQL := `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = authDB.Exec(createTableSQL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user with hashed password
|
||||||
|
func CreateUser(username, password string) (*User, error) {
|
||||||
|
// Generate UUID for user
|
||||||
|
userID := uuid.New().String()
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := time.Now().Unix()
|
||||||
|
|
||||||
|
_, err = authDB.Exec(
|
||||||
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
userID, username, string(hashedPassword), createdAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
ID: userID,
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
CreatedAt: time.Unix(createdAt, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername retrieves a user by username
|
||||||
|
func GetUserByUsername(username string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
var createdAt int64
|
||||||
|
|
||||||
|
err := authDB.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
|
||||||
|
username,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.PasswordHash, &createdAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword checks if the provided password matches the user's password hash
|
||||||
|
func VerifyPassword(user *User, password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSessionID generates a random session ID
|
||||||
|
func GenerateSessionID() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new session for a user
|
||||||
|
func CreateSession(userID string, username string) (*Session, error) {
|
||||||
|
sessionID, err := GenerateSessionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := time.Now()
|
||||||
|
expiresAt := createdAt.Add(30 * 24 * time.Hour) // 30 days
|
||||||
|
|
||||||
|
_, err = authDB.Exec(
|
||||||
|
"INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
sessionID, userID, createdAt.Unix(), expiresAt.Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Session{
|
||||||
|
ID: sessionID,
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves a session by ID
|
||||||
|
func GetSession(sessionID string) (*Session, error) {
|
||||||
|
var session Session
|
||||||
|
var createdAt, expiresAt int64
|
||||||
|
|
||||||
|
err := authDB.QueryRow(
|
||||||
|
`SELECT s.id, s.user_id, u.username, s.created_at, s.expires_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&session.ID, &session.UserID, &session.Username, &createdAt, &expiresAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
session.ExpiresAt = time.Unix(expiresAt, 0)
|
||||||
|
|
||||||
|
// Check if session is expired
|
||||||
|
if time.Now().After(session.ExpiresAt) {
|
||||||
|
DeleteSession(sessionID)
|
||||||
|
return nil, fmt.Errorf("session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession deletes a session by ID
|
||||||
|
func DeleteSession(sessionID string) error {
|
||||||
|
_, err := authDB.Exec("DELETE FROM sessions WHERE id = ?", sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpiredSessions removes all expired sessions
|
||||||
|
func CleanExpiredSessions() error {
|
||||||
|
_, err := authDB.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword updates a user's password
|
||||||
|
func UpdatePassword(userID string, newPassword string) error {
|
||||||
|
// Hash the new password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = authDB.Exec(
|
||||||
|
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
string(hashedPassword), userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleRegister(c echo.Context) error {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Username is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Username) < 3 {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Username must be at least 3 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Password) < 6 {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Password must be at least 6 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user, err := CreateUser(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
return c.JSON(http.StatusConflict, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Username already exists",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slog.Error("Error creating user", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to create user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session, err := CreateSession(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error creating session", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to create session",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: session.ID,
|
||||||
|
Expires: session.ExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Path: "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize user's database (using UUID as filename)
|
||||||
|
dbPathPrefix := os.Getenv("DB_PATH_PREFIX")
|
||||||
|
if dbPathPrefix == "" {
|
||||||
|
dbPathPrefix = "."
|
||||||
|
}
|
||||||
|
userDBPath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, user.ID)
|
||||||
|
if err := InitUserDB(user.ID, userDBPath); err != nil {
|
||||||
|
slog.Error("Error initializing user database", "error", err)
|
||||||
|
return echo.ErrInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
User: user,
|
||||||
|
Session: session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleLogin(c echo.Context) error {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Username and password are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := GetUserByUsername(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid username or password",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !VerifyPassword(user, req.Password) {
|
||||||
|
return c.JSON(http.StatusUnauthorized, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid username or password",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session, err := CreateSession(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error creating session", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to create session",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: session.ID,
|
||||||
|
Expires: session.ExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Path: "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
User: user,
|
||||||
|
Session: session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleLogout(c echo.Context) error {
|
||||||
|
// Get session ID from cookie
|
||||||
|
cookie, err := c.Cookie("session_id")
|
||||||
|
if err == nil {
|
||||||
|
// Delete session from database
|
||||||
|
DeleteSession(cookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cookie
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: "",
|
||||||
|
Expires: time.Now().Add(-1 * time.Hour),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Path: "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]bool{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleMe(c echo.Context) error {
|
||||||
|
// Get session from context (set by AuthMiddleware)
|
||||||
|
session, ok := c.Get("session").(*Session)
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusUnauthorized, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := GetUserByUsername(session.Username)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to get user info",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
User: user,
|
||||||
|
Session: session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleChangePassword(c echo.Context) error {
|
||||||
|
// Get session from context (set by AuthMiddleware)
|
||||||
|
session, ok := c.Get("session").(*Session)
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusUnauthorized, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ChangePasswordRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if req.OldPassword == "" || req.NewPassword == "" || req.ConfirmPassword == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "All fields are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.NewPassword != req.ConfirmPassword {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "New passwords do not match",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.NewPassword) < 6 {
|
||||||
|
return c.JSON(http.StatusBadRequest, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "New password must be at least 6 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := GetUserByUsername(session.Username)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to get user info",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !VerifyPassword(user, req.OldPassword) {
|
||||||
|
return c.JSON(http.StatusUnauthorized, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Current password is incorrect",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
if err := UpdatePassword(user.ID, req.NewPassword); err != nil {
|
||||||
|
slog.Error("Error updating password", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Failed to update password",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomCORSMiddleware creates a custom CORS middleware that properly handles credentials
|
||||||
|
func CustomCORSMiddleware() echo.MiddlewareFunc {
|
||||||
|
allowedOrigins := map[string]bool{
|
||||||
|
"http://localhost:5173": true,
|
||||||
|
"http://localhost:3000": true,
|
||||||
|
"http://localhost:8081": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
origin := c.Request().Header.Get("Origin")
|
||||||
|
|
||||||
|
// Check if origin is allowed
|
||||||
|
if allowedOrigins[origin] {
|
||||||
|
c.Response().Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Response().Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if c.Request().Method == http.MethodOptions {
|
||||||
|
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Response().Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||||
|
c.Response().Header().Set("Access-Control-Max-Age", "3600")
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,909 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
// userDBs stores per-user database connections (keyed by user ID)
|
||||||
|
var userDBs = make(map[string]*sql.DB)
|
||||||
|
var userDBsMutex sync.RWMutex
|
||||||
|
|
||||||
|
// truncateString truncates a string to maxLen characters for logging
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeUsername converts a username to a safe filesystem name
|
||||||
|
func SanitizeUsername(username string) string {
|
||||||
|
// Convert to lowercase
|
||||||
|
safe := strings.ToLower(username)
|
||||||
|
|
||||||
|
// Replace spaces and special characters with underscores
|
||||||
|
reg := regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
safe = reg.ReplaceAllString(safe, "_")
|
||||||
|
|
||||||
|
// Remove leading/trailing underscores
|
||||||
|
safe = strings.Trim(safe, "_")
|
||||||
|
|
||||||
|
// Ensure it's not empty
|
||||||
|
if safe == "" {
|
||||||
|
safe = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDB(filepath string) error {
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("sqlite3", filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableSQL := `
|
||||||
|
-- Unified table for SMS messages, MMS messages, and call logs
|
||||||
|
-- record_type: 1 = SMS, 2 = MMS, 3 = call
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
record_type INTEGER NOT NULL DEFAULT 1,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
date INTEGER NOT NULL,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
thread_id INTEGER,
|
||||||
|
subject TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
media_data BLOB,
|
||||||
|
protocol INTEGER,
|
||||||
|
status INTEGER,
|
||||||
|
service_center TEXT,
|
||||||
|
sub_id INTEGER,
|
||||||
|
contact_name TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
content_type TEXT,
|
||||||
|
read_report INTEGER,
|
||||||
|
read_status INTEGER,
|
||||||
|
message_id TEXT,
|
||||||
|
message_size INTEGER,
|
||||||
|
message_type INTEGER,
|
||||||
|
sim_slot INTEGER,
|
||||||
|
addresses TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
presentation INTEGER,
|
||||||
|
subscription_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_address ON messages(address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_date ON messages(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
|
||||||
|
|
||||||
|
-- Create unique constraints for idempotent imports
|
||||||
|
-- record_type differentiates SMS (1), MMS (2), and calls (3)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0));
|
||||||
|
|
||||||
|
-- Create FTS5 virtual table for full-text search of messages
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
|
message_id UNINDEXED,
|
||||||
|
address UNINDEXED,
|
||||||
|
body,
|
||||||
|
contact_name UNINDEXED,
|
||||||
|
date UNINDEXED,
|
||||||
|
content='messages',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create triggers to keep FTS table in sync
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
|
||||||
|
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
|
||||||
|
END;
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = db.Exec(createTableSQL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Database initialized successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitUserDB initializes a database for a specific user
|
||||||
|
func InitUserDB(userID string, filepath string) error {
|
||||||
|
userDB, err := sql.Open("sqlite3", filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = userDB.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableSQL := `
|
||||||
|
-- Unified table for SMS messages, MMS messages, and call logs
|
||||||
|
-- record_type: 1 = SMS, 2 = MMS, 3 = call
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
record_type INTEGER NOT NULL DEFAULT 1,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
date INTEGER NOT NULL,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
thread_id INTEGER,
|
||||||
|
subject TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
media_data BLOB,
|
||||||
|
protocol INTEGER,
|
||||||
|
status INTEGER,
|
||||||
|
service_center TEXT,
|
||||||
|
sub_id INTEGER,
|
||||||
|
contact_name TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
content_type TEXT,
|
||||||
|
read_report INTEGER,
|
||||||
|
read_status INTEGER,
|
||||||
|
message_id TEXT,
|
||||||
|
message_size INTEGER,
|
||||||
|
message_type INTEGER,
|
||||||
|
sim_slot INTEGER,
|
||||||
|
addresses TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
presentation INTEGER,
|
||||||
|
subscription_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_address ON messages(address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_date ON messages(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
|
||||||
|
|
||||||
|
-- record_type differentiates SMS (1), MMS (2), and calls (3)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0));
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
|
message_id UNINDEXED,
|
||||||
|
address UNINDEXED,
|
||||||
|
body,
|
||||||
|
contact_name UNINDEXED,
|
||||||
|
date UNINDEXED,
|
||||||
|
content='messages',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
|
||||||
|
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
|
||||||
|
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
|
||||||
|
END;
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = userDB.Exec(createTableSQL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in map
|
||||||
|
userDBsMutex.Lock()
|
||||||
|
userDBs[userID] = userDB
|
||||||
|
userDBsMutex.Unlock()
|
||||||
|
|
||||||
|
slog.Info("User database initialized", "user_id", userID, "path", filepath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserDB retrieves the database connection for a specific user
|
||||||
|
func GetUserDB(userID string, username string) (*sql.DB, error) {
|
||||||
|
userDBsMutex.RLock()
|
||||||
|
defer userDBsMutex.RUnlock()
|
||||||
|
|
||||||
|
userDB, exists := userDBs[userID]
|
||||||
|
if !exists {
|
||||||
|
// Try to open the database if it exists
|
||||||
|
dbPathPrefix := os.Getenv("DB_PATH_PREFIX")
|
||||||
|
if dbPathPrefix == "" {
|
||||||
|
dbPathPrefix = "."
|
||||||
|
}
|
||||||
|
// Use UUID as database filename instead of sanitized username
|
||||||
|
filepath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, userID)
|
||||||
|
if _, err := os.Stat(filepath); err == nil {
|
||||||
|
// Database file exists, try to open it
|
||||||
|
userDBsMutex.RUnlock()
|
||||||
|
if err := InitUserDB(userID, filepath); err != nil {
|
||||||
|
userDBsMutex.RLock()
|
||||||
|
return nil, fmt.Errorf("failed to open user database: %w", err)
|
||||||
|
}
|
||||||
|
userDBsMutex.RLock()
|
||||||
|
userDB = userDBs[userID]
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("user database not found for user %s", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userDB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertMessage(userDB *sql.DB, msg *Message) error {
|
||||||
|
// Convert addresses slice to JSON string
|
||||||
|
var addressesJSON string
|
||||||
|
if len(msg.Addresses) > 0 {
|
||||||
|
addresses := strings.Join(msg.Addresses, ",")
|
||||||
|
addressesJSON = addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine record type: 1 = SMS, 2 = MMS
|
||||||
|
// MMS messages have ContentType set (e.g., 'application/vnd.wap.multipart.related')
|
||||||
|
// SMS messages do not have ContentType
|
||||||
|
recordType := 1 // Default to SMS
|
||||||
|
if msg.ContentType != "" {
|
||||||
|
recordType = 2 // MMS
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO messages (
|
||||||
|
record_type, address, body, type, date, read, thread_id, subject, media_type, media_data,
|
||||||
|
protocol, status, service_center, sub_id, contact_name, sender,
|
||||||
|
content_type, read_report, read_status, message_id, message_size, message_type, sim_slot, addresses
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`
|
||||||
|
result, err := userDB.Exec(query,
|
||||||
|
recordType, // record_type: 1 = SMS, 2 = MMS
|
||||||
|
msg.Address,
|
||||||
|
msg.Body,
|
||||||
|
msg.Type,
|
||||||
|
msg.Date.Unix(),
|
||||||
|
msg.Read,
|
||||||
|
msg.ThreadID,
|
||||||
|
msg.Subject,
|
||||||
|
msg.MediaType,
|
||||||
|
msg.MediaData,
|
||||||
|
msg.Protocol,
|
||||||
|
msg.Status,
|
||||||
|
msg.ServiceCenter,
|
||||||
|
msg.SubID,
|
||||||
|
msg.ContactName,
|
||||||
|
msg.Sender,
|
||||||
|
msg.ContentType,
|
||||||
|
msg.ReadReport,
|
||||||
|
msg.ReadStatus,
|
||||||
|
msg.MessageID,
|
||||||
|
msg.MessageSize,
|
||||||
|
msg.MessageType,
|
||||||
|
msg.SimSlot,
|
||||||
|
addressesJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("InsertMessage: Error inserting message", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg.ID = id
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertCallLog(userDB *sql.DB, call *CallLog) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`
|
||||||
|
result, err := userDB.Exec(query,
|
||||||
|
3, // record_type: 3 = call
|
||||||
|
call.Number,
|
||||||
|
call.Type,
|
||||||
|
call.Date.Unix(),
|
||||||
|
call.Duration,
|
||||||
|
call.Presentation,
|
||||||
|
call.SubscriptionID,
|
||||||
|
call.ContactName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
call.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCallLogBatch inserts multiple call logs in a single transaction for better performance
|
||||||
|
func InsertCallLogBatch(userDB *sql.DB, calls []CallLog) error {
|
||||||
|
if len(calls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := userDB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for i := range calls {
|
||||||
|
_, err := stmt.Exec(
|
||||||
|
3, // record_type: 3 = call
|
||||||
|
calls[i].Number,
|
||||||
|
calls[i].Type,
|
||||||
|
calls[i].Date.Unix(),
|
||||||
|
calls[i].Duration,
|
||||||
|
calls[i].Presentation,
|
||||||
|
calls[i].SubscriptionID,
|
||||||
|
calls[i].ContactName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConversations(userDB *sql.DB, startDate, endDate *time.Time) ([]Conversation, error) {
|
||||||
|
// Build a query that groups all activity (messages and calls) by address
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
address,
|
||||||
|
MAX(COALESCE(contact_name, '')) as contact_name,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(subject, '')
|
||||||
|
FROM messages m2
|
||||||
|
WHERE m2.address = messages.address
|
||||||
|
AND m2.subject IS NOT NULL
|
||||||
|
AND m2.subject != ''
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as subject,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN record_type = 1 THEN body -- SMS
|
||||||
|
WHEN record_type = 2 THEN body -- MMS
|
||||||
|
WHEN record_type = 3 AND type = 1 THEN 'Incoming call'
|
||||||
|
WHEN record_type = 3 AND type = 2 THEN 'Outgoing call'
|
||||||
|
WHEN record_type = 3 AND type = 3 THEN 'Missed call'
|
||||||
|
WHEN record_type = 3 AND type = 4 THEN 'Voicemail'
|
||||||
|
WHEN record_type = 3 AND type = 5 THEN 'Rejected call'
|
||||||
|
WHEN record_type = 3 AND type = 6 THEN 'Refused call'
|
||||||
|
ELSE 'Call'
|
||||||
|
END
|
||||||
|
FROM messages m3
|
||||||
|
WHERE m3.address = messages.address
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as last_message,
|
||||||
|
MAX(date) as last_date,
|
||||||
|
COUNT(*) as activity_count
|
||||||
|
FROM messages
|
||||||
|
WHERE 1=1
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{}
|
||||||
|
if startDate != nil {
|
||||||
|
query += " AND date >= ?"
|
||||||
|
args = append(args, startDate.Unix())
|
||||||
|
}
|
||||||
|
if endDate != nil {
|
||||||
|
query += " AND date <= ?"
|
||||||
|
args = append(args, endDate.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY address
|
||||||
|
ORDER BY last_date DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := userDB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
conversations := []Conversation{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Conversation
|
||||||
|
var lastDateUnix int64
|
||||||
|
var subject sql.NullString
|
||||||
|
err := rows.Scan(&c.Address, &c.ContactName, &subject, &c.LastMessage, &lastDateUnix, &c.MessageCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.LastDate = time.Unix(lastDateUnix, 0)
|
||||||
|
c.Subject = subject.String
|
||||||
|
c.Type = "conversation" // Changed from "message" or "call" to indicate it's a merged conversation
|
||||||
|
conversations = append(conversations, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCallType(callType int) string {
|
||||||
|
switch callType {
|
||||||
|
case 1:
|
||||||
|
return "Incoming call"
|
||||||
|
case 2:
|
||||||
|
return "Outgoing call"
|
||||||
|
case 3:
|
||||||
|
return "Missed call"
|
||||||
|
case 4:
|
||||||
|
return "Voicemail"
|
||||||
|
case 5:
|
||||||
|
return "Rejected call"
|
||||||
|
case 6:
|
||||||
|
return "Refused call"
|
||||||
|
default:
|
||||||
|
return "Call"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMessages(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, address, body, type, date, read, thread_id,
|
||||||
|
COALESCE(subject, ''), COALESCE(media_type, ''), COALESCE(media_data, ''),
|
||||||
|
COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''),
|
||||||
|
COALESCE(sub_id, 0), COALESCE(contact_name, ''), COALESCE(sender, ''),
|
||||||
|
COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(read_status, 0),
|
||||||
|
COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(message_type, 0),
|
||||||
|
COALESCE(sim_slot, 0), COALESCE(addresses, '')
|
||||||
|
FROM messages
|
||||||
|
WHERE record_type IN (1, 2) AND address = ? -- 1 = SMS, 2 = MMS
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{address}
|
||||||
|
if startDate != nil {
|
||||||
|
query += " AND date >= ?"
|
||||||
|
args = append(args, startDate.Unix())
|
||||||
|
}
|
||||||
|
if endDate != nil {
|
||||||
|
query += " AND date <= ?"
|
||||||
|
args = append(args, endDate.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY date ASC"
|
||||||
|
|
||||||
|
slog.Debug("GetMessages: executing query", "address", address)
|
||||||
|
slog.Debug("GetMessages: SQL query", "query", query)
|
||||||
|
slog.Debug("GetMessages: query arguments", "args", args)
|
||||||
|
|
||||||
|
rows, err := userDB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("GetMessages: Query error", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
messages := []Message{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m Message
|
||||||
|
var dateUnix int64
|
||||||
|
var readInt int
|
||||||
|
var addressesStr string
|
||||||
|
err := rows.Scan(&m.ID, &m.Address, &m.Body, &m.Type, &dateUnix,
|
||||||
|
&readInt, &m.ThreadID, &m.Subject, &m.MediaType, &m.MediaData,
|
||||||
|
&m.Protocol, &m.Status, &m.ServiceCenter, &m.SubID, &m.ContactName, &m.Sender,
|
||||||
|
&m.ContentType, &m.ReadReport, &m.ReadStatus, &m.MessageID,
|
||||||
|
&m.MessageSize, &m.MessageType, &m.SimSlot, &addressesStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Date = time.Unix(dateUnix, 0)
|
||||||
|
m.Read = readInt == 1
|
||||||
|
|
||||||
|
// Parse addresses from comma-separated string
|
||||||
|
if addressesStr != "" {
|
||||||
|
m.Addresses = strings.Split(addressesStr, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't load media data - it will be fetched on demand via /api/media
|
||||||
|
// Clear MediaData to save memory in response
|
||||||
|
m.MediaData = nil
|
||||||
|
|
||||||
|
slog.Debug("GetMessages: Message", "id", m.ID, "address", m.Address, "media_type", m.MediaType, "body", truncateString(m.Body, 50))
|
||||||
|
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("GetMessages: Returning messages", "count", len(messages), "address", address)
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCallLogs(userDB *sql.DB, number string, startDate, endDate *time.Time) ([]CallLog, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, address, duration, date, type,
|
||||||
|
COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '')
|
||||||
|
FROM messages
|
||||||
|
WHERE record_type = 3 AND address = ? -- 3 = call
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{number}
|
||||||
|
if startDate != nil {
|
||||||
|
query += " AND date >= ?"
|
||||||
|
args = append(args, startDate.Unix())
|
||||||
|
}
|
||||||
|
if endDate != nil {
|
||||||
|
query += " AND date <= ?"
|
||||||
|
args = append(args, endDate.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY date ASC"
|
||||||
|
|
||||||
|
rows, err := userDB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
calls := []CallLog{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c CallLog
|
||||||
|
var dateUnix int64
|
||||||
|
err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type,
|
||||||
|
&c.Presentation, &c.SubscriptionID, &c.ContactName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Date = time.Unix(dateUnix, 0)
|
||||||
|
calls = append(calls, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return calls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
|
||||||
|
return GetActivityByAddress(userDB, "", startDate, endDate, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
|
||||||
|
var activities []ActivityItem
|
||||||
|
|
||||||
|
// Query from unified table
|
||||||
|
query := `
|
||||||
|
SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name,
|
||||||
|
id, body, type, read, thread_id, COALESCE(subject, ''),
|
||||||
|
COALESCE(media_type, ''), COALESCE(media_data, ''),
|
||||||
|
COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''),
|
||||||
|
COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0),
|
||||||
|
COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0),
|
||||||
|
COALESCE(message_type, 0), COALESCE(sim_slot, 0), COALESCE(addresses, ''),
|
||||||
|
COALESCE(duration, 0), COALESCE(presentation, 0), COALESCE(subscription_id, ''),
|
||||||
|
COALESCE(sender, '')
|
||||||
|
FROM messages
|
||||||
|
WHERE 1=1
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{}
|
||||||
|
if address != "" {
|
||||||
|
query += " AND address = ?"
|
||||||
|
args = append(args, address)
|
||||||
|
}
|
||||||
|
if startDate != nil {
|
||||||
|
query += " AND date >= ?"
|
||||||
|
args = append(args, startDate.Unix())
|
||||||
|
}
|
||||||
|
if endDate != nil {
|
||||||
|
query += " AND date <= ?"
|
||||||
|
args = append(args, endDate.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY date ASC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
slog.Debug("GetActivityByAddress: executing query", "address", address, "limit", limit, "offset", offset)
|
||||||
|
slog.Debug("GetActivityByAddress: SQL query", "query", query)
|
||||||
|
slog.Debug("GetActivityByAddress: query arguments", "args", args)
|
||||||
|
|
||||||
|
rows, err := userDB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("GetActivityByAddress: Query error", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var recordType int64
|
||||||
|
var dateUnix int64
|
||||||
|
var address, contactName string
|
||||||
|
|
||||||
|
// Shared fields
|
||||||
|
var id sql.NullInt64
|
||||||
|
var itemType sql.NullInt64 // type field - used for both message type and call type
|
||||||
|
|
||||||
|
// Message fields
|
||||||
|
var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString
|
||||||
|
var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64
|
||||||
|
var mediaData []byte
|
||||||
|
|
||||||
|
// Call fields
|
||||||
|
var duration, presentation sql.NullInt64
|
||||||
|
|
||||||
|
err := rows.Scan(&recordType, &dateUnix, &address, &contactName,
|
||||||
|
&id, &body, &itemType, &readInt, &threadID, &subject,
|
||||||
|
&mediaType, &mediaData,
|
||||||
|
&protocol, &status, &serviceCenter,
|
||||||
|
&subID, &contentType, &readReport,
|
||||||
|
&readStatus, &messageID, &messageSize,
|
||||||
|
&messageTypeField, &simSlot, &addressesStr,
|
||||||
|
&duration, &presentation, &subscriptionID, &sender)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var activityTypeStr string
|
||||||
|
if recordType == 1 || recordType == 2 {
|
||||||
|
// 1 = SMS, 2 = MMS
|
||||||
|
activityTypeStr = "message"
|
||||||
|
} else if recordType == 3 {
|
||||||
|
// 3 = call
|
||||||
|
activityTypeStr = "call"
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := ActivityItem{
|
||||||
|
Type: activityTypeStr,
|
||||||
|
Date: time.Unix(dateUnix, 0),
|
||||||
|
Address: address,
|
||||||
|
ContactName: contactName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordType == 1 || recordType == 2) && id.Valid {
|
||||||
|
// Handle SMS (1) and MMS (2)
|
||||||
|
msg := &Message{
|
||||||
|
ID: id.Int64,
|
||||||
|
Address: address,
|
||||||
|
Body: body.String,
|
||||||
|
Date: time.Unix(dateUnix, 0),
|
||||||
|
ThreadID: int(threadID.Int64),
|
||||||
|
Subject: subject.String,
|
||||||
|
MediaType: mediaType.String,
|
||||||
|
MediaData: mediaData,
|
||||||
|
Protocol: int(protocol.Int64),
|
||||||
|
Status: int(status.Int64),
|
||||||
|
ServiceCenter: serviceCenter.String,
|
||||||
|
SubID: int(subID.Int64),
|
||||||
|
ContactName: contactName,
|
||||||
|
ContentType: contentType.String,
|
||||||
|
ReadReport: int(readReport.Int64),
|
||||||
|
ReadStatus: int(readStatus.Int64),
|
||||||
|
MessageID: messageID.String,
|
||||||
|
MessageSize: int(messageSize.Int64),
|
||||||
|
MessageType: int(messageTypeField.Int64),
|
||||||
|
SimSlot: int(simSlot.Int64),
|
||||||
|
Sender: sender.String,
|
||||||
|
}
|
||||||
|
if itemType.Valid {
|
||||||
|
msg.Type = int(itemType.Int64)
|
||||||
|
}
|
||||||
|
if readInt.Valid {
|
||||||
|
msg.Read = readInt.Int64 == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse addresses from comma-separated string
|
||||||
|
slog.Debug("GetActivityByAddress: addressesStr raw", "id", id.Int64, "valid", addressesStr.Valid, "value", addressesStr.String)
|
||||||
|
if addressesStr.Valid && addressesStr.String != "" {
|
||||||
|
msg.Addresses = strings.Split(addressesStr.String, ",")
|
||||||
|
slog.Debug("GetActivityByAddress: addresses split result", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses)
|
||||||
|
} else if strings.Contains(address, ",") {
|
||||||
|
// Fallback: If addresses field is empty but address contains commas,
|
||||||
|
// this is a group conversation - parse the address field
|
||||||
|
msg.Addresses = strings.Split(address, ",")
|
||||||
|
slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't load media data - it will be fetched on demand via /api/media
|
||||||
|
// Clear MediaData to save memory in response
|
||||||
|
msg.MediaData = nil
|
||||||
|
|
||||||
|
slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50))
|
||||||
|
|
||||||
|
activity.Message = msg
|
||||||
|
} else if recordType == 3 && id.Valid {
|
||||||
|
// Handle calls (3)
|
||||||
|
call := &CallLog{
|
||||||
|
ID: id.Int64,
|
||||||
|
Number: address,
|
||||||
|
Duration: int(duration.Int64),
|
||||||
|
Date: time.Unix(dateUnix, 0),
|
||||||
|
Type: int(itemType.Int64),
|
||||||
|
Presentation: int(presentation.Int64),
|
||||||
|
SubscriptionID: subscriptionID.String,
|
||||||
|
ContactName: contactName,
|
||||||
|
}
|
||||||
|
slog.Debug("GetActivityByAddress: Call", "id", call.ID, "number", call.Number, "type", call.Type, "duration", call.Duration)
|
||||||
|
activity.Call = call
|
||||||
|
}
|
||||||
|
|
||||||
|
activities = append(activities, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("GetActivityByAddress: Returning activities", "count", len(activities), "address", address)
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(media_data, ''), COALESCE(media_type, '')
|
||||||
|
FROM messages
|
||||||
|
WHERE id = ? AND record_type IN (1, 2) -- 1 = SMS, 2 = MMS
|
||||||
|
`
|
||||||
|
|
||||||
|
slog.Debug("GetMessageMedia: Fetching media", "message_id", messageID)
|
||||||
|
slog.Debug("GetMessageMedia: SQL query", "query", query)
|
||||||
|
|
||||||
|
var mediaData []byte
|
||||||
|
var mediaType string
|
||||||
|
|
||||||
|
err := userDB.QueryRow(query, messageID).Scan(&mediaData, &mediaType)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("GetMessageMedia: Error scanning row", "message_id", messageID, "error", err)
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("GetMessageMedia: Found media", "media_type", mediaType, "data_length", len(mediaData), "message_id", messageID)
|
||||||
|
|
||||||
|
if len(mediaData) == 0 || mediaType == "" {
|
||||||
|
slog.Debug("GetMessageMedia: No media found", "message_id", messageID)
|
||||||
|
return nil, "", fmt.Errorf("no media found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert HEIC to JPEG if needed
|
||||||
|
if isHEICContentType(mediaType) {
|
||||||
|
convertedData, err := convertHEICtoJPEG(mediaData)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to convert HEIC to JPEG", "message_id", messageID, "error", err)
|
||||||
|
// Return original if conversion fails
|
||||||
|
return mediaData, mediaType, nil
|
||||||
|
}
|
||||||
|
return convertedData, "image/jpeg", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert unsupported video formats (3GP, etc.) to MP4 if needed
|
||||||
|
if needsVideoConversion(mediaType) {
|
||||||
|
slog.Info("Converting video to MP4", "from_type", mediaType, "message_id", messageID)
|
||||||
|
convertedData, err := convertVideoToMP4(mediaData)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to convert video to MP4", "message_id", messageID, "error", err)
|
||||||
|
// Return original if conversion fails
|
||||||
|
return mediaData, mediaType, nil
|
||||||
|
}
|
||||||
|
slog.Info("Successfully converted video to MP4", "message_id", messageID)
|
||||||
|
return convertedData, "video/mp4", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaData, mediaType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDateRange(userDB *sql.DB) (time.Time, time.Time, error) {
|
||||||
|
var minDate, maxDate int64
|
||||||
|
|
||||||
|
// Get min/max from unified messages table
|
||||||
|
query := "SELECT MIN(date), MAX(date) FROM messages"
|
||||||
|
var min, max sql.NullInt64
|
||||||
|
err := userDB.QueryRow(query).Scan(&min, &max)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return time.Time{}, time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !min.Valid || !max.Valid {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("no data available")
|
||||||
|
}
|
||||||
|
|
||||||
|
minDate = min.Int64
|
||||||
|
maxDate = max.Int64
|
||||||
|
|
||||||
|
return time.Unix(minDate, 0), time.Unix(maxDate, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult represents a message search result
|
||||||
|
type SearchResult struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
ContactName string `json:"contact_name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchMessages performs full-text search on message contents
|
||||||
|
func SearchMessages(userDB *sql.DB, query string, limit int) ([]SearchResult, error) {
|
||||||
|
if query == "" {
|
||||||
|
return []SearchResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlQuery := `
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.address,
|
||||||
|
COALESCE(m.contact_name, ''),
|
||||||
|
m.body,
|
||||||
|
m.date,
|
||||||
|
snippet(messages_fts, 2, '<mark>', '</mark>', '...', 50) as snippet
|
||||||
|
FROM messages_fts
|
||||||
|
JOIN messages m ON messages_fts.rowid = m.id
|
||||||
|
WHERE messages_fts MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := userDB.Query(sqlQuery, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results := []SearchResult{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r SearchResult
|
||||||
|
var dateUnix int64
|
||||||
|
err := rows.Scan(&r.MessageID, &r.Address, &r.ContactName, &r.Body, &dateUnix, &r.Snippet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Date = time.Unix(dateUnix, 0)
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"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" {
|
||||||
|
// Use a large limit to get all activity for this address
|
||||||
|
// We don't use pagination here since we want all items for the thread view
|
||||||
|
activities, err := GetActivityByAddress(userDB, address, startDate, endDate, 10000, 0)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate headers
|
||||||
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
||||||
|
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
|
||||||
|
|
||||||
|
// Write binary data with proper content type
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global test user ID - stored here so setupTestContext can use it
|
||||||
|
var testUserID string
|
||||||
|
|
||||||
|
// setupTestDB creates a test database with sample data
|
||||||
|
func setupTestDB(t *testing.T) (string, func()) {
|
||||||
|
tmpDB := "test_handlers.db"
|
||||||
|
tmpAuthDB := "test_handlers_auth.db"
|
||||||
|
|
||||||
|
// Clean up any existing test database
|
||||||
|
os.Remove(tmpDB)
|
||||||
|
os.Remove(tmpAuthDB)
|
||||||
|
|
||||||
|
// Initialize auth database first
|
||||||
|
if err := InitAuthDB(tmpAuthDB); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize auth database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize main database
|
||||||
|
if err := InitDB(tmpDB); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user, err := CreateUser("testuser", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user ID globally for setupTestContext to use
|
||||||
|
testUserID = user.ID
|
||||||
|
|
||||||
|
// Initialize user database (using UUID-based filename)
|
||||||
|
userDBPath := fmt.Sprintf("sbv_%s.db", user.ID)
|
||||||
|
if err := InitUserDB(user.ID, userDBPath); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize user database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user database connection
|
||||||
|
userDB, err := GetUserDB(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get user database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert test messages
|
||||||
|
sampleXML := `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<smses count="3">
|
||||||
|
<sms protocol="0" address="+15551234567" date="1285799668000" type="2" body="Test sent message" read="1" status="-1" />
|
||||||
|
<sms protocol="0" address="+15551234567" date="1285799669000" type="1" body="Test received message" read="1" status="-1" />
|
||||||
|
<mms date="1285799670000" rr="null" sub="null" read="1" ct_t="application/vnd.wap.multipart.related" msg_box="2" address="+15559876543" m_type="128" text_only="0">
|
||||||
|
<parts>
|
||||||
|
<part seq="0" ct="text/plain" name="null" chset="106" text="Test MMS message" />
|
||||||
|
</parts>
|
||||||
|
<addrs>
|
||||||
|
<addr address="+15552226543" type="137" charset="106" />
|
||||||
|
<addr address="+15551116565" type="151" charset="106" />
|
||||||
|
</addrs>
|
||||||
|
</mms>
|
||||||
|
</smses>`
|
||||||
|
|
||||||
|
reader := strings.NewReader(sampleXML)
|
||||||
|
result, err := ParseSMSBackup(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range result.Messages {
|
||||||
|
if err := InsertMessage(userDB, &result.Messages[i]); err != nil {
|
||||||
|
t.Fatalf("Failed to insert message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
cleanup := func() {
|
||||||
|
if db != nil {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
if userDB != nil {
|
||||||
|
userDB.Close()
|
||||||
|
}
|
||||||
|
os.Remove(tmpDB)
|
||||||
|
os.Remove(tmpAuthDB)
|
||||||
|
os.Remove(userDBPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDB, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestContext creates an Echo context with user authentication
|
||||||
|
func setupTestContext(method, url string, body string) (echo.Context, *httptest.ResponseRecorder) {
|
||||||
|
e := echo.New()
|
||||||
|
var req *http.Request
|
||||||
|
if body != "" {
|
||||||
|
req = httptest.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, url, nil)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
// Set user context (simulating authentication middleware)
|
||||||
|
// Use the global testUserID which was set by setupTestDB
|
||||||
|
c.Set("user_id", testUserID)
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthEndpoint(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
handler := func(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler(c); err != nil {
|
||||||
|
t.Fatalf("Health check failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Body.String() != "OK" {
|
||||||
|
t.Errorf("Expected body 'OK', got '%s'", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleConversations(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/conversations", "")
|
||||||
|
|
||||||
|
if err := HandleConversations(c); err != nil {
|
||||||
|
t.Fatalf("HandleConversations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []Conversation
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &conversations); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 conversations (one for +15551234567, one for +15559876543)
|
||||||
|
if len(conversations) < 1 {
|
||||||
|
t.Errorf("Expected at least 1 conversation, got %d", len(conversations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleConversationsWithDateRange(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Test with date range that includes all messages
|
||||||
|
start := time.Unix(1285799668, 0).Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
end := time.Unix(1285799671, 0).Format(time.RFC3339)
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/conversations?start="+start+"&end="+end, "")
|
||||||
|
c.QueryParams().Add("start", start)
|
||||||
|
c.QueryParams().Add("end", end)
|
||||||
|
|
||||||
|
if err := HandleConversations(c); err != nil {
|
||||||
|
t.Fatalf("HandleConversations with date range failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []Conversation
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &conversations); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conversations) < 1 {
|
||||||
|
t.Errorf("Expected at least 1 conversation, got %d", len(conversations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessages(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// First get conversations to find a valid address
|
||||||
|
c1, rec1 := setupTestContext(http.MethodGet, "/api/conversations", "")
|
||||||
|
if err := HandleConversations(c1); err != nil {
|
||||||
|
t.Fatalf("HandleConversations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []Conversation
|
||||||
|
if err := json.Unmarshal(rec1.Body.Bytes(), &conversations); err != nil {
|
||||||
|
t.Fatalf("Failed to parse conversations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conversations) == 0 {
|
||||||
|
t.Fatal("No conversations found in test database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first conversation's address
|
||||||
|
testAddress := conversations[0].Address
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/messages?address="+testAddress, "")
|
||||||
|
c.QueryParams().Add("address", testAddress)
|
||||||
|
|
||||||
|
if err := HandleMessages(c); err != nil {
|
||||||
|
t.Fatalf("HandleMessages failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []Message
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &messages); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the response is valid JSON array (might be empty if address format doesn't match)
|
||||||
|
// The important thing is that the handler responds correctly
|
||||||
|
t.Logf("Got %d messages for address %s", len(messages), testAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessagesWithoutAddress(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/messages", "")
|
||||||
|
|
||||||
|
if err := HandleMessages(c); err != nil {
|
||||||
|
t.Fatalf("HandleMessages failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]string
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Failed to parse error response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(response["error"], "Address parameter required") {
|
||||||
|
t.Errorf("Expected error about missing address, got: %s", response["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessagesConversationType(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// First get conversations to find a valid address
|
||||||
|
c1, rec1 := setupTestContext(http.MethodGet, "/api/conversations", "")
|
||||||
|
if err := HandleConversations(c1); err != nil {
|
||||||
|
t.Fatalf("HandleConversations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []Conversation
|
||||||
|
if err := json.Unmarshal(rec1.Body.Bytes(), &conversations); err != nil {
|
||||||
|
t.Fatalf("Failed to parse conversations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conversations) == 0 {
|
||||||
|
t.Fatal("No conversations found in test database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first conversation's address
|
||||||
|
testAddress := conversations[0].Address
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/messages?address="+testAddress+"&type=conversation", "")
|
||||||
|
c.QueryParams().Add("address", testAddress)
|
||||||
|
c.QueryParams().Add("type", "conversation")
|
||||||
|
|
||||||
|
if err := HandleMessages(c); err != nil {
|
||||||
|
t.Fatalf("HandleMessages with type=conversation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activities []ActivityItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the response is valid JSON array (might be empty if address format doesn't match)
|
||||||
|
// The important thing is that the handler responds correctly with type=conversation
|
||||||
|
t.Logf("Got %d activities for address %s with type=conversation", len(activities), testAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleActivity(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/activity", "")
|
||||||
|
|
||||||
|
if err := HandleActivity(c); err != nil {
|
||||||
|
t.Fatalf("HandleActivity failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activities []ActivityItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 3 activities (2 SMS + 1 MMS)
|
||||||
|
if len(activities) != 3 {
|
||||||
|
t.Errorf("Expected 3 activities, got %d", len(activities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleActivityWithPagination(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/activity?limit=1&offset=0", "")
|
||||||
|
c.QueryParams().Add("limit", "1")
|
||||||
|
c.QueryParams().Add("offset", "0")
|
||||||
|
|
||||||
|
if err := HandleActivity(c); err != nil {
|
||||||
|
t.Fatalf("HandleActivity with pagination failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activities []ActivityItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have exactly 1 activity due to limit
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Errorf("Expected 1 activity, got %d", len(activities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDateRange(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/daterange", "")
|
||||||
|
|
||||||
|
if err := HandleDateRange(c); err != nil {
|
||||||
|
t.Fatalf("HandleDateRange failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response["min_date"] == nil {
|
||||||
|
t.Error("Expected min_date in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response["max_date"] == nil {
|
||||||
|
t.Error("Expected max_date in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMedia(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Test without message ID
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/media", "")
|
||||||
|
|
||||||
|
if err := HandleMedia(c); err != nil {
|
||||||
|
t.Fatalf("HandleMedia failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]string
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Failed to parse error response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(response["error"], "Message ID required") {
|
||||||
|
t.Errorf("Expected error about missing message ID, got: %s", response["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMediaNotFound(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/media?id=99999", "")
|
||||||
|
c.QueryParams().Add("id", "99999")
|
||||||
|
|
||||||
|
if err := HandleMedia(c); err != nil {
|
||||||
|
t.Fatalf("HandleMedia failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/search?q=Test", "")
|
||||||
|
c.QueryParams().Add("q", "Test")
|
||||||
|
|
||||||
|
if err := HandleSearch(c); err != nil {
|
||||||
|
t.Fatalf("HandleSearch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should find messages containing "Test"
|
||||||
|
if len(results) < 1 {
|
||||||
|
t.Errorf("Expected at least 1 search result, got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearchEmpty(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/search", "")
|
||||||
|
|
||||||
|
if err := HandleSearch(c); err != nil {
|
||||||
|
t.Fatalf("HandleSearch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return empty array for empty query
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("Expected 0 search results for empty query, got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearchWithLimit(t *testing.T) {
|
||||||
|
_, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/search?q=Test&limit=1", "")
|
||||||
|
c.QueryParams().Add("q", "Test")
|
||||||
|
c.QueryParams().Add("limit", "1")
|
||||||
|
|
||||||
|
if err := HandleSearch(c); err != nil {
|
||||||
|
t.Fatalf("HandleSearch with limit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should respect the limit
|
||||||
|
if len(results) > 1 {
|
||||||
|
t.Errorf("Expected at most 1 search result, got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleProgress(t *testing.T) {
|
||||||
|
c, rec := setupTestContext(http.MethodGet, "/api/progress", "")
|
||||||
|
|
||||||
|
if err := HandleProgress(c); err != nil {
|
||||||
|
t.Fatalf("HandleProgress failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return no_upload status when no upload is in progress
|
||||||
|
if response["status"] != "no_upload" {
|
||||||
|
t.Errorf("Expected status 'no_upload', got '%v'", response["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserDBHelperMissingUserID(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
// Don't set user_id in context
|
||||||
|
_, err := getUserDB(c)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when user_id is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "user_id not found") {
|
||||||
|
t.Errorf("Expected error about missing user_id, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserDBHelperMissingUsername(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
// Set user_id but not username
|
||||||
|
c.Set("user_id", "test-user-id")
|
||||||
|
|
||||||
|
_, err := getUserDB(c)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when username is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "username not found") {
|
||||||
|
t.Errorf("Expected error about missing username, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//go:build !heic
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convertHEICtoJPEG returns a placeholder image when HEIC support is disabled
|
||||||
|
// This version does not require the libheif library
|
||||||
|
func convertHEICtoJPEG(heicData []byte) ([]byte, error) {
|
||||||
|
slog.Warn("HEIC conversion is disabled. Returning placeholder image. Build with -tags heic to enable HEIC support.")
|
||||||
|
|
||||||
|
// Return a simple placeholder JPEG image (400x300 gray rectangle with text)
|
||||||
|
// This is better than returning an error, as it allows the app to function
|
||||||
|
return generatePlaceholderJPEG()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePlaceholderJPEG creates a simple gray placeholder image
|
||||||
|
func generatePlaceholderJPEG() ([]byte, error) {
|
||||||
|
// Create a 400x300 image
|
||||||
|
width, height := 400, 300
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
|
||||||
|
// Fill with gray background
|
||||||
|
gray := color.RGBA{200, 200, 200, 255}
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.Set(x, y, gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a dark border
|
||||||
|
borderColor := color.RGBA{100, 100, 100, 255}
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.Set(x, 0, borderColor)
|
||||||
|
img.Set(x, height-1, borderColor)
|
||||||
|
}
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
img.Set(0, y, borderColor)
|
||||||
|
img.Set(width-1, y, borderColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode as JPEG
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode placeholder image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Return a base64-encoded minimal JPEG (1x1 pixel)
|
||||||
|
// This is more efficient but less user-friendly
|
||||||
|
func generateMinimalPlaceholderJPEG() ([]byte, error) {
|
||||||
|
// 1x1 gray pixel JPEG (base64 encoded minimal JPEG)
|
||||||
|
minimalJPEG := "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA/h"
|
||||||
|
return base64.StdEncoding.DecodeString(minimalJPEG)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build heic
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image/jpeg"
|
||||||
|
|
||||||
|
"github.com/strukturag/libheif-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convertHEICtoJPEG converts HEIC image data to JPEG format
|
||||||
|
// Returns the converted JPEG data or an error if conversion fails
|
||||||
|
// This version requires the libheif library and is enabled with the 'heic' build tag
|
||||||
|
func convertHEICtoJPEG(heicData []byte) ([]byte, error) {
|
||||||
|
// Create a new HEIF context
|
||||||
|
ctx, err := libheif.NewContext()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read HEIC data from memory
|
||||||
|
err = ctx.ReadFromMemory(heicData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the primary image handle
|
||||||
|
handle, err := ctx.GetPrimaryImageHandle()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the image to RGB format
|
||||||
|
img, err := handle.DecodeImage(libheif.ColorspaceRGB, libheif.ChromaInterleavedRGB, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Go's standard image.Image
|
||||||
|
goImg, err := img.GetImage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode as JPEG with high quality
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = jpeg.Encode(&buf, goImg, &jpeg.Options{Quality: 90})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware checks for a valid session cookie
|
||||||
|
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Get session ID from cookie
|
||||||
|
cookie, err := c.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "Unauthorized: No session found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session
|
||||||
|
session, err := GetSession(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "Unauthorized: Invalid or expired session",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store session in context for use by handlers
|
||||||
|
c.Set("session", session)
|
||||||
|
c.Set("user_id", session.UserID)
|
||||||
|
c.Set("username", session.Username)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoCacheMiddleware adds cache control headers to prevent browser caching
|
||||||
|
// This ensures that dynamic API responses are always fetched fresh from the server
|
||||||
|
func NoCacheMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Set headers to prevent caching
|
||||||
|
c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate, private")
|
||||||
|
c.Response().Header().Set("Pragma", "no-cache")
|
||||||
|
c.Response().Header().Set("Expires", "0")
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Type int `json:"type"` // 1 = received, 2 = sent, 3 = draft, 4 = outbox, 5 = failed, 6 = queued
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Read bool `json:"read"`
|
||||||
|
ThreadID int `json:"thread_id"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
MediaType string `json:"media_type,omitempty"`
|
||||||
|
MediaData []byte `json:"-"`
|
||||||
|
MediaBase64 string `json:"media_base64,omitempty"`
|
||||||
|
// Additional SMS fields
|
||||||
|
Protocol int `json:"protocol,omitempty"`
|
||||||
|
Status int `json:"status,omitempty"` // -1 = none, 0 = complete, 32 = pending, 64 = failed
|
||||||
|
ServiceCenter string `json:"service_center,omitempty"`
|
||||||
|
SubID int `json:"sub_id,omitempty"`
|
||||||
|
ContactName string `json:"contact_name,omitempty"`
|
||||||
|
Sender string `json:"sender,omitempty"` // Sender phone number for received messages
|
||||||
|
// Additional MMS fields
|
||||||
|
ContentType string `json:"content_type,omitempty"` // ct_t field
|
||||||
|
ReadReport int `json:"read_report,omitempty"` // rr field
|
||||||
|
ReadStatus int `json:"read_status,omitempty"`
|
||||||
|
MessageID string `json:"message_id,omitempty"` // m_id field
|
||||||
|
MessageSize int `json:"message_size,omitempty"` // m_size field
|
||||||
|
MessageType int `json:"message_type,omitempty"` // m_type field
|
||||||
|
SimSlot int `json:"sim_slot,omitempty"`
|
||||||
|
Addresses []string `json:"addresses,omitempty"` // All phone numbers in conversation (for MMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Duration int `json:"duration"` // in seconds
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Type int `json:"type"` // 1 = incoming, 2 = outgoing, 3 = missed, 4 = voicemail, 5 = rejected, 6 = refused
|
||||||
|
Presentation int `json:"presentation,omitempty"` // 1 = allowed, 2 = restricted, 3 = unknown, 4 = payphone
|
||||||
|
SubscriptionID string `json:"subscription_id,omitempty"`
|
||||||
|
ContactName string `json:"contact_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Conversation struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
ContactName string `json:"contact_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
LastMessage string `json:"last_message"`
|
||||||
|
LastDate time.Time `json:"last_date"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
Type string `json:"type"` // "sms", "mms", or "call"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityItem struct {
|
||||||
|
Type string `json:"type"` // "message" or "call"
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
ContactName string `json:"contact_name,omitempty"`
|
||||||
|
// Message-specific fields
|
||||||
|
Message *Message `json:"message,omitempty"`
|
||||||
|
// Call-specific fields
|
||||||
|
Call *CallLog `json:"call,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
CallLogCount int `json:"call_log_count"`
|
||||||
|
Processing bool `json:"processing,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"` // Never send password hash to client
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
User *User `json:"user,omitempty"`
|
||||||
|
Session *Session `json:"session,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
ConfirmPassword string `json:"confirm_password"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SMSBackup struct {
|
||||||
|
XMLName xml.Name `xml:"smses"`
|
||||||
|
Count int `xml:"count,attr"`
|
||||||
|
Messages []SMSEntry `xml:"sms"`
|
||||||
|
MMS []MMSEntry `xml:"mms"`
|
||||||
|
Calls []CallEntry `xml:"call"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSEntry struct {
|
||||||
|
Address string `xml:"address,attr"`
|
||||||
|
Date string `xml:"date,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Body string `xml:"body,attr"`
|
||||||
|
Read string `xml:"read,attr"`
|
||||||
|
ThreadID string `xml:"thread_id,attr"`
|
||||||
|
Subject string `xml:"subject,attr"`
|
||||||
|
Protocol string `xml:"protocol,attr"`
|
||||||
|
TOA string `xml:"toa,attr"`
|
||||||
|
SCTOA string `xml:"sc_toa,attr"`
|
||||||
|
ServiceCenter string `xml:"service_center,attr"`
|
||||||
|
Status string `xml:"status,attr"`
|
||||||
|
SubID string `xml:"sub_id,attr"`
|
||||||
|
ReadableDate string `xml:"readable_date,attr"`
|
||||||
|
ContactName string `xml:"contact_name,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MMSEntry struct {
|
||||||
|
Address string `xml:"address,attr"`
|
||||||
|
Date string `xml:"date,attr"`
|
||||||
|
Type string `xml:"msg_box,attr"`
|
||||||
|
Read string `xml:"read,attr"`
|
||||||
|
ThreadID string `xml:"thread_id,attr"`
|
||||||
|
Subject string `xml:"sub,attr"`
|
||||||
|
TrID string `xml:"tr_id,attr"`
|
||||||
|
ContentType string `xml:"ct_t,attr"`
|
||||||
|
ReadReport string `xml:"rr,attr"`
|
||||||
|
ReadStatus string `xml:"read_status,attr"`
|
||||||
|
MessageID string `xml:"m_id,attr"`
|
||||||
|
MessageSize string `xml:"m_size,attr"`
|
||||||
|
MessageType string `xml:"m_type,attr"`
|
||||||
|
SimSlot string `xml:"sim_slot,attr"`
|
||||||
|
ReadableDate string `xml:"readable_date,attr"`
|
||||||
|
ContactName string `xml:"contact_name,attr"`
|
||||||
|
Parts []MMSPart `xml:"parts>part"`
|
||||||
|
Addrs []MMSAddr `xml:"addrs>addr"`
|
||||||
|
Body string `xml:"body,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MMSPart struct {
|
||||||
|
Seq string `xml:"seq,attr"`
|
||||||
|
ContentType string `xml:"ct,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Charset string `xml:"chset,attr"`
|
||||||
|
CL string `xml:"cl,attr"`
|
||||||
|
Text string `xml:"text,attr"`
|
||||||
|
Data string `xml:"data,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MMSAddr struct {
|
||||||
|
Address string `xml:"address,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Charset string `xml:"charset,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallEntry struct {
|
||||||
|
Number string `xml:"number,attr"`
|
||||||
|
Duration string `xml:"duration,attr"`
|
||||||
|
Date string `xml:"date,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Presentation string `xml:"presentation,attr"`
|
||||||
|
SubscriptionID string `xml:"subscription_id,attr"`
|
||||||
|
ReadableDate string `xml:"readable_date,attr"`
|
||||||
|
ContactName string `xml:"contact_name,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParseResult struct {
|
||||||
|
Messages []Message
|
||||||
|
Calls []CallLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseSMSBackup(r io.Reader) (ParseResult, error) {
|
||||||
|
var backup SMSBackup
|
||||||
|
decoder := xml.NewDecoder(r)
|
||||||
|
err := decoder.Decode(&backup)
|
||||||
|
if err != nil {
|
||||||
|
return ParseResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ParseResult
|
||||||
|
|
||||||
|
// Parse SMS messages
|
||||||
|
for _, sms := range backup.Messages {
|
||||||
|
msg, err := convertSMSEntry(sms)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error parsing SMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Messages = append(result.Messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MMS messages
|
||||||
|
for _, mms := range backup.MMS {
|
||||||
|
msg, err := convertMMSEntry(mms)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error parsing MMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Messages = append(result.Messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse call logs
|
||||||
|
for _, call := range backup.Calls {
|
||||||
|
callLog, err := convertCallEntry(call)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error parsing call log", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Calls = append(result.Calls, callLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSMSEntry(sms SMSEntry) (Message, error) {
|
||||||
|
dateMs, err := strconv.ParseInt(sms.Date, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType, _ := strconv.Atoi(sms.Type)
|
||||||
|
read := sms.Read == "1"
|
||||||
|
threadID, _ := strconv.Atoi(sms.ThreadID)
|
||||||
|
protocol, _ := strconv.Atoi(sms.Protocol)
|
||||||
|
status, _ := strconv.Atoi(sms.Status)
|
||||||
|
subID, _ := strconv.Atoi(sms.SubID)
|
||||||
|
|
||||||
|
// Normalize the phone number to remove formatting differences
|
||||||
|
normalizedAddress := normalizePhoneNumber(sms.Address)
|
||||||
|
|
||||||
|
// For SMS, the address is the single phone number
|
||||||
|
addresses := []string{}
|
||||||
|
if normalizedAddress != "" {
|
||||||
|
addresses = append(addresses, normalizedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For received SMS messages, the sender is the address
|
||||||
|
var sender string
|
||||||
|
if msgType == 1 && normalizedAddress != "" {
|
||||||
|
sender = normalizedAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
return Message{
|
||||||
|
Address: normalizedAddress,
|
||||||
|
Body: sms.Body,
|
||||||
|
Type: msgType,
|
||||||
|
Date: time.Unix(dateMs/1000, 0),
|
||||||
|
Read: read,
|
||||||
|
ThreadID: threadID,
|
||||||
|
Subject: normalizeNullString(sms.Subject),
|
||||||
|
Protocol: protocol,
|
||||||
|
Status: status,
|
||||||
|
ServiceCenter: sms.ServiceCenter,
|
||||||
|
SubID: subID,
|
||||||
|
ContactName: sms.ContactName,
|
||||||
|
Sender: sender,
|
||||||
|
Addresses: addresses,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMMSEntry(mms MMSEntry) (Message, error) {
|
||||||
|
dateMs, err := strconv.ParseInt(mms.Date, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType, _ := strconv.Atoi(mms.Type)
|
||||||
|
read := mms.Read == "1"
|
||||||
|
threadID, _ := strconv.Atoi(mms.ThreadID)
|
||||||
|
readReport, _ := strconv.Atoi(mms.ReadReport)
|
||||||
|
readStatus, _ := strconv.Atoi(mms.ReadStatus)
|
||||||
|
messageSize, _ := strconv.Atoi(mms.MessageSize)
|
||||||
|
messageType, _ := strconv.Atoi(mms.MessageType)
|
||||||
|
simSlot, _ := strconv.Atoi(mms.SimSlot)
|
||||||
|
|
||||||
|
// Normalize the phone number to remove formatting differences
|
||||||
|
normalizedAddress := normalizePhoneNumber(mms.Address)
|
||||||
|
|
||||||
|
// Extract all addresses from MMS and find the sender (type 137 = FROM)
|
||||||
|
// Include ALL addresses to keep group conversations consistent
|
||||||
|
addressMap := make(map[string]bool)
|
||||||
|
var senderAddress string
|
||||||
|
var firstAddress string
|
||||||
|
|
||||||
|
for _, addr := range mms.Addrs {
|
||||||
|
if addr.Address != "" {
|
||||||
|
// Normalize each address to prevent duplicates due to formatting
|
||||||
|
normalizedAddr := normalizePhoneNumber(addr.Address)
|
||||||
|
if normalizedAddr != "" {
|
||||||
|
addressMap[normalizedAddr] = true
|
||||||
|
|
||||||
|
// Remember the first address we encounter
|
||||||
|
if firstAddress == "" {
|
||||||
|
firstAddress = normalizedAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type 137 (0x89) = FROM (sender in Android MMS)
|
||||||
|
// For received messages, this tells us who sent it
|
||||||
|
addrType, _ := strconv.Atoi(addr.Type)
|
||||||
|
if addrType == 137 {
|
||||||
|
senderAddress = normalizedAddr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no type 137 sender was found for a received message, use the first address
|
||||||
|
// or the single address for 1-on-1 conversations
|
||||||
|
if msgType == 1 && senderAddress == "" {
|
||||||
|
if len(addressMap) == 1 && firstAddress != "" {
|
||||||
|
// 1-on-1 conversation: the single address is definitely the sender
|
||||||
|
senderAddress = firstAddress
|
||||||
|
} else if len(addressMap) > 1 && firstAddress != "" {
|
||||||
|
// Group conversation without explicit sender: use first address as best guess
|
||||||
|
senderAddress = firstAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to sorted, deduplicated slice
|
||||||
|
addresses := make([]string, 0, len(addressMap))
|
||||||
|
for addr := range addressMap {
|
||||||
|
addresses = append(addresses, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort addresses for consistency
|
||||||
|
sort.Strings(addresses)
|
||||||
|
|
||||||
|
// Determine the primary address field for conversation grouping
|
||||||
|
var primaryAddress string
|
||||||
|
if len(addresses) >= 3 {
|
||||||
|
// Group MMS (3+ participants) - join all normalized addresses to create a consistent group identifier
|
||||||
|
primaryAddress = strings.Join(addresses, ",")
|
||||||
|
} else if len(addresses) > 0 {
|
||||||
|
// MMS with 1-2 addresses - use the normalized address
|
||||||
|
primaryAddress = normalizedAddress
|
||||||
|
} else {
|
||||||
|
// Fallback to normalized mms.Address if no addresses found in mms.Addrs
|
||||||
|
primaryAddress = normalizedAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
// For received messages, store the sender in the Sender field
|
||||||
|
// This allows us to display who sent each message in the UI
|
||||||
|
var sender string
|
||||||
|
if msgType == 1 && senderAddress != "" {
|
||||||
|
// Received message - store the sender address
|
||||||
|
sender = senderAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
Address: primaryAddress,
|
||||||
|
Type: msgType,
|
||||||
|
Date: time.Unix(dateMs/1000, 0),
|
||||||
|
Read: read,
|
||||||
|
ThreadID: threadID,
|
||||||
|
Subject: normalizeNullString(mms.Subject),
|
||||||
|
ContentType: mms.ContentType,
|
||||||
|
ReadReport: readReport,
|
||||||
|
ReadStatus: readStatus,
|
||||||
|
MessageID: mms.MessageID,
|
||||||
|
MessageSize: messageSize,
|
||||||
|
MessageType: messageType,
|
||||||
|
SimSlot: simSlot,
|
||||||
|
ContactName: mms.ContactName,
|
||||||
|
Sender: sender,
|
||||||
|
Addresses: addresses,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body text and media from parts
|
||||||
|
var bodyText string
|
||||||
|
for _, part := range mms.Parts {
|
||||||
|
// Skip SMIL content - it's presentation metadata, not actual message content
|
||||||
|
if isSMILContentType(part.ContentType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for VCF (vCard) files - these are text/* but should be treated as media attachments
|
||||||
|
if isVCardContentType(part.ContentType) && part.Data != "" {
|
||||||
|
if msg.MediaType == "" { // Only store first media item
|
||||||
|
data, err := base64.StdEncoding.DecodeString(part.Data)
|
||||||
|
if err == nil {
|
||||||
|
msg.MediaType = part.ContentType
|
||||||
|
msg.MediaData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for media - media parts often have text="null" which should be ignored
|
||||||
|
if part.ContentType != "" && part.Data != "" && !isTextContentType(part.ContentType) {
|
||||||
|
// This is media content (image, video, audio, etc.)
|
||||||
|
if msg.MediaType == "" { // Only store first media item
|
||||||
|
data, err := base64.StdEncoding.DecodeString(part.Data)
|
||||||
|
if err == nil {
|
||||||
|
// Store all media as-is (including HEIC images in original format)
|
||||||
|
msg.MediaType = part.ContentType
|
||||||
|
msg.MediaData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if part.Text != "" && normalizeNullString(part.Text) != "" {
|
||||||
|
// This is actual text content (not "null")
|
||||||
|
bodyText += part.Text + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bodyText != "" {
|
||||||
|
msg.Body = strings.TrimSpace(bodyText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract group name from RCS proto: tr_id if available
|
||||||
|
// Use it as the subject if the current subject is empty or starts with "proto:"
|
||||||
|
if mms.TrID != "" && strings.HasPrefix(mms.TrID, "proto:") {
|
||||||
|
groupName := extractGroupNameFromTrID(mms.TrID)
|
||||||
|
if groupName != "" {
|
||||||
|
// Only use the extracted name if subject is empty or also starts with "proto:"
|
||||||
|
if msg.Subject == "" || strings.HasPrefix(mms.Subject, "proto:") {
|
||||||
|
msg.Subject = groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeNullString converts the string "null" to an empty string
|
||||||
|
func normalizeNullString(s string) string {
|
||||||
|
if strings.TrimSpace(strings.ToLower(s)) == "null" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTextContentType checks if a content type is text-based
|
||||||
|
func isTextContentType(contentType string) bool {
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
return strings.HasPrefix(ct, "text/") ||
|
||||||
|
ct == "application/xml" ||
|
||||||
|
ct == "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSMILContentType checks if a content type is SMIL markup
|
||||||
|
func isSMILContentType(contentType string) bool {
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
return ct == "application/smil" ||
|
||||||
|
strings.HasPrefix(ct, "application/smil+") ||
|
||||||
|
strings.Contains(ct, "smil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSMILMarkup checks if the body text is SMIL (Synchronized Multimedia Integration Language) markup
|
||||||
|
// which is MMS presentation metadata and should not be displayed to users
|
||||||
|
func isSMILMarkup(body string) bool {
|
||||||
|
trimmed := strings.TrimSpace(body)
|
||||||
|
return strings.HasPrefix(trimmed, "<smil") || strings.HasPrefix(trimmed, "<?xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVCardContentType checks if a content type is vCard format
|
||||||
|
func isVCardContentType(contentType string) bool {
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
return ct == "text/vcard" || ct == "text/x-vcard" || ct == "text/directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGroupNameFromTrID extracts the group conversation name from RCS proto: tr_id field
|
||||||
|
func extractGroupNameFromTrID(trID string) string {
|
||||||
|
return ""
|
||||||
|
/*
|
||||||
|
// Check if tr_id starts with "proto:"
|
||||||
|
if !strings.HasPrefix(trID, "proto:") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the "proto:" prefix
|
||||||
|
protoData := strings.TrimPrefix(trID, "proto:")
|
||||||
|
|
||||||
|
// Base64 decode the remaining bytes
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(protoData)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to base64 decode tr_id", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have enough bytes (need at least 84 bytes: offset 83 + 1 for length)
|
||||||
|
if len(decoded) < 84 {
|
||||||
|
slog.Debug("Decoded tr_id too short", "bytes", len(decoded), "required", 84)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the length byte at offset 83
|
||||||
|
nameLength := int(decoded[83])
|
||||||
|
|
||||||
|
// Check if we have enough bytes for the name
|
||||||
|
if len(decoded) < 84+nameLength {
|
||||||
|
slog.Debug("Not enough bytes for group name", "have", len(decoded), "need", 84+nameLength)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the group name string
|
||||||
|
groupName := string(decoded[84 : 84+nameLength])
|
||||||
|
|
||||||
|
slog.Debug("Extracted group name from tr_id", "group_name", groupName)
|
||||||
|
return groupName
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHEICContentType checks if a content type is HEIC/HEIF format
|
||||||
|
func isHEICContentType(contentType string) bool {
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
return strings.Contains(ct, "heic") || strings.Contains(ct, "heif")
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsVideoConversion checks if a video format needs conversion for browser compatibility
|
||||||
|
func needsVideoConversion(contentType string) bool {
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
// 3GP, 3G2, and other old mobile formats that browsers don't support
|
||||||
|
unsupportedFormats := []string{
|
||||||
|
"3gpp", "3gp", "3g2", "3gpp2",
|
||||||
|
"video/3gpp", "video/3gp", "video/3gpp2", "video/3g2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range unsupportedFormats {
|
||||||
|
if strings.Contains(ct, format) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertHEICtoJPEG is implemented in heic_enabled.go (with -tags heic) or heic_disabled.go (default)
|
||||||
|
// When HEIC support is enabled, it converts HEIC image data to JPEG format
|
||||||
|
// When HEIC support is disabled, it returns a placeholder image
|
||||||
|
|
||||||
|
// convertVideoToMP4 converts unsupported video formats (like 3GP) to MP4 using ffmpeg
|
||||||
|
// Returns the converted MP4 data or an error if conversion fails
|
||||||
|
func convertVideoToMP4(videoData []byte) ([]byte, error) {
|
||||||
|
// Create temporary files for input and output
|
||||||
|
tmpInputFile, err := os.CreateTemp("", "video-input-*.3gp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp input file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpInputFile.Name())
|
||||||
|
defer tmpInputFile.Close()
|
||||||
|
|
||||||
|
tmpOutputFile, err := os.CreateTemp("", "video-output-*.mp4")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp output file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpOutputFile.Name())
|
||||||
|
tmpOutputFile.Close()
|
||||||
|
|
||||||
|
// Write input video data to temp file
|
||||||
|
_, err = tmpInputFile.Write(videoData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write input video: %w", err)
|
||||||
|
}
|
||||||
|
tmpInputFile.Close()
|
||||||
|
|
||||||
|
// Run ffmpeg to convert video to MP4 with H.264 codec
|
||||||
|
// -i: input file
|
||||||
|
// -c:v libx264: use H.264 video codec
|
||||||
|
// -c:a aac: use AAC audio codec
|
||||||
|
// -movflags +faststart: optimize for streaming
|
||||||
|
// -preset fast: balance between speed and quality
|
||||||
|
// -crf 23: constant rate factor (quality, lower is better, 23 is good default)
|
||||||
|
cmd := exec.Command("ffmpeg",
|
||||||
|
"-i", tmpInputFile.Name(),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-preset", "fast",
|
||||||
|
"-crf", "23",
|
||||||
|
"-y", // overwrite output file
|
||||||
|
tmpOutputFile.Name(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capture stderr for error messages
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg conversion failed: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read converted video data
|
||||||
|
convertedData, err := os.ReadFile(tmpOutputFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read converted video: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertCallEntry(call CallEntry) (CallLog, error) {
|
||||||
|
dateMs, err := strconv.ParseInt(call.Date, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return CallLog{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, _ := strconv.Atoi(call.Duration)
|
||||||
|
callType, _ := strconv.Atoi(call.Type)
|
||||||
|
presentation, _ := strconv.Atoi(call.Presentation)
|
||||||
|
|
||||||
|
// Normalize the phone number to remove formatting differences
|
||||||
|
normalizedNumber := normalizePhoneNumber(call.Number)
|
||||||
|
|
||||||
|
return CallLog{
|
||||||
|
Number: normalizedNumber,
|
||||||
|
Duration: duration,
|
||||||
|
Date: time.Unix(dateMs/1000, 0),
|
||||||
|
Type: callType,
|
||||||
|
Presentation: presentation,
|
||||||
|
SubscriptionID: call.SubscriptionID,
|
||||||
|
ContactName: call.ContactName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProgress tracks the progress of an ongoing upload
|
||||||
|
type UploadProgress struct {
|
||||||
|
TotalMessages int `json:"total_messages"`
|
||||||
|
ProcessedMessages int `json:"processed_messages"`
|
||||||
|
TotalCalls int `json:"total_calls"`
|
||||||
|
ProcessedCalls int `json:"processed_calls"`
|
||||||
|
Status string `json:"status"` // "parsing", "importing", "completed", "error"
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
uploadProgress *UploadProgress
|
||||||
|
uploadProgressLock sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUploadProgress returns the current upload progress
|
||||||
|
func GetUploadProgress() *UploadProgress {
|
||||||
|
uploadProgressLock.RLock()
|
||||||
|
defer uploadProgressLock.RUnlock()
|
||||||
|
|
||||||
|
if uploadProgress == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.mu.RLock()
|
||||||
|
defer uploadProgress.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return a copy to avoid race conditions
|
||||||
|
return &UploadProgress{
|
||||||
|
TotalMessages: uploadProgress.TotalMessages,
|
||||||
|
ProcessedMessages: uploadProgress.ProcessedMessages,
|
||||||
|
TotalCalls: uploadProgress.TotalCalls,
|
||||||
|
ProcessedCalls: uploadProgress.ProcessedCalls,
|
||||||
|
Status: uploadProgress.Status,
|
||||||
|
ErrorMessage: uploadProgress.ErrorMessage,
|
||||||
|
StartTime: uploadProgress.StartTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUploadProgress initializes or updates the upload progress
|
||||||
|
func SetUploadProgress(total, processed int, status string) {
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
defer uploadProgressLock.Unlock()
|
||||||
|
|
||||||
|
if uploadProgress == nil {
|
||||||
|
uploadProgress = &UploadProgress{
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
defer uploadProgress.mu.Unlock()
|
||||||
|
|
||||||
|
uploadProgress.TotalMessages = total
|
||||||
|
uploadProgress.ProcessedMessages = processed
|
||||||
|
uploadProgress.Status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMessageProgress updates the progress for messages
|
||||||
|
func UpdateMessageProgress(processed int) {
|
||||||
|
uploadProgressLock.RLock()
|
||||||
|
defer uploadProgressLock.RUnlock()
|
||||||
|
|
||||||
|
if uploadProgress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
defer uploadProgress.mu.Unlock()
|
||||||
|
|
||||||
|
uploadProgress.ProcessedMessages = processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCallProgress updates the progress for calls
|
||||||
|
func UpdateCallProgress(processed int) {
|
||||||
|
uploadProgressLock.RLock()
|
||||||
|
defer uploadProgressLock.RUnlock()
|
||||||
|
|
||||||
|
if uploadProgress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
defer uploadProgress.mu.Unlock()
|
||||||
|
|
||||||
|
uploadProgress.ProcessedCalls = processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearUploadProgress clears the upload progress
|
||||||
|
func ClearUploadProgress() {
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
defer uploadProgressLock.Unlock()
|
||||||
|
uploadProgress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUploadedFile saves the uploaded file to a temporary location
|
||||||
|
func SaveUploadedFile(file io.Reader, filename string) (string, error) {
|
||||||
|
// Create temp directory if it doesn't exist
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
uploadDir := filepath.Join(tempDir, "sbv-uploads")
|
||||||
|
err := os.MkdirAll(uploadDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create upload directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
tempFile, err := os.CreateTemp(uploadDir, "backup-*.xml")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Copy uploaded file to temp file
|
||||||
|
_, err = io.Copy(tempFile, file)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
return "", fmt.Errorf("failed to save file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessUploadedFile processes the uploaded file in the background
|
||||||
|
func ProcessUploadedFile(userID string, username string, filePath string) {
|
||||||
|
defer func() {
|
||||||
|
// Always clean up the temp file when done
|
||||||
|
slog.Info("Removing temporary file", "path", filePath)
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
slog.Warn("Failed to remove temp file", "path", filePath, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
slog.Info("Starting background processing", "path", filePath, "user", username)
|
||||||
|
|
||||||
|
// Get user database
|
||||||
|
userDB, err := GetUserDB(userID, username)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting user database", "error", err)
|
||||||
|
SetUploadProgress(0, 0, "error")
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
if uploadProgress != nil {
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
uploadProgress.ErrorMessage = fmt.Sprintf("Failed to get user database: %v", err)
|
||||||
|
uploadProgress.mu.Unlock()
|
||||||
|
}
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the file for reading
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error opening file", "error", err)
|
||||||
|
SetUploadProgress(0, 0, "error")
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
if uploadProgress != nil {
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
uploadProgress.ErrorMessage = fmt.Sprintf("Failed to open file: %v", err)
|
||||||
|
uploadProgress.mu.Unlock()
|
||||||
|
}
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Process with streaming parser (batch size 1 for minimal memory)
|
||||||
|
messageCount, callCount, err := ParseSMSBackupStreaming(userDB, file, 1) // Insert immediately, no batching
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error processing file", "error", err)
|
||||||
|
SetUploadProgress(0, 0, "error")
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
if uploadProgress != nil {
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
uploadProgress.ErrorMessage = fmt.Sprintf("Failed to process file: %v", err)
|
||||||
|
uploadProgress.mu.Unlock()
|
||||||
|
}
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Completed processing", "messages", messageCount, "calls", callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSMSBackupStreaming parses SMS backup file with streaming to reduce memory usage
|
||||||
|
// Each message is inserted immediately and memory is freed aggressively
|
||||||
|
func ParseSMSBackupStreaming(userDB *sql.DB, r io.Reader, batchSize int) (int, int, error) {
|
||||||
|
// Initialize progress tracking
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
uploadProgress = &UploadProgress{
|
||||||
|
Status: "parsing",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(r)
|
||||||
|
|
||||||
|
var messageCount, callCount int
|
||||||
|
|
||||||
|
// Track total count from root element if available
|
||||||
|
var totalCount int
|
||||||
|
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
SetUploadProgress(0, 0, "error")
|
||||||
|
return messageCount, callCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch elem := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
// Get total count from root element
|
||||||
|
if elem.Name.Local == "smses" {
|
||||||
|
for _, attr := range elem.Attr {
|
||||||
|
if attr.Name.Local == "count" {
|
||||||
|
totalCount, _ = strconv.Atoi(attr.Value)
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
uploadProgress.TotalMessages = totalCount
|
||||||
|
uploadProgress.mu.Unlock()
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process SMS messages
|
||||||
|
if elem.Name.Local == "sms" {
|
||||||
|
var sms SMSEntry
|
||||||
|
err := decoder.DecodeElement(&sms, &elem)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error decoding SMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := convertSMSEntry(sms)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error converting SMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert immediately - no batching
|
||||||
|
err = InsertMessage(userDB, &msg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error inserting message", "error", err)
|
||||||
|
} else {
|
||||||
|
messageCount++
|
||||||
|
UpdateMessageProgress(messageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force garbage collection every 1000 messages to keep memory low
|
||||||
|
if messageCount%1000 == 0 {
|
||||||
|
runtime.GC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process MMS messages
|
||||||
|
if elem.Name.Local == "mms" {
|
||||||
|
var mms MMSEntry
|
||||||
|
err := decoder.DecodeElement(&mms, &elem)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error decoding MMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := convertMMSEntry(mms)
|
||||||
|
|
||||||
|
// Clear the MMS struct immediately after conversion to free base64 strings
|
||||||
|
mms.Parts = nil
|
||||||
|
mms = MMSEntry{}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error converting MMS", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert immediately - no batching
|
||||||
|
err = InsertMessage(userDB, &msg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error inserting message", "error", err)
|
||||||
|
} else {
|
||||||
|
messageCount++
|
||||||
|
UpdateMessageProgress(messageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the message data immediately after insert
|
||||||
|
msg.MediaData = nil
|
||||||
|
msg = Message{}
|
||||||
|
|
||||||
|
// Force garbage collection every 100 MMS messages (they're larger)
|
||||||
|
if messageCount%100 == 0 {
|
||||||
|
runtime.GC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process call logs
|
||||||
|
if elem.Name.Local == "call" {
|
||||||
|
var call CallEntry
|
||||||
|
err := decoder.DecodeElement(&call, &elem)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error decoding call", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
callLog, err := convertCallEntry(call)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error converting call", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert immediately - no batching
|
||||||
|
err = InsertCallLog(userDB, &callLog)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error inserting call log", "error", err)
|
||||||
|
} else {
|
||||||
|
callCount++
|
||||||
|
uploadProgressLock.Lock()
|
||||||
|
uploadProgress.mu.Lock()
|
||||||
|
uploadProgress.TotalCalls++
|
||||||
|
uploadProgress.ProcessedCalls = callCount
|
||||||
|
uploadProgress.mu.Unlock()
|
||||||
|
uploadProgressLock.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final garbage collection
|
||||||
|
runtime.GC()
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
SetUploadProgress(messageCount, messageCount, "completed")
|
||||||
|
|
||||||
|
return messageCount, callCount, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleXML = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<?xml-stylesheet type="text/xsl" href="sms.xsl"?>
|
||||||
|
<smses count="2">
|
||||||
|
<sms protocol="0" address="332" date="1285799668193" type="2" subject="null" body="Sample Message Sent from the phone" toa="null" sc_toa="null" service_center="null" read="1" status="-1" locked="0" readable_date="Sep 30, 2010 8:34:28 AM" contact_name="(Unknown)" />
|
||||||
|
<sms protocol="0" address="4433221123" date="1289643415810" type="1" subject="null" body="Sample Message received by the phone" toa="null" sc_toa="null" service_center="null" read="0" status="-1" locked="0" readable_date="Nov 13, 2010 9:16:55 PM" contact_name="(Unknown)" />
|
||||||
|
</smses>`
|
||||||
|
|
||||||
|
func TestSampleXMLParsing(t *testing.T) {
|
||||||
|
// Parse the XML
|
||||||
|
reader := strings.NewReader(sampleXML)
|
||||||
|
result, err := ParseSMSBackup(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we got 2 messages
|
||||||
|
if len(result.Messages) != 2 {
|
||||||
|
t.Errorf("Expected 2 messages, got %d", len(result.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first message (sent)
|
||||||
|
msg1 := result.Messages[0]
|
||||||
|
if msg1.Address != "332" {
|
||||||
|
t.Errorf("Expected address '332', got '%s'", msg1.Address)
|
||||||
|
}
|
||||||
|
if msg1.Type != 2 {
|
||||||
|
t.Errorf("Expected type 2 (sent), got %d", msg1.Type)
|
||||||
|
}
|
||||||
|
if msg1.Body != "Sample Message Sent from the phone" {
|
||||||
|
t.Errorf("Expected body 'Sample Message Sent from the phone', got '%s'", msg1.Body)
|
||||||
|
}
|
||||||
|
if msg1.Protocol != 0 {
|
||||||
|
t.Errorf("Expected protocol 0, got %d", msg1.Protocol)
|
||||||
|
}
|
||||||
|
if !msg1.Read {
|
||||||
|
t.Errorf("Expected message to be read (read=1)")
|
||||||
|
}
|
||||||
|
if msg1.Status != -1 {
|
||||||
|
t.Errorf("Expected status -1, got %d", msg1.Status)
|
||||||
|
}
|
||||||
|
// Check date: 1285799668193 milliseconds = Sep 30, 2010 8:34:28 AM
|
||||||
|
expectedDate1 := time.Unix(1285799668, 0)
|
||||||
|
if !msg1.Date.Equal(expectedDate1) {
|
||||||
|
t.Errorf("Expected date %v, got %v", expectedDate1, msg1.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify second message (received)
|
||||||
|
msg2 := result.Messages[1]
|
||||||
|
// Phone number normalization adds +1 to 10-digit US numbers
|
||||||
|
if msg2.Address != "+14433221123" {
|
||||||
|
t.Errorf("Expected address '+14433221123', got '%s'", msg2.Address)
|
||||||
|
}
|
||||||
|
if msg2.Type != 1 {
|
||||||
|
t.Errorf("Expected type 1 (received), got %d", msg2.Type)
|
||||||
|
}
|
||||||
|
if msg2.Body != "Sample Message received by the phone" {
|
||||||
|
t.Errorf("Expected body 'Sample Message received by the phone', got '%s'", msg2.Body)
|
||||||
|
}
|
||||||
|
if msg2.Read {
|
||||||
|
t.Errorf("Expected message to be unread (read=0)")
|
||||||
|
}
|
||||||
|
// Check date: 1289643415810 milliseconds = Nov 13, 2010 9:16:55 PM
|
||||||
|
expectedDate2 := time.Unix(1289643415, 0)
|
||||||
|
if !msg2.Date.Equal(expectedDate2) {
|
||||||
|
t.Errorf("Expected date %v, got %v", expectedDate2, msg2.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no call logs in this sample
|
||||||
|
if len(result.Calls) != 0 {
|
||||||
|
t.Errorf("Expected 0 call logs, got %d", len(result.Calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSampleXMLDatabaseIngestion(t *testing.T) {
|
||||||
|
// Create a temporary database file
|
||||||
|
tmpDB := "test_messages.db"
|
||||||
|
defer os.Remove(tmpDB) // Clean up after test
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
err := InitDB(tmpDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Parse the XML
|
||||||
|
reader := strings.NewReader(sampleXML)
|
||||||
|
result, err := ParseSMSBackup(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert messages into database
|
||||||
|
messageCount := 0
|
||||||
|
for i := range result.Messages {
|
||||||
|
err := InsertMessage(db, &result.Messages[i])
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to insert message %d: %v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messageCount++
|
||||||
|
|
||||||
|
// Verify the ID was set
|
||||||
|
if result.Messages[i].ID == 0 {
|
||||||
|
t.Errorf("Message %d: ID was not set after insert", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we inserted 2 messages
|
||||||
|
if messageCount != 2 {
|
||||||
|
t.Errorf("Expected to insert 2 messages, inserted %d", messageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve messages from database and verify
|
||||||
|
messages, err := GetMessages(db, "332", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve messages for address '332': %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Errorf("Expected 1 message for address '332', got %d", len(messages))
|
||||||
|
} else {
|
||||||
|
msg := messages[0]
|
||||||
|
if msg.Body != "Sample Message Sent from the phone" {
|
||||||
|
t.Errorf("Retrieved message has wrong body: '%s'", msg.Body)
|
||||||
|
}
|
||||||
|
if msg.Type != 2 {
|
||||||
|
t.Errorf("Retrieved message has wrong type: %d", msg.Type)
|
||||||
|
}
|
||||||
|
if msg.Protocol != 0 {
|
||||||
|
t.Errorf("Retrieved message has wrong protocol: %d", msg.Protocol)
|
||||||
|
}
|
||||||
|
if msg.Status != -1 {
|
||||||
|
t.Errorf("Retrieved message has wrong status: %d", msg.Status)
|
||||||
|
}
|
||||||
|
if !msg.Read {
|
||||||
|
t.Errorf("Retrieved message should be marked as read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve second message
|
||||||
|
messages2, err := GetMessages(db, "+14433221123", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve messages for address '+14433221123': %v", err)
|
||||||
|
}
|
||||||
|
if len(messages2) != 1 {
|
||||||
|
t.Errorf("Expected 1 message for address '+14433221123', got %d", len(messages2))
|
||||||
|
} else {
|
||||||
|
msg := messages2[0]
|
||||||
|
if msg.Body != "Sample Message received by the phone" {
|
||||||
|
t.Errorf("Retrieved message has wrong body: '%s'", msg.Body)
|
||||||
|
}
|
||||||
|
if msg.Type != 1 {
|
||||||
|
t.Errorf("Retrieved message has wrong type: %d", msg.Type)
|
||||||
|
}
|
||||||
|
if msg.Read {
|
||||||
|
t.Errorf("Retrieved message should be marked as unread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetConversations
|
||||||
|
conversations, err := GetConversations(db, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get conversations: %v", err)
|
||||||
|
}
|
||||||
|
if len(conversations) != 2 {
|
||||||
|
t.Errorf("Expected 2 conversations, got %d", len(conversations))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify conversations are sorted by date (most recent first)
|
||||||
|
// Second message (1289643415) should be first as it's more recent
|
||||||
|
if len(conversations) == 2 {
|
||||||
|
if conversations[0].Address != "+14433221123" {
|
||||||
|
t.Errorf("Expected first conversation to be '+14433221123', got '%s'", conversations[0].Address)
|
||||||
|
}
|
||||||
|
if conversations[1].Address != "332" {
|
||||||
|
t.Errorf("Expected second conversation to be '332', got '%s'", conversations[1].Address)
|
||||||
|
}
|
||||||
|
if conversations[0].MessageCount != 1 {
|
||||||
|
t.Errorf("Expected first conversation to have 1 message, got %d", conversations[0].MessageCount)
|
||||||
|
}
|
||||||
|
if conversations[0].Type != "conversation" {
|
||||||
|
t.Errorf("Expected conversation type to be 'conversation', got '%s'", conversations[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test date range functionality
|
||||||
|
startDate := time.Unix(1289000000, 0) // After first message, before second
|
||||||
|
messages3, err := GetMessages(db, "332", &startDate, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve messages with date filter: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages3) != 0 {
|
||||||
|
t.Errorf("Expected 0 messages after start date, got %d", len(messages3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date range
|
||||||
|
minDate, maxDate, err := GetDateRange(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get date range: %v", err)
|
||||||
|
}
|
||||||
|
expectedMin := time.Unix(1285799668, 0)
|
||||||
|
expectedMax := time.Unix(1289643415, 0)
|
||||||
|
if !minDate.Equal(expectedMin) {
|
||||||
|
t.Errorf("Expected min date %v, got %v", expectedMin, minDate)
|
||||||
|
}
|
||||||
|
if !maxDate.Equal(expectedMax) {
|
||||||
|
t.Errorf("Expected max date %v, got %v", expectedMax, maxDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyXML(t *testing.T) {
|
||||||
|
emptyXML := `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<smses count="0">
|
||||||
|
</smses>`
|
||||||
|
|
||||||
|
reader := strings.NewReader(emptyXML)
|
||||||
|
result, err := ParseSMSBackup(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse empty XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Messages) != 0 {
|
||||||
|
t.Errorf("Expected 0 messages, got %d", len(result.Messages))
|
||||||
|
}
|
||||||
|
if len(result.Calls) != 0 {
|
||||||
|
t.Errorf("Expected 0 calls, got %d", len(result.Calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidXML(t *testing.T) {
|
||||||
|
invalidXML := `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<smses count="1">
|
||||||
|
<sms protocol="invalid" address="123" date="notanumber" type="2" body="Test" />
|
||||||
|
</smses>`
|
||||||
|
|
||||||
|
reader := strings.NewReader(invalidXML)
|
||||||
|
result, err := ParseSMSBackup(reader)
|
||||||
|
|
||||||
|
// Should parse but skip invalid entries or use defaults
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parser should handle invalid data gracefully: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message might be parsed with default values for invalid fields
|
||||||
|
if len(result.Messages) > 0 {
|
||||||
|
msg := result.Messages[0]
|
||||||
|
// Protocol "invalid" should parse as 0
|
||||||
|
if msg.Protocol != 0 {
|
||||||
|
t.Logf("Invalid protocol parsed as: %d", msg.Protocol)
|
||||||
|
}
|
||||||
|
// Date "notanumber" should result in Unix epoch
|
||||||
|
t.Logf("Invalid date parsed as: %v", msg.Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// normalizePhoneNumber removes all non-numeric characters except leading +
|
||||||
|
// and standardizes US phone numbers to include the +1 country code
|
||||||
|
// This prevents duplicate conversations due to different phone number formatting
|
||||||
|
func normalizePhoneNumber(phoneNumber string) string {
|
||||||
|
if phoneNumber == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it starts with +
|
||||||
|
hasPlus := strings.HasPrefix(phoneNumber, "+")
|
||||||
|
|
||||||
|
// Remove all non-numeric characters
|
||||||
|
var result strings.Builder
|
||||||
|
for _, ch := range phoneNumber {
|
||||||
|
if ch >= '0' && ch <= '9' {
|
||||||
|
result.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := result.String()
|
||||||
|
if normalized == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standardize US phone numbers
|
||||||
|
if !hasPlus {
|
||||||
|
// 10 digits without country code - add +1 (US number)
|
||||||
|
if len(normalized) == 10 {
|
||||||
|
return "+1" + normalized
|
||||||
|
}
|
||||||
|
// 11 digits starting with 1 - add + (US number with 1 prefix)
|
||||||
|
if len(normalized) == 11 && normalized[0] == '1' {
|
||||||
|
return "+" + normalized
|
||||||
|
}
|
||||||
|
// Other lengths without + - keep as is (might be partial/invalid)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already has +, keep it
|
||||||
|
return "+" + normalized
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lowcarbdev/sbv/internal"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logger *slog.Logger
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Initialize slog logger
|
||||||
|
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
// Initialize authentication database
|
||||||
|
dbPathPrefix := os.Getenv("DB_PATH_PREFIX")
|
||||||
|
if dbPathPrefix == "" {
|
||||||
|
dbPathPrefix = "."
|
||||||
|
}
|
||||||
|
authDBPath := dbPathPrefix + "/sbv.db"
|
||||||
|
|
||||||
|
err := internal.InitAuthDB(authDBPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to initialize authentication database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("Authentication database initialized", "path", authDBPath)
|
||||||
|
|
||||||
|
// Create Echo instance
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
|
||||||
|
// Use custom CORS middleware that properly handles credentials
|
||||||
|
e.Use(internal.CustomCORSMiddleware())
|
||||||
|
|
||||||
|
// Configure timeouts for large file uploads
|
||||||
|
e.Server.ReadTimeout = 30 * time.Minute
|
||||||
|
e.Server.WriteTimeout = 30 * time.Minute
|
||||||
|
e.Server.ReadHeaderTimeout = 1 * time.Minute
|
||||||
|
e.Server.IdleTimeout = 2 * time.Minute
|
||||||
|
e.Server.MaxHeaderBytes = 1 << 20 // 1 MB max header size
|
||||||
|
|
||||||
|
// Public routes (no authentication required)
|
||||||
|
// Apply NoCacheMiddleware to prevent browser caching of auth responses
|
||||||
|
e.POST("/api/auth/register", internal.HandleRegister, internal.NoCacheMiddleware)
|
||||||
|
e.POST("/api/auth/login", internal.HandleLogin, internal.NoCacheMiddleware)
|
||||||
|
e.POST("/api/auth/logout", internal.HandleLogout, internal.NoCacheMiddleware)
|
||||||
|
|
||||||
|
// Protected routes (authentication required)
|
||||||
|
protected := e.Group("/api")
|
||||||
|
protected.Use(internal.AuthMiddleware)
|
||||||
|
protected.Use(internal.NoCacheMiddleware) // Prevent browser caching of API responses
|
||||||
|
|
||||||
|
protected.GET("/auth/me", internal.HandleMe)
|
||||||
|
protected.POST("/auth/change-password", internal.HandleChangePassword)
|
||||||
|
protected.POST("/upload", internal.HandleUpload)
|
||||||
|
protected.GET("/conversations", internal.HandleConversations)
|
||||||
|
protected.GET("/messages", internal.HandleMessages)
|
||||||
|
protected.GET("/activity", internal.HandleActivity)
|
||||||
|
protected.GET("/daterange", internal.HandleDateRange)
|
||||||
|
protected.GET("/progress", internal.HandleProgress)
|
||||||
|
protected.GET("/media", internal.HandleMedia)
|
||||||
|
protected.GET("/search", internal.HandleSearch)
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
e.GET("/api/health", func(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, "OK")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve static files from frontend/dist if it exists (for production/Docker)
|
||||||
|
if _, err := os.Stat("./frontend/dist"); err == nil {
|
||||||
|
// Serve static assets (JS, CSS, images, etc.)
|
||||||
|
e.Static("/assets", "./frontend/dist/assets")
|
||||||
|
e.File("/favicon.ico", "./frontend/dist/favicon.ico")
|
||||||
|
e.File("/favicon.svg", "./frontend/dist/favicon.svg")
|
||||||
|
e.File("/apple-touch-icon.png", "./frontend/dist/apple-touch-icon.png")
|
||||||
|
e.File("/favicon-96x96.png", "./frontend/dist/favicon-96x96.png")
|
||||||
|
e.File("/web-app-manifest-192x192.png", "./frontend/dist/web-app-manifest-192x192.png")
|
||||||
|
e.File("/web-app-manifest-512x512.png", "./frontend/dist/web-app-manifest-512x512.png")
|
||||||
|
e.File("/site.webmanifest", "./frontend/dist/site.webmanifest")
|
||||||
|
|
||||||
|
// SPA fallback - serve index.html for all non-API routes
|
||||||
|
// This must be last so it doesn't interfere with API routes
|
||||||
|
e.GET("/*", func(c echo.Context) error {
|
||||||
|
return c.File("./frontend/dist/index.html")
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.Info("Serving static files from ./frontend/dist with SPA routing support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start pprof server in a separate goroutine for profiling
|
||||||
|
go func() {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8081"
|
||||||
|
}
|
||||||
|
pprofPort := "6060"
|
||||||
|
logger.Info("Memory profiling available", "url", "http://localhost:"+pprofPort+"/debug/pprof/")
|
||||||
|
if err := http.ListenAndServe(":"+pprofPort, nil); err != nil {
|
||||||
|
logger.Error("pprof server failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8081"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP server with longer timeouts for large file uploads
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
ReadTimeout: 30 * time.Minute, // Allow 30 minutes for reading large uploads
|
||||||
|
WriteTimeout: 30 * time.Minute, // Allow 30 minutes for writing responses
|
||||||
|
ReadHeaderTimeout: 1 * time.Minute, // Header read timeout
|
||||||
|
IdleTimeout: 2 * time.Minute, // Idle connection timeout
|
||||||
|
MaxHeaderBytes: 1 << 20, // 1 MB max header size
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Server starting", "port", port)
|
||||||
|
logger.Info("Upload timeout set to 30 minutes for large backup files")
|
||||||
|
|
||||||
|
e.Server = server
|
||||||
|
// Start server
|
||||||
|
if err := e.Start(":" + port); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Error("Server failed to start", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||