fixed security issue with how images were served

This commit is contained in:
Gamosoft 2025-11-20 14:40:14 +01:00
parent 4ff1f7e8bd
commit 62c93064de
4 changed files with 108 additions and 26 deletions

View File

@ -80,10 +80,6 @@ plugin_manager.run_hook('on_app_startup')
static_path = Path(__file__).parent.parent / "frontend" static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static") app.mount("/static", StaticFiles(directory=static_path), name="static")
# Mount data directory for serving images
data_path = Path(config['storage']['notes_dir'])
app.mount("/data", StaticFiles(directory=data_path), name="data")
# ============================================================================ # ============================================================================
# Custom Exception Handlers # Custom Exception Handlers
@ -438,6 +434,36 @@ async def create_new_folder(data: dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/images/{image_path:path}")
async def get_image(image_path: str):
"""
Serve an image file with authentication protection.
"""
try:
notes_dir = config['storage']['notes_dir']
full_path = Path(notes_dir) / image_path
# Security: Validate path is within notes directory
if not validate_path_security(notes_dir, full_path):
raise HTTPException(status_code=403, detail="Access denied")
# Check file exists and is an image
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Image not found")
# Validate it's an image file
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
if full_path.suffix.lower() not in allowed_extensions:
raise HTTPException(status_code=400, detail="Not an image file")
# Return the file
return FileResponse(full_path)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@api_router.post("/upload-image") @api_router.post("/upload-image")
async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)): async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)):
""" """

View File

@ -81,6 +81,67 @@ Content-Type: application/json
} }
``` ```
## 🖼️ Images
### Get Image
```http
GET /api/images/{image_path}
```
Retrieve an image file with authentication protection.
**Example:**
```bash
curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png
```
**Security Note:** This endpoint requires authentication and validates that:
- The image path is within the notes directory (prevents directory traversal)
- The file exists and is a valid image format
- The requesting user is authenticated (if auth is enabled)
### Upload Image
```http
POST /api/upload-image
Content-Type: multipart/form-data
file: <image file>
note_path: <path of note to attach to>
```
Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts.
**Supported formats:** JPG, JPEG, PNG, GIF, WEBP
**Maximum size:** 10MB
**Response:**
```json
{
"success": true,
"path": "folder/_attachments/image-20240417093343.png",
"filename": "image-20240417093343.png",
"message": "Image uploaded successfully"
}
```
**Example (using curl):**
```bash
curl -X POST http://localhost:8000/api/upload-image \
-F "file=@/path/to/image.png" \
-F "note_path=folder/mynote.md"
```
**Windows PowerShell:**
```powershell
curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md"
```
**Notes:**
- Images are stored in `_attachments` folders relative to the note's location
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`)
- Images appear in the sidebar navigation and can be viewed/deleted directly
- Drag & drop images into the editor automatically uploads and inserts markdown
- All image access requires authentication when security is enabled
## 📁 Folders ## 📁 Folders
### Create Folder ### Create Folder
@ -93,6 +154,17 @@ Content-Type: application/json
} }
``` ```
### Delete Folder
```http
DELETE /api/folders/{folder_path}
```
Deletes a folder and all its contents.
**Example:**
```bash
curl -X DELETE http://localhost:8000/api/folders/Projects/Archive
```
### Move Folder ### Move Folder
```http ```http
POST /api/folders/move POST /api/folders/move

View File

@ -787,7 +787,7 @@ function noteApp() {
const filename = notePath.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension const filename = notePath.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension
// URL-encode the path to handle spaces and special characters // URL-encode the path to handle spaces and special characters
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
link = `![${filename}](/data/${encodedPath})`; link = `![${filename}](/api/images/${encodedPath})`;
} else { } else {
// For notes, insert note link // For notes, insert note link
const noteName = notePath.split('/').pop().replace('.md', ''); const noteName = notePath.split('/').pop().replace('.md', '');
@ -879,7 +879,7 @@ function noteApp() {
const filename = altText.replace(/\.[^/.]+$/, ''); // Remove extension const filename = altText.replace(/\.[^/.]+$/, ''); // Remove extension
// URL-encode the path to handle spaces and special characters // URL-encode the path to handle spaces and special characters
const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const markdown = `![${filename}](/data/${encodedPath})`; const markdown = `![${filename}](/api/images/${encodedPath})`;
const textBefore = this.noteContent.substring(0, cursorPos); const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos); const textAfter = this.noteContent.substring(cursorPos);
@ -1242,7 +1242,7 @@ function noteApp() {
const path = window.location.pathname; const path = window.location.pathname;
// Skip if root path or static assets // Skip if root path or static assets
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/') || path.startsWith('/data/')) { if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
return; return;
} }
@ -1855,24 +1855,8 @@ function noteApp() {
async deleteCurrentNote() { async deleteCurrentNote() {
if (!this.currentNote) return; if (!this.currentNote) return;
if (!confirm(`Delete "${this.currentNoteName}"?`)) return; // Just call deleteNote with current note details
await this.deleteNote(this.currentNote, this.currentNoteName);
try {
const response = await fetch(`/api/notes/${this.currentNote}`, {
method: 'DELETE'
});
if (response.ok) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
await this.loadNotes();
} else {
ErrorHandler.handle('delete note', new Error('Server returned error'));
}
} catch (error) {
ErrorHandler.handle('delete note', error);
}
}, },
// Delete any note from sidebar // Delete any note from sidebar

View File

@ -1197,7 +1197,7 @@
style="background-color: var(--bg-primary); min-height: 0;" style="background-color: var(--bg-primary); min-height: 0;"
> >
<img <img
:src="`/data/${currentImage}`" :src="`/api/images/${currentImage}`"
:alt="currentImage.split('/').pop()" :alt="currentImage.split('/').pop()"
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;" style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
/> />