upload images to storage instead of DB and add image high quaiilty view
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.routers import auth, maps, items, map_share, websocket
|
||||
from app.routers import auth, maps, items, map_share, websocket, uploads
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
@@ -25,6 +25,7 @@ app.include_router(maps.router)
|
||||
app.include_router(items.router)
|
||||
app.include_router(map_share.router)
|
||||
app.include_router(websocket.router)
|
||||
app.include_router(uploads.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
165
app/routers/uploads.py
Normal file
165
app/routers/uploads.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from app.dependencies import get_current_user, get_optional_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
|
||||
|
||||
# Storage directory for uploaded images
|
||||
STORAGE_DIR = Path("/home/shihaam/git/sarlink/mapmaker/storage/images")
|
||||
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp"
|
||||
}
|
||||
|
||||
# Maximum file size: 8MB
|
||||
MAX_FILE_SIZE = 8 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Upload an image file.
|
||||
Returns the file path that can be used to retrieve the image.
|
||||
"""
|
||||
# Validate file type by MIME type
|
||||
if file.content_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_MIME_TYPES)}"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
# Validate file size
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File too large. Maximum size is 8MB."
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
original_extension = Path(file.filename).suffix.lower()
|
||||
if original_extension not in ALLOWED_EXTENSIONS:
|
||||
# Fallback to MIME type mapping
|
||||
mime_to_ext = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp"
|
||||
}
|
||||
original_extension = mime_to_ext.get(file.content_type, ".jpg")
|
||||
|
||||
# Generate random filename
|
||||
random_filename = f"{uuid.uuid4()}{original_extension}"
|
||||
file_path = STORAGE_DIR / random_filename
|
||||
|
||||
# Save file
|
||||
try:
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save file: {str(e)}"
|
||||
)
|
||||
|
||||
# Return the relative path
|
||||
return {
|
||||
"filename": random_filename,
|
||||
"path": f"/api/uploads/image/{random_filename}",
|
||||
"size": len(content)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/image/{filename}")
|
||||
async def get_image(
|
||||
filename: str,
|
||||
current_user: Optional[User] = Depends(get_optional_current_user)
|
||||
):
|
||||
"""
|
||||
Retrieve an uploaded image.
|
||||
Public endpoint - requires either authenticated user or valid share token.
|
||||
"""
|
||||
# Validate filename to prevent directory traversal
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid filename"
|
||||
)
|
||||
|
||||
file_path = STORAGE_DIR / filename
|
||||
|
||||
# Check if file exists
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found"
|
||||
)
|
||||
|
||||
# Determine media type based on extension
|
||||
extension = file_path.suffix.lower()
|
||||
media_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp"
|
||||
}
|
||||
media_type = media_type_map.get(extension, "application/octet-stream")
|
||||
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
|
||||
|
||||
@router.delete("/image/{filename}")
|
||||
async def delete_image(
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete an uploaded image.
|
||||
Requires authentication.
|
||||
"""
|
||||
# Validate filename to prevent directory traversal
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid filename"
|
||||
)
|
||||
|
||||
file_path = STORAGE_DIR / filename
|
||||
|
||||
# Check if file exists
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found"
|
||||
)
|
||||
|
||||
# Delete file
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete file: {str(e)}"
|
||||
)
|
||||
|
||||
return {"message": "Image deleted successfully"}
|
||||
66
public/src/components/common/ImagePreview.tsx
Normal file
66
public/src/components/common/ImagePreview.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
imageUrl: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImagePreview({ imageUrl, onClose }: ImagePreviewProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 animate-fadeIn"
|
||||
style={{ zIndex: 10001 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 bg-white/10 hover:bg-white/20 text-white rounded-full w-10 h-10 flex items-center justify-center transition-colors backdrop-blur-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { mapItemService } from '../../services/mapItemService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
interface ItemContextMenuProps {
|
||||
@@ -18,8 +19,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
|
||||
const [newName, setNewName] = useState(item.properties.name || '');
|
||||
const [notes, setNotes] = useState(item.properties.notes || '');
|
||||
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(item.properties.image || null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -105,9 +108,9 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
showToast('Image too large. Please use an image smaller than 2MB.', 'error');
|
||||
// Check file size (max 8MB)
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
showToast('Image too large. Please use an image smaller than 8MB.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,15 +120,21 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageData(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// Store the file for upload later
|
||||
setImageFile(file);
|
||||
|
||||
// Create a preview URL
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setImagePath(previewUrl);
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageData(null);
|
||||
// Revoke the preview URL if it exists
|
||||
if (imagePath && imagePath.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imagePath);
|
||||
}
|
||||
setImagePath(null);
|
||||
setImageFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
@@ -133,13 +142,31 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
|
||||
const handleSaveNotes = async () => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
let finalImagePath = imagePath;
|
||||
|
||||
// If there's a new file to upload
|
||||
if (imageFile) {
|
||||
try {
|
||||
const uploadResponse = await uploadService.uploadImage(imageFile);
|
||||
finalImagePath = uploadResponse.path;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
showToast('Failed to upload image', 'error');
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the notes and image path
|
||||
await mapItemService.updateMapItem(item.map_id, item.id, {
|
||||
properties: {
|
||||
...item.properties,
|
||||
notes: notes,
|
||||
image: imageData,
|
||||
image: finalImagePath,
|
||||
},
|
||||
});
|
||||
|
||||
onUpdate();
|
||||
setShowNotesDialog(false);
|
||||
onClose();
|
||||
@@ -147,6 +174,8 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
} catch (error) {
|
||||
console.error('Failed to save notes:', error);
|
||||
showToast('Failed to save notes', 'error');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,10 +353,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Attach Image (optional)
|
||||
</label>
|
||||
{imageData ? (
|
||||
{imagePath ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageData}
|
||||
src={imagePath.startsWith('blob:')
|
||||
? imagePath
|
||||
: uploadService.getImageUrl(imagePath)}
|
||||
alt="Attached"
|
||||
className="w-full rounded border border-gray-300 dark:border-gray-600 mb-2 bg-gray-50 dark:bg-gray-900"
|
||||
style={{ maxHeight: '200px', objectFit: 'contain' }}
|
||||
@@ -348,19 +379,21 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 dark:file:bg-blue-900/30 file:text-blue-700 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50 transition-colors"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Max size: 2MB</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Max size: 8MB</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveNotes}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md"
|
||||
disabled={isUploading}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
{isUploading ? 'Uploading...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowNotesDialog(false); onClose(); }}
|
||||
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
|
||||
disabled={isUploading}
|
||||
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createPortal } from 'react-dom';
|
||||
import { Polyline, Marker, Popup, Circle, Tooltip, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { mapItemService } from '../../services/mapItemService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { CABLE_COLORS, type MapItem, type CableType } from '../../types/mapItem';
|
||||
import { ItemContextMenu } from './ItemContextMenu';
|
||||
import { ImagePreview } from '../common/ImagePreview';
|
||||
import { useDrawingStore } from '../../stores/drawingStore';
|
||||
|
||||
// Calculate distance between two lat/lng points in meters using Haversine formula
|
||||
@@ -106,6 +108,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
item: MapItem;
|
||||
position: { x: number; y: number };
|
||||
} | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const { activeTool } = useDrawingStore();
|
||||
|
||||
// Check if we're in drawing mode (should suppress popups)
|
||||
@@ -247,10 +250,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
{item.properties.image && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={item.properties.image}
|
||||
src={uploadService.getImageUrl(item.properties.image)}
|
||||
alt="Attachment"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImagePreview(uploadService.getImageUrl(item.properties.image));
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -344,10 +355,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
{item.properties.image && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={item.properties.image}
|
||||
src={uploadService.getImageUrl(item.properties.image)}
|
||||
alt="Attachment"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImagePreview(uploadService.getImageUrl(item.properties.image));
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -479,10 +498,18 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
{item.properties.image && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={item.properties.image}
|
||||
src={uploadService.getImageUrl(item.properties.image)}
|
||||
alt="Attachment"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
||||
className="w-full rounded border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImagePreview(uploadService.getImageUrl(item.properties.image));
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,6 +533,14 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Image preview modal */}
|
||||
{imagePreview && (
|
||||
<ImagePreview
|
||||
imageUrl={imagePreview}
|
||||
onClose={() => setImagePreview(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
@@ -46,6 +50,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet CSS will be imported in main.tsx */
|
||||
|
||||
40
public/src/services/uploadService.ts
Normal file
40
public/src/services/uploadService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from './api';
|
||||
|
||||
interface UploadImageResponse {
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
class UploadService {
|
||||
async uploadImage(file: File): Promise<UploadImageResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<UploadImageResponse>('/api/uploads/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteImage(filename: string): Promise<void> {
|
||||
await apiClient.delete(`/api/uploads/image/${filename}`);
|
||||
}
|
||||
|
||||
getImageUrl(path: string): string {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
// If it's a path like /api/uploads/image/xxx.jpg
|
||||
if (path.startsWith('/api/uploads/image/')) {
|
||||
return `${apiUrl}${path}`;
|
||||
}
|
||||
|
||||
// If it's just a filename
|
||||
return `${apiUrl}/api/uploads/image/${path}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadService = new UploadService();
|
||||
Reference in New Issue
Block a user