11 KiB
Attachment API Documentation
Overview
The Masina-Dock application supports file attachments for fuel records, service records, and recurring expenses (taxes). This document describes the API endpoints, data models, and usage patterns for working with attachments.
Security
- All attachment operations require authentication via
@login_requireddecorator - Users can only access their own attachments through vehicle ownership validation
- File paths are validated to prevent directory traversal attacks
- Uploaded files are stored in
/app/uploads/attachments/with secure filenames
API Endpoints
Upload Attachment
Upload a file attachment.
Endpoint: POST /api/upload/attachment
Authentication: Required
Request:
- Content-Type:
multipart/form-data - Body parameter:
attachment(file)
Allowed File Types:
- PDF:
.pdf - Images:
.png,.jpg,.jpeg - Documents:
.txt,.doc,.docx - Spreadsheets:
.xls,.xlsx,.csv - OpenDocument:
.odt,.ods
Response:
{
"file_path": "attachments/20231112153045_a1b2c3d4_receipt.pdf"
}
Error Responses:
- 400: No attachment provided / No file selected / Invalid file type
- 500: Server error during upload
Example:
const formData = new FormData();
formData.append('attachment', fileInput.files[0]);
const response = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
const data = await response.json();
const filePath = data.file_path;
Download Attachment
Download or view an attachment.
Endpoint: GET /api/attachments/download
Authentication: Required
Query Parameters:
path(required): Relative path to the attachment (e.g.,attachments/filename.pdf)
Response:
- File content with appropriate Content-Type header
- Downloads as attachment
Error Responses:
- 400: No file path provided
- 403: Invalid file path (security violation)
- 404: File not found
Example:
<a href="/api/attachments/download?path=attachments/20231112153045_a1b2c3d4_receipt.pdf">
Download Receipt
</a>
Delete Attachment
Delete an attachment file.
Endpoint: DELETE /api/attachments/delete
Authentication: Required
Query Parameters:
path(required): Relative path to the attachment (e.g.,attachments/filename.pdf)
Response:
{
"message": "Attachment deleted successfully"
}
Error Responses:
- 400: No file path provided
- 403: Invalid file path (security violation)
- 404: File not found
- 500: Server error during deletion
Example:
await fetch(`/api/attachments/delete?path=${encodeURIComponent(filePath)}`, {
method: 'DELETE',
credentials: 'include'
});
Note: When deleting a fuel record, service record, or recurring expense, the associated attachment is automatically deleted.
Data Models
FuelRecord
class FuelRecord(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
date = db.Column(db.Date, nullable=False)
odometer = db.Column(db.Integer, nullable=False)
fuel_amount = db.Column(db.Float, nullable=False)
cost = db.Column(db.Float, nullable=False)
unit_cost = db.Column(db.Float)
distance = db.Column(db.Integer)
fuel_economy = db.Column(db.Float)
unit = db.Column(db.String(20), default='MPG')
notes = db.Column(db.Text)
document_path = db.Column(db.String(255)) # NEW: Stores attachment path
created_at = db.Column(db.DateTime, default=datetime.utcnow)
RecurringExpense
class RecurringExpense(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
expense_type = db.Column(db.String(50), nullable=False)
description = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Float, nullable=False)
frequency = db.Column(db.String(20), nullable=False)
start_date = db.Column(db.Date, nullable=False)
next_due_date = db.Column(db.Date, nullable=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
notes = db.Column(db.Text)
document_path = db.Column(db.String(255)) # NEW: Stores attachment path
ServiceRecord
class ServiceRecord(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
date = db.Column(db.Date, nullable=False)
odometer = db.Column(db.Integer, nullable=False)
description = db.Column(db.String(200), nullable=False)
cost = db.Column(db.Float, default=0.0)
notes = db.Column(db.Text)
category = db.Column(db.String(50))
document_path = db.Column(db.String(255)) # EXISTING: Already supported
created_at = db.Column(db.DateTime, default=datetime.utcnow)
Using Attachments with Records
Fuel Records
Create with Attachment:
// 1. Upload attachment first
const formData = new FormData();
formData.append('attachment', fileInput.files[0]);
const uploadResponse = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
const { file_path } = await uploadResponse.json();
// 2. Create fuel record with attachment path
await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
date: '2023-11-12',
odometer: 45000,
fuel_amount: 12.5,
cost: 45.00,
unit: 'MPG',
notes: 'Regular fill-up',
document_path: file_path // Attach the uploaded file
})
});
Retrieve with Attachment:
const records = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
credentials: 'include'
}).then(r => r.json());
// Each record includes document_path
records.forEach(record => {
if (record.document_path) {
console.log(`Attachment: ${record.document_path}`);
}
});
Recurring Expenses (Taxes)
Create with Attachment:
// 1. Upload attachment
const formData = new FormData();
formData.append('attachment', fileInput.files[0]);
const uploadResponse = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
const { file_path } = await uploadResponse.json();
// 2. Create recurring expense with attachment
await fetch(`/api/vehicles/${vehicleId}/recurring-expenses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
expense_type: 'Insurance',
description: 'Car Insurance',
amount: 1200.00,
frequency: 'yearly',
start_date: '2023-11-12',
notes: 'Annual premium',
document_path: file_path // Attach the policy document
})
});
Service Records
Create with Attachment:
// Same pattern as fuel records
const formData = new FormData();
formData.append('attachment', fileInput.files[0]);
const uploadResponse = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
const { file_path } = await uploadResponse.json();
await fetch(`/api/vehicles/${vehicleId}/service-records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
date: '2023-11-12',
odometer: 45000,
description: 'Oil Change',
category: 'Maintenance',
cost: 45.00,
notes: 'Synthetic oil',
document_path: file_path // Attach the invoice
})
});
Mobile Camera Support
All attachment input fields support camera capture on mobile devices via the capture="environment" attribute:
<input type="file"
id="fuel-attachment"
name="attachment"
accept="image/*,.pdf,.txt"
capture="environment">
On mobile devices, this will prompt the user to:
- Take a photo with the camera, OR
- Select a file from the device
The accept="image/*" attribute allows any image format, while specific formats like .pdf and .txt are also accepted.
Migration
To add attachment support to an existing database, run the migration script:
# Inside Docker container
python /app/backend/migrate_attachments.py
# Or locally
cd backend
python migrate_attachments.py
This will add the document_path column to:
fuel_recordtablerecurring_expensetable
File Storage
Location: /app/uploads/attachments/
Filename Format: {timestamp}_{random}_{original_filename}
- Example:
20231112153045_a1b2c3d4_receipt.pdf
Permissions: Files are saved with 0o644 permissions (read for all, write for owner)
Directory Creation: The attachments directory is created automatically if it doesn't exist
Best Practices
- Always upload the file first, then reference it in the record
- Handle upload failures gracefully - allow users to save records without attachments
- Validate file types on both client and server side
- Use relative paths - store only
attachments/filename.ext, not absolute paths - Check file existence before displaying download links
- Implement cleanup - consider removing orphaned attachments when records are deleted
Example: Complete Flow
async function addFuelRecordWithAttachment(vehicleId, recordData, file) {
let documentPath = null;
// Step 1: Upload attachment if present
if (file) {
try {
const formData = new FormData();
formData.append('attachment', file);
const uploadResponse = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
if (!uploadResponse.ok) {
console.error('Attachment upload failed');
// Continue without attachment
} else {
const { file_path } = await uploadResponse.json();
documentPath = file_path;
}
} catch (error) {
console.error('Attachment upload error:', error);
// Continue without attachment
}
}
// Step 2: Create fuel record
const response = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
...recordData,
document_path: documentPath
})
});
if (!response.ok) {
throw new Error('Failed to create fuel record');
}
return await response.json();
}
Error Handling
try {
const formData = new FormData();
formData.append('attachment', file);
const response = await fetch('/api/upload/attachment', {
method: 'POST',
credentials: 'include',
body: formData
});
if (!response.ok) {
const error = await response.json();
console.error('Upload failed:', error.error);
// Handle specific error cases
if (response.status === 400) {
alert('Invalid file type or no file selected');
} else if (response.status === 500) {
alert('Server error during upload');
}
}
} catch (error) {
console.error('Network error:', error);
alert('Failed to connect to server');
}