Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
move_folder,
rename_folder,
delete_folder,
save_uploaded_image,
validate_path_security,
)
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
Expand Down Expand Up @@ -433,6 +435,91 @@ async def create_new_folder(data: dict):
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")
async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)):
"""
Upload an image file and save it to the attachments directory.
Returns the relative path to the image for markdown linking.
"""
try:
# Validate file type
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}

# Get file extension
file_ext = Path(file.filename).suffix.lower() if file.filename else ''

if file.content_type not in allowed_types and file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}"
)

# Read file data
file_data = await file.read()

# Validate file size (10MB max)
max_size = 10 * 1024 * 1024 # 10MB in bytes
if len(file_data) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
)

# Save the image
image_path = save_uploaded_image(
config['storage']['notes_dir'],
note_path,
file.filename,
file_data
)

if not image_path:
raise HTTPException(status_code=500, detail="Failed to save image")

return {
"success": True,
"path": image_path,
"filename": Path(image_path).name,
"message": "Image uploaded successfully"
}

except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@api_router.post("/notes/move")
async def move_note_endpoint(data: dict):
"""Move a note to a different folder"""
Expand Down
140 changes: 135 additions & 5 deletions backend/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:


def get_all_notes(notes_dir: str) -> List[Dict]:
"""Recursively get all markdown notes"""
notes = []
"""Recursively get all markdown notes and images"""
items = []
notes_path = Path(notes_dir)

# Get all markdown notes
for md_file in notes_path.rglob("*.md"):
relative_path = md_file.relative_to(notes_path)
stat = md_file.stat()

notes.append({
items.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"size": stat.st_size
"size": stat.st_size,
"type": "note"
})

return sorted(notes, key=lambda x: x['modified'], reverse=True)
# Get all images
images = get_all_images(notes_dir)
items.extend(images)

return sorted(items, key=lambda x: x['modified'], reverse=True)


def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
Expand Down Expand Up @@ -296,3 +302,127 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
"lines": line_count
}


def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename by removing/replacing invalid characters.
Keeps only alphanumeric chars, dots, dashes, and underscores.
"""
# Get the extension first
parts = filename.rsplit('.', 1)
name = parts[0]
ext = parts[1] if len(parts) > 1 else ''

# Remove/replace invalid characters
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)

# Rejoin with extension
return f"{name}.{ext}" if ext else name


def get_attachment_dir(notes_dir: str, note_path: str) -> Path:
"""
Get the attachments directory for a given note.
If note is in root, returns /data/_attachments/
If note is in folder, returns /data/folder/_attachments/
"""
if not note_path:
# Root level
return Path(notes_dir) / "_attachments"

note_path_obj = Path(note_path)
folder = note_path_obj.parent

if str(folder) == '.':
# Note is in root
return Path(notes_dir) / "_attachments"
else:
# Note is in a folder
return Path(notes_dir) / folder / "_attachments"


def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]:
"""
Save an uploaded image to the appropriate attachments directory.
Returns the relative path to the image if successful, None otherwise.

Args:
notes_dir: Base notes directory
note_path: Path of the note the image is being uploaded to
filename: Original filename
file_data: Binary file data

Returns:
Relative path to the saved image, or None if failed
"""
# Sanitize filename
sanitized_name = sanitize_filename(filename)

# Get extension
ext = Path(sanitized_name).suffix
name_without_ext = Path(sanitized_name).stem

# Add timestamp to prevent collisions
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
final_filename = f"{name_without_ext}-{timestamp}{ext}"

# Get attachments directory
attachments_dir = get_attachment_dir(notes_dir, note_path)

# Create directory if it doesn't exist
attachments_dir.mkdir(parents=True, exist_ok=True)

# Full path to save the image
full_path = attachments_dir / final_filename

# Security check
if not validate_path_security(notes_dir, full_path):
print(f"Security: Attempted to save image outside notes directory: {full_path}")
return None

try:
# Write the file
with open(full_path, 'wb') as f:
f.write(file_data)

# Return relative path from notes_dir
relative_path = full_path.relative_to(Path(notes_dir))
return str(relative_path.as_posix())
except Exception as e:
print(f"Error saving image: {e}")
return None


def get_all_images(notes_dir: str) -> List[Dict]:
"""
Get all images from attachments directories.
Returns list of image dictionaries with metadata.
"""
images = []
notes_path = Path(notes_dir)

# Common image extensions
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}

# Find all attachments directories
for attachments_dir in notes_path.rglob("_attachments"):
if not attachments_dir.is_dir():
continue

# Find all images in this attachments directory
for image_file in attachments_dir.iterdir():
if image_file.is_file() and image_file.suffix.lower() in image_extensions:
relative_path = image_file.relative_to(notes_path)
stat = image_file.stat()

images.append({
"name": image_file.name,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"size": stat.st_size,
"type": "image"
})

return images

72 changes: 72 additions & 0 deletions documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### Create Folder
Expand All @@ -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
```http
POST /api/folders/move
Expand Down
8 changes: 6 additions & 2 deletions documentation/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
- **HTML Export** - Export notes as standalone HTML files

### Image Support
- **Drag & drop upload** - Drop images from your file system directly into the editor
- **Clipboard paste** - Paste images from clipboard with Ctrl+V
- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB)

### Organization
- **Folder hierarchy** - Organize notes in nested folders
- **Drag & drop** - Move notes and folders effortlessly
Expand All @@ -24,7 +29,7 @@

### Internal Links
- **Wiki-style links** - `[[Note Name]]` syntax
- **Drag to link** - Hold Ctrl and drag a note into the editor
- **Drag to link** - Drag notes or images into the editor to insert links
- **Click to navigate** - Jump between notes seamlessly
- **External links** - Open in new tabs automatically

Expand Down Expand Up @@ -143,7 +148,6 @@ graph TD
| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo |
| `F3` | `F3` | Next search match |
| `Shift+F3` | `Shift+F3` | Previous search match |
| `Ctrl+Drag` | `Cmd+Drag` | Create internal link |

## 🚀 Performance

Expand Down
Loading