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
26 changes: 26 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,21 @@ curl -X POST http://localhost:8080/api/v1/devices/237123456789/message \
}'
```

#### Multiple Recipients (Comma-Separated)

Send the same message to multiple addresses by providing a comma-separated list:

```bash
curl -X POST http://localhost:8080/api/v1/devices/237123456789/message \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact": "1234567890, 0987654321, another_address",
"platform": "wa",
"text": "Hello to multiple recipients"
}'
```

#### Text + File (Multipart)

```bash
Expand All @@ -151,6 +166,17 @@ curl -X POST http://localhost:8080/api/v1/devices/237123456789/message \
-F "file=@/path/to/document.pdf"
```

For multiple recipients with files, use comma-separated addresses:

```bash
curl -X POST http://localhost:8080/api/v1/devices/237123456789/message \
-H "Authorization: Bearer $TOKEN" \
-F "contact=1234567890, 0987654321" \
-F "platform=wa" \
-F "text=Check out this document" \
-F "file=@/path/to/document.pdf"
```

File must have an extension.

### Delete Device
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture/send-message-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ sequenceDiagram

Client->>API: Send message request
API->>API: Validate request
API->>API: Split comma-separated addresses
API->>DB: Retrieve user matrix profile
DB-->>API: Matrix profile
API->>API: Decrypt credentials
API->>RMQ: Publish message to exchange
RMQ-->>API: Acknowledgment
API-->>Client: Message queued
loop For each address
API->>RMQ: Publish message to exchange
RMQ-->>API: Acknowledgment
end
API-->>Client: Message(s) queued

Note over Worker,RMQ: Worker subscribed to exchange

Expand Down
44 changes: 30 additions & 14 deletions internal/api/v1/handlers/devices/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type queuedMessage struct {
// @Param platform formData string false "Platform (multipart)"
// @Param text formData string false "Message text (multipart, optional if file provided)"
// @Param file formData file false "File to upload (multipart)"
// @Success 200 {object} SendMessageResponse "Message queued successfully"
// @Success 200 {object} SendMessageResponse "Message(s) queued successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body or validation error"
// @Failure 401 {object} ErrorResponse "Invalid or expired matrix token"
// @Failure 403 {object} ErrorResponse "Invalid or expired matrix token"
Expand Down Expand Up @@ -159,23 +159,39 @@ func (h *DeviceHandler) SendMessage(c echo.Context) error {
return echo.ErrInternalServerError
}

message := queuedMessage{
DeviceID: deviceID,
Contact: req.Contact,
PlatformName: req.Platform,
Text: req.Text,
Username: matrixUsername,
FileContent: fileContent,
FileExtension: fileExtension,
addresses := strings.SplitSeq(req.Contact, ",")
validCount := 0
for address := range addresses {
address = strings.TrimSpace(address)
if address == "" {
continue
}
Comment thread
PromiseFru marked this conversation as resolved.
validCount++

message := queuedMessage{
DeviceID: deviceID,
Contact: address,
PlatformName: req.Platform,
Text: req.Text,
Username: matrixUsername,
FileContent: fileContent,
FileExtension: fileExtension,
}

if err := producer.Publish(exchangeName, routingKey, message, rabbitmq.DefaultPublishOptions()); err != nil {
logger.Error(fmt.Sprintf("RabbitMQ message publish failed for address %s: %v\n%s", address, err, debug.Stack()))
return echo.ErrInternalServerError
}
}
Comment on lines +162 to 185

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open an issue for this feedback tag as enhancement

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have the capability to create GitHub issues with my available tools. You can open the issue manually using the details below:

Title: Enforce max-recipients limit on comma-separated contact field to prevent DoS

Labels: enhancement

Body:

Allowing comma-separated recipients in the POST /api/v1/devices/{device_id}/message endpoint means a single request can trigger an unbounded number of RabbitMQ publishes (one per address). This is a potential abuse/DoS vector and can cause long request times for legitimate users.

Proposed changes:

  • Define a configurable constant (e.g. maxRecipients = 50) for the maximum number of recipients allowed per request.
  • Define a configurable constant (e.g. maxContactLength = 1024) for the maximum byte length of the raw contact field.
  • Return HTTP 400 with a clear error message when either limit is exceeded.

Affected file: internal/api/v1/handlers/devices/send.go (loop starting around line 162)


if err := producer.Publish(exchangeName, routingKey, message, rabbitmq.DefaultPublishOptions()); err != nil {
logger.Error(fmt.Sprintf("RabbitMQ message publish failed: %v\n%s", err, debug.Stack()))
return echo.ErrInternalServerError
if validCount == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Missing required field: contact",
})
}

logger.Info("Message queued successfully")
logger.Info("Message(s) queued successfully")
return c.JSON(http.StatusOK, SendMessageResponse{
Message: "Message queued successfully",
Message: "Message(s) queued successfully",
})
}
2 changes: 1 addition & 1 deletion internal/api/v1/handlers/devices/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ type SendMessageRequest struct {

// SendMessageResponse represents the response after queuing a message
type SendMessageResponse struct {
Message string `json:"message" example:"Message queued successfully"`
Message string `json:"message" example:"Message(s) queued successfully"`
}

// DeviceResponse represents the response after device operations
Expand Down
690 changes: 690 additions & 0 deletions pkg/adminweb/web/dist/assets/index-CesdBrmM.js

Large diffs are not rendered by default.

679 changes: 0 additions & 679 deletions pkg/adminweb/web/dist/assets/index-RfRcnsUM.js

This file was deleted.

2 changes: 1 addition & 1 deletion pkg/adminweb/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
rel="stylesheet"
/>
<title>ShortMesh Admin</title>
<script type="module" crossorigin src="/admin/assets/index-RfRcnsUM.js"></script>
<script type="module" crossorigin src="/admin/assets/index-CesdBrmM.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CJ_4jDPL.css">
</head>
<body>
Expand Down
105 changes: 88 additions & 17 deletions pkg/adminweb/web/src/pages/Devices.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
IconButton,
Tooltip,
Alert,
Chip,
Fab,
} from "@mui/material";
import {
Add,
Expand Down Expand Up @@ -50,7 +52,9 @@ export default function Devices() {
const [matrixToken, setMatrixToken] = useState("");
const [matrixTokenError, setMatrixTokenError] = useState("");
const [selectedDevice, setSelectedDevice] = useState(null);
const [messageContact, setMessageContact] = useState("");
const [contacts, setContacts] = useState([]);
const [contactInput, setContactInput] = useState("");
const [contactMode, setContactMode] = useState("phone");
const [messageText, setMessageText] = useState("");
const [revealedFields, setRevealedFields] = useState({});
const [platformLoading, setPlatformLoading] = useState(false);
Expand Down Expand Up @@ -303,7 +307,9 @@ export default function Devices() {

const handleOpenSendMessage = (device) => {
setSelectedDevice(device);
setMessageContact("");
setContacts([]);
setContactInput("");
setContactMode("phone");
setMessageText("");
setMessageFiles((prev) => {
prev.forEach((e) => { if (e.previewUrl) URL.revokeObjectURL(e.previewUrl); });
Expand All @@ -329,9 +335,20 @@ export default function Devices() {
});
};

const handleAddContact = () => {
const trimmed = contactInput?.trim();
if (!trimmed) return;
setContacts((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
setContactInput("");
};

const handleRemoveContact = (contact) => {
setContacts((prev) => prev.filter((c) => c !== contact));
};

const handleSendMessage = async () => {
if (!messageContact.trim()) {
setSendMessageError("Contact number is required");
if (contacts.length === 0) {
setSendMessageError("At least one contact is required");
return;
}
if (!messageText.trim()) {
Expand All @@ -340,10 +357,11 @@ export default function Devices() {
}

try {
const contact = contacts.join(",");
const sendRequest = async (file) => {
if (file) {
const formData = new FormData();
formData.append("contact", messageContact);
formData.append("contact", contact);
formData.append("platform", selectedDevice.platform);
formData.append("text", messageText);
formData.append("file", file);
Expand All @@ -352,7 +370,7 @@ export default function Devices() {
return apiCall(`/api/v1/admin/devices/${selectedDevice.device_id}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contact: messageContact, platform: selectedDevice.platform, text: messageText }),
body: JSON.stringify({ contact, platform: selectedDevice.platform, text: messageText }),
});
};

