Initial commit to setup repo via Fish
This commit is contained in:
commit
b88774272d
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "readur"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
bcrypt = "0.15"
|
||||
jsonwebtoken = "9.0"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
futures-util = "0.3"
|
||||
notify = "6.0"
|
||||
mime_guess = "2.0"
|
||||
tesseract = "0.14"
|
||||
pdf-extract = "0.7"
|
||||
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||
dotenvy = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Build stage
|
||||
FROM rust:1.75 as builder
|
||||
|
||||
# Install system dependencies for OCR
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
libtesseract-dev \
|
||||
libleptonica-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /app/target/release/readur /app/readur
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/uploads /app/watch /app/frontend
|
||||
|
||||
# Copy frontend files (will be built separately)
|
||||
COPY frontend/dist /app/frontend
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["./readur"]
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
readur:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://readur:readur_password@postgres:5432/readur
|
||||
- JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
- UPLOAD_PATH=/app/uploads
|
||||
- WATCH_FOLDER=/app/watch
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- watch:/app/watch
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_USER=readur
|
||||
- POSTGRES_PASSWORD=readur_password
|
||||
- POSTGRES_DB=readur
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
uploads:
|
||||
watch:
|
||||
postgres_data:
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!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>Readur - Document Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "readur-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"axios": "^1.3.0",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"@heroicons/react": "^2.0.16",
|
||||
"react-dropzone": "^14.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.0",
|
||||
"vitest": "^0.28.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import Login from './components/Login'
|
||||
import Register from './components/Register'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function App() {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={!user ? <Login /> : <Navigate to="/" />} />
|
||||
<Route path="/register" element={!user ? <Register /> : <Navigate to="/" />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
user ? (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Navigate to="/login" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import FileUpload from './FileUpload'
|
||||
import DocumentList from './DocumentList'
|
||||
import SearchBar from './SearchBar'
|
||||
import { Document, documentService } from '../services/api'
|
||||
|
||||
function Dashboard() {
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchResults, setSearchResults] = useState<Document[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments()
|
||||
}, [])
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
const response = await documentService.list()
|
||||
setDocuments(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (newDocument: Document) => {
|
||||
setDocuments(prev => [newDocument, ...prev])
|
||||
}
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await documentService.search({ query })
|
||||
setSearchResults(response.data.documents)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const displayDocuments = searchResults || documents
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Document Management</h1>
|
||||
<FileUpload onUploadSuccess={handleUploadSuccess} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{searchResults && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setSearchResults(null)}
|
||||
className="text-blue-600 hover:text-blue-500 text-sm"
|
||||
>
|
||||
← Back to all documents
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentList documents={displayDocuments} loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
DocumentIcon,
|
||||
PhotoIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { Document, documentService } from '../services/api'
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Document[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function DocumentList({ documents, loading }: DocumentListProps) {
|
||||
const handleDownload = async (document: Document) => {
|
||||
try {
|
||||
const response = await documentService.download(document.id)
|
||||
const blob = new Blob([response.data])
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = window.document.createElement('a')
|
||||
link.href = url
|
||||
link.download = document.original_filename
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return <PhotoIcon className="h-8 w-8 text-green-500" />
|
||||
}
|
||||
return <DocumentIcon className="h-8 w-8 text-blue-500" />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">Loading documents...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<DocumentIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-gray-600">No documents found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{documents.map((document) => (
|
||||
<li key={document.id}>
|
||||
<div className="px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{getFileIcon(document.mime_type)}
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{document.original_filename}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatFileSize(document.file_size)} • {document.mime_type}
|
||||
{document.has_ocr_text && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
OCR
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{new Date(document.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(document)}
|
||||
className="ml-4 inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentList
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { DocumentArrowUpIcon } from '@heroicons/react/24/outline'
|
||||
import { Document, documentService } from '../services/api'
|
||||
|
||||
interface FileUploadProps {
|
||||
onUploadSuccess: (document: Document) => void
|
||||
}
|
||||
|
||||
function FileUpload({ onUploadSuccess }: FileUploadProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await documentService.upload(file)
|
||||
onUploadSuccess(response.data)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [onUploadSuccess])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'text/plain': ['.txt'],
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.tiff', '.bmp'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${uploading ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<DocumentArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{isDragActive
|
||||
? 'Drop the file here...'
|
||||
: 'Drag & drop a file here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Supported: PDF, TXT, DOC, DOCX, PNG, JPG, JPEG, TIFF, BMP
|
||||
</p>
|
||||
{uploading && (
|
||||
<p className="text-blue-600 mt-2">Uploading and processing...</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUpload
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Layout({ children }: LayoutProps) {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Readur</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-700">Welcome, {user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to Readur
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link to="/register" className="text-blue-600 hover:text-blue-500">
|
||||
Don't have an account? Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function Register() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { register } = useAuth()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await register(username, email, password)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to register')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your Readur account
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link to="/login" className="text-blue-600 hover:text-blue-500">
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useState } from 'react'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (query: string) => void
|
||||
}
|
||||
|
||||
function SearchBar({ onSearch }: SearchBarProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSearch(query)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-blue-600 text-white px-4 py-1 rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBar
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import Dashboard from '../Dashboard'
|
||||
import { documentService } from '../../services/api'
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../../services/api', () => ({
|
||||
documentService: {
|
||||
list: vi.fn(),
|
||||
search: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../FileUpload', () => ({
|
||||
default: ({ onUploadSuccess }: any) => (
|
||||
<div data-testid="file-upload">File Upload Component</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../DocumentList', () => ({
|
||||
default: ({ documents, loading }: any) => (
|
||||
<div data-testid="document-list">
|
||||
{loading ? 'Loading...' : `${documents.length} documents`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../SearchBar', () => ({
|
||||
default: ({ onSearch }: any) => (
|
||||
<input
|
||||
data-testid="search-bar"
|
||||
placeholder="Search"
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockDocuments = [
|
||||
{
|
||||
id: '1',
|
||||
filename: 'test1.pdf',
|
||||
original_filename: 'test1.pdf',
|
||||
file_size: 1024,
|
||||
mime_type: 'application/pdf',
|
||||
tags: [],
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
has_ocr_text: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
filename: 'test2.txt',
|
||||
original_filename: 'test2.txt',
|
||||
file_size: 512,
|
||||
mime_type: 'text/plain',
|
||||
tags: ['important'],
|
||||
created_at: '2023-01-02T00:00:00Z',
|
||||
has_ocr_text: false,
|
||||
},
|
||||
]
|
||||
|
||||
describe('Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders dashboard with file upload and document list', async () => {
|
||||
const mockList = vi.mocked(documentService.list)
|
||||
mockList.mockResolvedValue({ data: mockDocuments })
|
||||
|
||||
render(<Dashboard />)
|
||||
|
||||
expect(screen.getByText('Document Management')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('file-upload')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-bar')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('document-list')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 documents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('handles loading state', () => {
|
||||
const mockList = vi.mocked(documentService.list)
|
||||
mockList.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
render(<Dashboard />)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles search functionality', async () => {
|
||||
const mockList = vi.mocked(documentService.list)
|
||||
const mockSearch = vi.mocked(documentService.search)
|
||||
|
||||
mockList.mockResolvedValue({ data: mockDocuments })
|
||||
mockSearch.mockResolvedValue({
|
||||
data: {
|
||||
documents: [mockDocuments[0]],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Dashboard />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2 documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchBar = screen.getByTestId('search-bar')
|
||||
searchBar.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, email: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
fetchUser()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/me')
|
||||
setUser(response.data)
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { username, password })
|
||||
const { token, user: userData } = response.data
|
||||
|
||||
localStorage.setItem('token', token)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
setUser(userData)
|
||||
}
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
await api.post('/auth/register', { username, email, password })
|
||||
await login(username, password)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
filename: string
|
||||
original_filename: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
tags: string[]
|
||||
created_at: string
|
||||
has_ocr_text: boolean
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
query: string
|
||||
tags?: string[]
|
||||
mime_types?: string[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
documents: Document[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const documentService = {
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/documents', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
list: (limit = 50, offset = 0) => {
|
||||
return api.get<Document[]>('/documents', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
},
|
||||
|
||||
download: (id: string) => {
|
||||
return api.get(`/documents/${id}/download`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
|
||||
search: (searchRequest: SearchRequest) => {
|
||||
return api.get<SearchResponse>('/search', {
|
||||
params: searchRequest,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
use anyhow::Result;
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRequestParts, State},
|
||||
http::{request::Parts, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{models::User, AppState};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: Uuid,
|
||||
pub username: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
pub struct AuthUser {
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let headers = &parts.headers;
|
||||
let token = extract_token_from_headers(headers)
|
||||
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response())?;
|
||||
|
||||
let claims = verify_jwt(&token, &state.config.jwt_secret)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token").into_response())?;
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.get_user_by_id(claims.sub)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response())?
|
||||
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "User not found").into_response())?;
|
||||
|
||||
Ok(AuthUser { user })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_jwt(user: &User, secret: &str) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::hours(24))
|
||||
.expect("valid timestamp")
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
sub: user.id,
|
||||
username: user.username.clone(),
|
||||
exp: expiration as usize,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn verify_jwt(token: &str, secret: &str) -> Result<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
fn extract_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = headers.get("authorization")?;
|
||||
let auth_str = auth_header.to_str().ok()?;
|
||||
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
Some(auth_str.trim_start_matches("Bearer ").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
use anyhow::Result;
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub server_address: String,
|
||||
pub jwt_secret: String,
|
||||
pub upload_path: String,
|
||||
pub watch_folder: String,
|
||||
pub allowed_file_types: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
Ok(Config {
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgresql://readur:readur@localhost/readur".to_string()),
|
||||
server_address: env::var("SERVER_ADDRESS")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8000".to_string()),
|
||||
jwt_secret: env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-secret-key".to_string()),
|
||||
upload_path: env::var("UPLOAD_PATH")
|
||||
.unwrap_or_else(|_| "./uploads".to_string()),
|
||||
watch_folder: env::var("WATCH_FOLDER")
|
||||
.unwrap_or_else(|_| "./watch".to_string()),
|
||||
allowed_file_types: env::var("ALLOWED_FILE_TYPES")
|
||||
.unwrap_or_else(|_| "pdf,txt,doc,docx,png,jpg,jpeg".to_string())
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{CreateUser, Document, SearchRequest, User};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(database_url: &str) -> Result<Self> {
|
||||
let pool = PgPool::connect(database_url).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
content TEXT,
|
||||
ocr_text TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user_id ON documents(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_filename ON documents(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_mime_type ON documents(mime_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_tags ON documents USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_content_search ON documents USING GIN(to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')));
|
||||
"#
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_user(&self, user: CreateUser) -> Result<User> {
|
||||
let password_hash = bcrypt::hash(&user.password, 12)?;
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (username, email, password_hash, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, email, password_hash, created_at, updated_at
|
||||
"#
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
.bind(&password_hash)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(User {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Some(User {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Some(User {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_document(&self, document: Document) -> Result<Document> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO documents (id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, tags, created_at, updated_at, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, tags, created_at, updated_at, user_id
|
||||
"#
|
||||
)
|
||||
.bind(document.id)
|
||||
.bind(&document.filename)
|
||||
.bind(&document.original_filename)
|
||||
.bind(&document.file_path)
|
||||
.bind(document.file_size)
|
||||
.bind(&document.mime_type)
|
||||
.bind(&document.content)
|
||||
.bind(&document.ocr_text)
|
||||
.bind(&document.tags)
|
||||
.bind(document.created_at)
|
||||
.bind(document.updated_at)
|
||||
.bind(document.user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Document {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
original_filename: row.get("original_filename"),
|
||||
file_path: row.get("file_path"),
|
||||
file_size: row.get("file_size"),
|
||||
mime_type: row.get("mime_type"),
|
||||
content: row.get("content"),
|
||||
ocr_text: row.get("ocr_text"),
|
||||
tags: row.get("tags"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
user_id: row.get("user_id"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_documents_by_user(&self, user_id: Uuid, limit: i64, offset: i64) -> Result<Vec<Document>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, tags, created_at, updated_at, user_id
|
||||
FROM documents
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let documents = rows
|
||||
.into_iter()
|
||||
.map(|row| Document {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
original_filename: row.get("original_filename"),
|
||||
file_path: row.get("file_path"),
|
||||
file_size: row.get("file_size"),
|
||||
mime_type: row.get("mime_type"),
|
||||
content: row.get("content"),
|
||||
ocr_text: row.get("ocr_text"),
|
||||
tags: row.get("tags"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
user_id: row.get("user_id"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(documents)
|
||||
}
|
||||
|
||||
pub async fn search_documents(&self, user_id: Uuid, search: SearchRequest) -> Result<(Vec<Document>, i64)> {
|
||||
let mut query_builder = sqlx::QueryBuilder::new(
|
||||
r#"
|
||||
SELECT id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, tags, created_at, updated_at, user_id,
|
||||
ts_rank(to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')), plainto_tsquery('english', "#
|
||||
);
|
||||
|
||||
query_builder.push_bind(&search.query);
|
||||
query_builder.push(")) as rank FROM documents WHERE user_id = ");
|
||||
query_builder.push_bind(user_id);
|
||||
query_builder.push(" AND to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')) @@ plainto_tsquery('english', ");
|
||||
query_builder.push_bind(&search.query);
|
||||
query_builder.push(")");
|
||||
|
||||
if let Some(tags) = &search.tags {
|
||||
if !tags.is_empty() {
|
||||
query_builder.push(" AND tags && ");
|
||||
query_builder.push_bind(tags);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mime_types) = &search.mime_types {
|
||||
if !mime_types.is_empty() {
|
||||
query_builder.push(" AND mime_type = ANY(");
|
||||
query_builder.push_bind(mime_types);
|
||||
query_builder.push(")");
|
||||
}
|
||||
}
|
||||
|
||||
query_builder.push(" ORDER BY rank DESC, created_at DESC");
|
||||
|
||||
if let Some(limit) = search.limit {
|
||||
query_builder.push(" LIMIT ");
|
||||
query_builder.push_bind(limit);
|
||||
}
|
||||
|
||||
if let Some(offset) = search.offset {
|
||||
query_builder.push(" OFFSET ");
|
||||
query_builder.push_bind(offset);
|
||||
}
|
||||
|
||||
let rows = query_builder.build().fetch_all(&self.pool).await?;
|
||||
|
||||
let documents = rows
|
||||
.into_iter()
|
||||
.map(|row| Document {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
original_filename: row.get("original_filename"),
|
||||
file_path: row.get("file_path"),
|
||||
file_size: row.get("file_size"),
|
||||
mime_type: row.get("mime_type"),
|
||||
content: row.get("content"),
|
||||
ocr_text: row.get("ocr_text"),
|
||||
tags: row.get("tags"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
user_id: row.get("user_id"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) as total FROM documents
|
||||
WHERE user_id = $1
|
||||
AND to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')) @@ plainto_tsquery('english', $2)
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&search.query)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let total: i64 = total_row.get("total");
|
||||
|
||||
Ok((documents, total))
|
||||
}
|
||||
|
||||
pub async fn update_document_ocr(&self, id: Uuid, ocr_text: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE documents SET ocr_text = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(ocr_text)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::Document;
|
||||
|
||||
pub struct FileService {
|
||||
upload_path: String,
|
||||
}
|
||||
|
||||
impl FileService {
|
||||
pub fn new(upload_path: String) -> Self {
|
||||
Self { upload_path }
|
||||
}
|
||||
|
||||
pub async fn save_file(&self, filename: &str, data: &[u8]) -> Result<String> {
|
||||
let file_id = Uuid::new_v4();
|
||||
let extension = Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let saved_filename = if extension.is_empty() {
|
||||
file_id.to_string()
|
||||
} else {
|
||||
format!("{}.{}", file_id, extension)
|
||||
};
|
||||
|
||||
let file_path = Path::new(&self.upload_path).join(&saved_filename);
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
fs::write(&file_path, data).await?;
|
||||
|
||||
Ok(file_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub async fn create_document(
|
||||
&self,
|
||||
filename: &str,
|
||||
original_filename: &str,
|
||||
file_path: &str,
|
||||
file_size: i64,
|
||||
mime_type: &str,
|
||||
user_id: Uuid,
|
||||
) -> Document {
|
||||
Document {
|
||||
id: Uuid::new_v4(),
|
||||
filename: filename.to_string(),
|
||||
original_filename: original_filename.to_string(),
|
||||
file_path: file_path.to_string(),
|
||||
file_size,
|
||||
mime_type: mime_type.to_string(),
|
||||
content: None,
|
||||
ocr_text: None,
|
||||
tags: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed_file_type(&self, filename: &str, allowed_types: &[String]) -> bool {
|
||||
if let Some(extension) = Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
{
|
||||
let ext_lower = extension.to_lowercase();
|
||||
allowed_types.contains(&ext_lower)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_file(&self, file_path: &str) -> Result<Vec<u8>> {
|
||||
let data = fs::read(file_path).await?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing::{info, error};
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod file_service;
|
||||
mod models;
|
||||
mod ocr;
|
||||
mod routes;
|
||||
mod watcher;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use config::Config;
|
||||
use db::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
|
||||
db.migrate().await?;
|
||||
|
||||
let state = AppState { db, config: config.clone() };
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.nest("/api/auth", routes::auth::router())
|
||||
.nest("/api/documents", routes::documents::router())
|
||||
.nest("/api/search", routes::search::router())
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(Arc::new(state));
|
||||
|
||||
let watcher_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = watcher::start_folder_watcher(watcher_config).await {
|
||||
error!("Folder watcher error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.server_address).await?;
|
||||
info!("Server starting on {}", config.server_address);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check() -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateUser {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Document {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_filename: String,
|
||||
pub file_path: String,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
pub content: Option<String>,
|
||||
pub ocr_text: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_filename: String,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
pub tags: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub has_ocr_text: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub query: String,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub mime_types: Option<Vec<String>>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResponse {
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
impl From<Document> for DocumentResponse {
|
||||
fn from(doc: Document) -> Self {
|
||||
Self {
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
original_filename: doc.original_filename,
|
||||
file_size: doc.file_size,
|
||||
mime_type: doc.mime_type,
|
||||
tags: doc.tags,
|
||||
created_at: doc.created_at,
|
||||
has_ocr_text: doc.ocr_text.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(user: User) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use std::path::Path;
|
||||
use tesseract::Tesseract;
|
||||
|
||||
pub struct OcrService;
|
||||
|
||||
impl OcrService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub async fn extract_text_from_image(&self, file_path: &str) -> Result<String> {
|
||||
let mut tesseract = Tesseract::new(None, Some("eng"))?;
|
||||
|
||||
tesseract.set_image(file_path)?;
|
||||
|
||||
let text = tesseract.get_text()?;
|
||||
|
||||
Ok(text.trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn extract_text_from_pdf(&self, file_path: &str) -> Result<String> {
|
||||
let bytes = std::fs::read(file_path)?;
|
||||
let text = pdf_extract::extract_text_from_mem(&bytes)
|
||||
.map_err(|e| anyhow!("Failed to extract text from PDF: {}", e))?;
|
||||
|
||||
Ok(text.trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn extract_text(&self, file_path: &str, mime_type: &str) -> Result<String> {
|
||||
match mime_type {
|
||||
"application/pdf" => self.extract_text_from_pdf(file_path).await,
|
||||
"image/png" | "image/jpeg" | "image/jpg" | "image/tiff" | "image/bmp" => {
|
||||
self.extract_text_from_image(file_path).await
|
||||
}
|
||||
"text/plain" => {
|
||||
let text = tokio::fs::read_to_string(file_path).await?;
|
||||
Ok(text)
|
||||
}
|
||||
_ => {
|
||||
if self.is_image_file(file_path) {
|
||||
self.extract_text_from_image(file_path).await
|
||||
} else {
|
||||
Err(anyhow!("Unsupported file type for OCR: {}", mime_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_image_file(&self, file_path: &str) -> bool {
|
||||
if let Some(extension) = Path::new(file_path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
{
|
||||
let ext_lower = extension.to_lowercase();
|
||||
matches!(ext_lower.as_str(), "png" | "jpg" | "jpeg" | "tiff" | "bmp" | "gif")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
auth::{create_jwt, AuthUser},
|
||||
models::{CreateUser, LoginRequest, LoginResponse, UserResponse},
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/me", get(me))
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(user_data): Json<CreateUser>,
|
||||
) -> Result<Json<UserResponse>, StatusCode> {
|
||||
let user = state
|
||||
.db
|
||||
.create_user(user_data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
Ok(Json(user.into()))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(login_data): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, StatusCode> {
|
||||
let user = state
|
||||
.db
|
||||
.get_user_by_username(&login_data.username)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let is_valid = bcrypt::verify(&login_data.password, &user.password_hash)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !is_valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = create_jwt(&user, &state.config.jwt_secret)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn me(auth_user: AuthUser) -> Json<UserResponse> {
|
||||
Json(auth_user.user.into())
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
use axum::{
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::spawn;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
file_service::FileService,
|
||||
models::{DocumentResponse, SearchRequest, SearchResponse},
|
||||
ocr::OcrService,
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PaginationQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/", post(upload_document))
|
||||
.route("/", get(list_documents))
|
||||
.route("/:id/download", get(download_document))
|
||||
}
|
||||
|
||||
async fn upload_document(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<DocumentResponse>, StatusCode> {
|
||||
let file_service = FileService::new(state.config.upload_path.clone());
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if name == "file" {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or(StatusCode::BAD_REQUEST)?
|
||||
.to_string();
|
||||
|
||||
if !file_service.is_allowed_file_type(&filename, &state.config.allowed_file_types) {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let file_size = data.len() as i64;
|
||||
|
||||
let mime_type = mime_guess::from_path(&filename)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
let file_path = file_service
|
||||
.save_file(&filename, &data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let document = file_service.create_document(
|
||||
&filename,
|
||||
&filename,
|
||||
&file_path,
|
||||
file_size,
|
||||
&mime_type,
|
||||
auth_user.user.id,
|
||||
);
|
||||
|
||||
let saved_document = state
|
||||
.db
|
||||
.create_document(document)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let document_id = saved_document.id;
|
||||
let db_clone = state.db.clone();
|
||||
let file_path_clone = file_path.clone();
|
||||
let mime_type_clone = mime_type.clone();
|
||||
|
||||
spawn(async move {
|
||||
let ocr_service = OcrService::new();
|
||||
if let Ok(text) = ocr_service.extract_text(&file_path_clone, &mime_type_clone).await {
|
||||
if !text.is_empty() {
|
||||
let _ = db_clone.update_document_ocr(document_id, &text).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(Json(saved_document.into()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
async fn list_documents(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(pagination): Query<PaginationQuery>,
|
||||
) -> Result<Json<Vec<DocumentResponse>>, StatusCode> {
|
||||
let limit = pagination.limit.unwrap_or(50);
|
||||
let offset = pagination.offset.unwrap_or(0);
|
||||
|
||||
let documents = state
|
||||
.db
|
||||
.get_documents_by_user(auth_user.user.id, limit, offset)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let response: Vec<DocumentResponse> = documents.into_iter().map(|doc| doc.into()).collect();
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn download_document(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(document_id): Path<uuid::Uuid>,
|
||||
) -> Result<Vec<u8>, StatusCode> {
|
||||
let documents = state
|
||||
.db
|
||||
.get_documents_by_user(auth_user.user.id, 1000, 0)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let document = documents
|
||||
.into_iter()
|
||||
.find(|doc| doc.id == document_id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let file_service = FileService::new(state.config.upload_path.clone());
|
||||
let file_data = file_service
|
||||
.read_file(&document.file_path)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(file_data)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod auth;
|
||||
pub mod documents;
|
||||
pub mod search;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
models::{SearchRequest, SearchResponse},
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/", get(search_documents))
|
||||
}
|
||||
|
||||
async fn search_documents(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(search_request): Query<SearchRequest>,
|
||||
) -> Result<Json<SearchResponse>, StatusCode> {
|
||||
let (documents, total) = state
|
||||
.db
|
||||
.search_documents(auth_user.user.id, search_request)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let response = SearchResponse {
|
||||
documents: documents.into_iter().map(|doc| doc.into()).collect(),
|
||||
total,
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::auth::{create_jwt, verify_jwt};
|
||||
use super::super::models::User;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn create_test_user() -> User {
|
||||
User {
|
||||
id: Uuid::new_v4(),
|
||||
username: "testuser".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_jwt() {
|
||||
let user = create_test_user();
|
||||
let secret = "test_secret";
|
||||
|
||||
let result = create_jwt(&user, secret);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let token = result.unwrap();
|
||||
assert!(!token.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_jwt_valid() {
|
||||
let user = create_test_user();
|
||||
let secret = "test_secret";
|
||||
|
||||
let token = create_jwt(&user, secret).unwrap();
|
||||
let result = verify_jwt(&token, secret);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let claims = result.unwrap();
|
||||
assert_eq!(claims.sub, user.id);
|
||||
assert_eq!(claims.username, user.username);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_jwt_invalid_secret() {
|
||||
let user = create_test_user();
|
||||
let secret = "test_secret";
|
||||
let wrong_secret = "wrong_secret";
|
||||
|
||||
let token = create_jwt(&user, secret).unwrap();
|
||||
let result = verify_jwt(&token, wrong_secret);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_jwt_malformed_token() {
|
||||
let secret = "test_secret";
|
||||
let malformed_token = "invalid.token.here";
|
||||
|
||||
let result = verify_jwt(malformed_token, secret);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_jwt_empty_token() {
|
||||
let secret = "test_secret";
|
||||
let empty_token = "";
|
||||
|
||||
let result = verify_jwt(empty_token, secret);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::db::Database;
|
||||
use super::super::models::{CreateUser, Document, SearchRequest};
|
||||
use chrono::Utc;
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn create_test_db() -> Database {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let db_url = format!("sqlite://{}", temp_file.path().display());
|
||||
|
||||
let db = Database::new(&db_url).await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn create_test_user_data() -> CreateUser {
|
||||
CreateUser {
|
||||
username: "testuser".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
password: "password123".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_document(user_id: Uuid) -> Document {
|
||||
Document {
|
||||
id: Uuid::new_v4(),
|
||||
filename: "test.pdf".to_string(),
|
||||
original_filename: "test.pdf".to_string(),
|
||||
file_path: "/path/to/test.pdf".to_string(),
|
||||
file_size: 1024,
|
||||
mime_type: "application/pdf".to_string(),
|
||||
content: Some("Test content".to_string()),
|
||||
ocr_text: Some("OCR extracted text".to_string()),
|
||||
tags: vec!["test".to_string(), "document".to_string()],
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_user() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
|
||||
let result = db.create_user(user_data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let user = result.unwrap();
|
||||
assert_eq!(user.username, "testuser");
|
||||
assert_eq!(user.email, "test@example.com");
|
||||
assert!(!user.password_hash.is_empty());
|
||||
assert_ne!(user.password_hash, "password123"); // Should be hashed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_by_username() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
|
||||
let created_user = db.create_user(user_data).await.unwrap();
|
||||
|
||||
let result = db.get_user_by_username("testuser").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let found_user = result.unwrap();
|
||||
assert!(found_user.is_some());
|
||||
|
||||
let user = found_user.unwrap();
|
||||
assert_eq!(user.id, created_user.id);
|
||||
assert_eq!(user.username, "testuser");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_by_username_not_found() {
|
||||
let db = create_test_db().await;
|
||||
|
||||
let result = db.get_user_by_username("nonexistent").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let found_user = result.unwrap();
|
||||
assert!(found_user.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_document() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
let user = db.create_user(user_data).await.unwrap();
|
||||
|
||||
let document = create_test_document(user.id);
|
||||
|
||||
let result = db.create_document(document.clone()).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let created_doc = result.unwrap();
|
||||
assert_eq!(created_doc.filename, document.filename);
|
||||
assert_eq!(created_doc.user_id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_documents_by_user() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
let user = db.create_user(user_data).await.unwrap();
|
||||
|
||||
let document1 = create_test_document(user.id);
|
||||
let document2 = create_test_document(user.id);
|
||||
|
||||
db.create_document(document1).await.unwrap();
|
||||
db.create_document(document2).await.unwrap();
|
||||
|
||||
let result = db.get_documents_by_user(user.id, 10, 0).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let documents = result.unwrap();
|
||||
assert_eq!(documents.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_documents() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
let user = db.create_user(user_data).await.unwrap();
|
||||
|
||||
let mut document = create_test_document(user.id);
|
||||
document.content = Some("This is a searchable document".to_string());
|
||||
document.ocr_text = Some("OCR searchable text".to_string());
|
||||
|
||||
db.create_document(document).await.unwrap();
|
||||
|
||||
let search_request = SearchRequest {
|
||||
query: "searchable".to_string(),
|
||||
tags: None,
|
||||
mime_types: None,
|
||||
limit: Some(10),
|
||||
offset: Some(0),
|
||||
};
|
||||
|
||||
let result = db.search_documents(user.id, search_request).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (documents, total) = result.unwrap();
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(total, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_document_ocr() {
|
||||
let db = create_test_db().await;
|
||||
let user_data = create_test_user_data();
|
||||
let user = db.create_user(user_data).await.unwrap();
|
||||
|
||||
let document = create_test_document(user.id);
|
||||
let created_doc = db.create_document(document).await.unwrap();
|
||||
|
||||
let new_ocr_text = "Updated OCR text";
|
||||
let result = db.update_document_ocr(created_doc.id, new_ocr_text).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the update by searching
|
||||
let documents = db.get_documents_by_user(user.id, 10, 0).await.unwrap();
|
||||
let updated_doc = documents.iter().find(|d| d.id == created_doc.id).unwrap();
|
||||
assert_eq!(updated_doc.ocr_text.as_ref().unwrap(), new_ocr_text);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::file_service::FileService;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn create_test_file_service() -> (FileService, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let upload_path = temp_dir.path().to_string_lossy().to_string();
|
||||
let service = FileService::new(upload_path);
|
||||
(service, temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_file() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let filename = "test.txt";
|
||||
let data = b"Hello, World!";
|
||||
|
||||
let result = service.save_file(filename, data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let file_path = result.unwrap();
|
||||
assert!(fs::metadata(&file_path).is_ok());
|
||||
|
||||
let saved_content = fs::read(&file_path).unwrap();
|
||||
assert_eq!(saved_content, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_file_with_extension() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let filename = "document.pdf";
|
||||
let data = b"PDF content";
|
||||
|
||||
let result = service.save_file(filename, data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let file_path = result.unwrap();
|
||||
assert!(file_path.ends_with(".pdf"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_file_without_extension() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let filename = "document";
|
||||
let data = b"Some content";
|
||||
|
||||
let result = service.save_file(filename, data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let file_path = result.unwrap();
|
||||
// Should not have an extension
|
||||
assert!(!file_path.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_document() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let document = service.create_document(
|
||||
"saved_file.pdf",
|
||||
"original_file.pdf",
|
||||
"/path/to/saved_file.pdf",
|
||||
1024,
|
||||
"application/pdf",
|
||||
user_id,
|
||||
);
|
||||
|
||||
assert_eq!(document.filename, "saved_file.pdf");
|
||||
assert_eq!(document.original_filename, "original_file.pdf");
|
||||
assert_eq!(document.file_path, "/path/to/saved_file.pdf");
|
||||
assert_eq!(document.file_size, 1024);
|
||||
assert_eq!(document.mime_type, "application/pdf");
|
||||
assert_eq!(document.user_id, user_id);
|
||||
assert!(document.content.is_none());
|
||||
assert!(document.ocr_text.is_none());
|
||||
assert!(document.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_allowed_file_type() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let allowed_types = vec![
|
||||
"pdf".to_string(),
|
||||
"txt".to_string(),
|
||||
"png".to_string(),
|
||||
"jpg".to_string(),
|
||||
];
|
||||
|
||||
assert!(service.is_allowed_file_type("document.pdf", &allowed_types));
|
||||
assert!(service.is_allowed_file_type("text.txt", &allowed_types));
|
||||
assert!(service.is_allowed_file_type("image.PNG", &allowed_types)); // Case insensitive
|
||||
assert!(service.is_allowed_file_type("photo.JPG", &allowed_types)); // Case insensitive
|
||||
|
||||
assert!(!service.is_allowed_file_type("document.doc", &allowed_types));
|
||||
assert!(!service.is_allowed_file_type("archive.zip", &allowed_types));
|
||||
assert!(!service.is_allowed_file_type("noextension", &allowed_types));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let filename = "test.txt";
|
||||
let original_data = b"Hello, World!";
|
||||
|
||||
let file_path = service.save_file(filename, original_data).await.unwrap();
|
||||
|
||||
let result = service.read_file(&file_path).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let read_data = result.unwrap();
|
||||
assert_eq!(read_data, original_data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_nonexistent_file() {
|
||||
let (service, _temp_dir) = create_test_file_service();
|
||||
let nonexistent_path = "/path/to/nonexistent/file.txt";
|
||||
|
||||
let result = service.read_file(nonexistent_path).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
mod auth_tests;
|
||||
mod db_tests;
|
||||
mod file_service_tests;
|
||||
mod ocr_tests;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ocr::OcrService;
|
||||
use std::fs;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_is_image_file() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
assert!(ocr_service.is_image_file("image.png"));
|
||||
assert!(ocr_service.is_image_file("photo.jpg"));
|
||||
assert!(ocr_service.is_image_file("picture.JPEG"));
|
||||
assert!(ocr_service.is_image_file("scan.tiff"));
|
||||
assert!(ocr_service.is_image_file("bitmap.bmp"));
|
||||
assert!(ocr_service.is_image_file("animation.gif"));
|
||||
|
||||
assert!(!ocr_service.is_image_file("document.pdf"));
|
||||
assert!(!ocr_service.is_image_file("text.txt"));
|
||||
assert!(!ocr_service.is_image_file("archive.zip"));
|
||||
assert!(!ocr_service.is_image_file("noextension"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_text_from_plain_text() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
let test_content = "This is a test text file.\nWith multiple lines.";
|
||||
fs::write(temp_file.path(), test_content).unwrap();
|
||||
|
||||
let result = ocr_service
|
||||
.extract_text(temp_file.path().to_str().unwrap(), "text/plain")
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let extracted_text = result.unwrap();
|
||||
assert_eq!(extracted_text, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_text_unsupported_type() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
fs::write(temp_file.path(), "some content").unwrap();
|
||||
|
||||
let result = ocr_service
|
||||
.extract_text(temp_file.path().to_str().unwrap(), "application/zip")
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unsupported file type"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_text_from_nonexistent_file() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
let result = ocr_service
|
||||
.extract_text("/path/to/nonexistent/file.txt", "text/plain")
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Note: These tests would require actual PDF and image files to test fully
|
||||
// For now, we're testing the error handling and basic functionality
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_text_from_pdf_empty_file() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
fs::write(temp_file.path(), "").unwrap(); // Empty file, not a valid PDF
|
||||
|
||||
let result = ocr_service
|
||||
.extract_text_from_pdf(temp_file.path().to_str().unwrap())
|
||||
.await;
|
||||
|
||||
// Should fail because it's not a valid PDF
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_text_with_image_extension_fallback() {
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
let mut temp_file = NamedTempFile::with_suffix(".png").unwrap();
|
||||
fs::write(temp_file.path(), "fake image data").unwrap();
|
||||
|
||||
let result = ocr_service
|
||||
.extract_text(temp_file.path().to_str().unwrap(), "unknown/type")
|
||||
.await;
|
||||
|
||||
// This should try to process as image due to extension, but fail due to invalid data
|
||||
// The important thing is that it attempts image processing
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
use anyhow::Result;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::Config, db::Database, file_service::FileService, ocr::OcrService};
|
||||
|
||||
pub async fn start_folder_watcher(config: Config) -> Result<()> {
|
||||
let (tx, mut rx) = mpsc::channel(100);
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res| {
|
||||
if let Err(e) = tx.blocking_send(res) {
|
||||
error!("Failed to send file event: {}", e);
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
)?;
|
||||
|
||||
watcher.watch(Path::new(&config.watch_folder), RecursiveMode::Recursive)?;
|
||||
|
||||
info!("Starting folder watcher on: {}", config.watch_folder);
|
||||
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
let file_service = FileService::new(config.upload_path.clone());
|
||||
let ocr_service = OcrService::new();
|
||||
|
||||
while let Some(res) = rx.recv().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
for path in event.paths {
|
||||
if let Err(e) = process_file(&path, &db, &file_service, &ocr_service, &config).await {
|
||||
error!("Failed to process file {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Watch error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_file(
|
||||
path: &std::path::Path,
|
||||
db: &Database,
|
||||
file_service: &FileService,
|
||||
ocr_service: &OcrService,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if !file_service.is_allowed_file_type(&filename, &config.allowed_file_types) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Processing new file: {:?}", path);
|
||||
|
||||
let file_data = tokio::fs::read(path).await?;
|
||||
let file_size = file_data.len() as i64;
|
||||
|
||||
let mime_type = mime_guess::from_path(&filename)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
let file_path = file_service.save_file(&filename, &file_data).await?;
|
||||
|
||||
let system_user_id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000")?;
|
||||
|
||||
let mut document = file_service.create_document(
|
||||
&filename,
|
||||
&filename,
|
||||
&file_path,
|
||||
file_size,
|
||||
&mime_type,
|
||||
system_user_id,
|
||||
);
|
||||
|
||||
if let Ok(text) = ocr_service.extract_text(&file_path, &mime_type).await {
|
||||
if !text.is_empty() {
|
||||
document.ocr_text = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
db.create_document(document).await?;
|
||||
|
||||
info!("Successfully processed file: {}", filename);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in New Issue