Expand All @@ -372,7 +390,7 @@ export default function Devices() {
}
}
handleCloseSendMessage();
message.success("Message queued successfully");
message.success(contacts.length > 1 ? `Message queued for ${contacts.length} recipients` : "Message queued successfully");
} catch (error) {
console.error("Error sending message:", error);
setSendMessageError("Failed to send message");
Expand Down Expand Up @@ -624,7 +642,9 @@ export default function Devices() {
</Typography>
{qrCodeData ? (
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<QRCodeSVG value={qrCodeData} size={300} />
<Box sx={{ background: "#fff", p: 2, borderRadius: 2, display: "inline-flex" }}>
<QRCodeSVG value={qrCodeData} size={280} />
</Box>
</Box>
) : (
<Box sx={{ py: 10 }}>
Expand Down Expand Up @@ -748,16 +768,67 @@ export default function Devices() {
<Alert severity="error" sx={{ mb: 2 }}>{sendMessageError}</Alert>
)}
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Contact Number
Contact(s)
</Typography>
<PhoneInput
international
defaultCountry="CM"
value={messageContact}
onChange={(val) => { setMessageContact(val); setSendMessageError(""); }}
placeholder="Enter phone number"
autoComplete="tel"
/>
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
<Button
size="small"
type={contactMode === "phone" ? "primary" : "default"}
onClick={() => { setContactMode("phone"); setContactInput(""); }}
>
Phone Number
</Button>
<Button
size="small"
type={contactMode === "text" ? "primary" : "default"}
onClick={() => { setContactMode("text"); setContactInput(""); }}
>
Name / Username
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{contactMode === "phone" ? (
<Box sx={{ flex: 1 }}>
<PhoneInput
international
defaultCountry="CM"
value={contactInput}
onChange={(val) => { setContactInput(val || ""); setSendMessageError(""); }}
placeholder="Enter phone number"
/>
</Box>
) : (
<Input
style={{ flex: 1 }}
value={contactInput}
onChange={(e) => { setContactInput(e.target.value); setSendMessageError(""); }}
placeholder="Enter name or username"
onPressEnter={handleAddContact}
autoComplete="off"
/>
)}
<Fab
size="small"
color="primary"
onClick={handleAddContact}
disabled={!contactInput?.trim()}
sx={{ flexShrink: 0, boxShadow: 2 }}
>
<Add />
</Fab>
</Box>
{contacts.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5 }}>
{contacts.map((c) => (
<Chip
key={c}
label={c}
size="small"
onDelete={() => handleRemoveContact(c)}
/>
))}
</Box>
)}
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Expand Down
Loading