mirror of
https://github.com/MvDevsUnion/WPetition.git
synced 2026-01-13 08:59:29 +00:00
Implement Turnstile captcha for petition signing and add tweet prompt modal
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker compose build:*)",
|
||||
"Bash(docker compose:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
298
DOCKER_README.md
Normal file
298
DOCKER_README.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Docker Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses Docker Compose to run both the frontend and API in containers with Nginx as a unified gateway.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Browser (localhost:8080) │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Nginx Container │
|
||||
│ - Serves static frontend files │
|
||||
│ - Proxies /api/* to backend │
|
||||
│ - Proxies /swagger to backend │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ .NET API Container │
|
||||
│ - ASP.NET Core 9.0 │
|
||||
│ - Connects to MongoDB on host │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- MongoDB running on your host machine (port 27017)
|
||||
- Port 9755 available on your host
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Build and Start Containers
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:9755
|
||||
- **API** (via proxy): http://localhost:9755/api/*
|
||||
- **Swagger UI**: http://localhost:9755/swagger
|
||||
|
||||
### 3. View Logs
|
||||
|
||||
```bash
|
||||
# View all logs
|
||||
docker compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose logs -f nginx
|
||||
docker compose logs -f submission.api
|
||||
```
|
||||
|
||||
### 4. Stop Containers
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Frontend Changes (HTML/CSS/JS)
|
||||
|
||||
1. Edit files in the `Frontend/` directory
|
||||
2. Rebuild the nginx container:
|
||||
```bash
|
||||
docker compose build nginx && docker compose up -d
|
||||
```
|
||||
|
||||
### API Changes (.NET/C# Code)
|
||||
|
||||
1. Edit files in the `Submission.Api/` directory
|
||||
2. Rebuild the API container:
|
||||
```bash
|
||||
docker compose build submission.api && docker compose up -d
|
||||
```
|
||||
|
||||
### Both Frontend and API Changed
|
||||
|
||||
```bash
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Container Management
|
||||
|
||||
```bash
|
||||
# Start containers
|
||||
docker compose up -d
|
||||
|
||||
# Stop containers
|
||||
docker compose down
|
||||
|
||||
# Restart containers
|
||||
docker compose restart
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
|
||||
# Remove all containers and volumes
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### Logs and Debugging
|
||||
|
||||
```bash
|
||||
# Follow all logs
|
||||
docker compose logs -f
|
||||
|
||||
# View last 50 lines of API logs
|
||||
docker compose logs --tail=50 submission.api
|
||||
|
||||
# View last 50 lines of Nginx logs
|
||||
docker compose logs --tail=50 nginx
|
||||
|
||||
# Execute command inside API container
|
||||
docker compose exec submission.api /bin/bash
|
||||
```
|
||||
|
||||
### Rebuilding
|
||||
|
||||
```bash
|
||||
# Rebuild all containers
|
||||
docker compose build
|
||||
|
||||
# Rebuild with no cache (clean build)
|
||||
docker compose build --no-cache
|
||||
|
||||
# Rebuild specific service
|
||||
docker compose build nginx
|
||||
docker compose build submission.api
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### MongoDB Connection
|
||||
|
||||
The API connects to MongoDB on your host machine using `host.docker.internal:27017`.
|
||||
|
||||
To change the MongoDB connection:
|
||||
1. Edit `compose.yaml`
|
||||
2. Update the `MongoDbSettings__ConnectionString` environment variable
|
||||
3. Restart containers
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- MongoDbSettings__ConnectionString=mongodb://host.docker.internal:27017
|
||||
```
|
||||
|
||||
### Port Configuration
|
||||
|
||||
The application is exposed on port 9755. To change this:
|
||||
1. Edit `compose.yaml`
|
||||
2. Update the nginx ports mapping:
|
||||
```yaml
|
||||
ports:
|
||||
- "9755:80" # Change 9755 to your desired port
|
||||
```
|
||||
3. Restart containers
|
||||
|
||||
### API Base URL (Frontend)
|
||||
|
||||
The frontend API configuration is in `Frontend/index.html` at line 105:
|
||||
```javascript
|
||||
const API_CONFIG = {
|
||||
baseUrl: '' // Empty for same-origin requests
|
||||
};
|
||||
```
|
||||
|
||||
This is set to empty because Nginx proxies all `/api/*` requests to the backend.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── compose.yaml # Docker Compose configuration
|
||||
├── nginx/
|
||||
│ ├── Dockerfile # Nginx container definition
|
||||
│ └── nginx.conf # Nginx configuration
|
||||
├── Frontend/ # Static frontend files
|
||||
│ ├── index.html
|
||||
│ ├── style.css
|
||||
│ └── fonts/
|
||||
└── Submission.Api/
|
||||
├── Dockerfile # API container definition
|
||||
└── ... # .NET source files
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MongoDB Connection Issues
|
||||
|
||||
**Problem**: API can't connect to MongoDB on host
|
||||
|
||||
**Solution**:
|
||||
1. Verify MongoDB is running on host: `mongosh --eval "db.version()"`
|
||||
2. Check MongoDB is listening on `0.0.0.0:27017` not just `127.0.0.1`
|
||||
3. Check Windows Firewall isn't blocking the connection
|
||||
4. Verify the connection string in `compose.yaml` uses `host.docker.internal`
|
||||
|
||||
### Port 9755 Already in Use
|
||||
|
||||
**Problem**: Error binding to port 9755
|
||||
|
||||
**Solution**:
|
||||
1. Find what's using the port: `netstat -ano | findstr :9755`
|
||||
2. Either stop that process or change the port in `compose.yaml`
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
**Problem**: Container crashes on startup
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker compose logs submission.api
|
||||
|
||||
# Rebuild with no cache
|
||||
docker compose build --no-cache
|
||||
|
||||
# Remove old containers and rebuild
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Changes Not Reflected
|
||||
|
||||
**Problem**: Code changes don't appear after rebuild
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Force rebuild and restart
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Nginx 502 Bad Gateway
|
||||
|
||||
**Problem**: Nginx can't reach the API
|
||||
|
||||
**Solution**:
|
||||
1. Check API container is running: `docker compose ps`
|
||||
2. Check API logs: `docker compose logs submission.api`
|
||||
3. Verify API is listening on port 8080
|
||||
4. Check both containers are on the same network
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. **Update API base URL**: Change port 8080 to 80 (or use a reverse proxy)
|
||||
2. **MongoDB**: Use a proper MongoDB connection string with authentication
|
||||
3. **HTTPS**: Add SSL certificates to Nginx configuration
|
||||
4. **Environment variables**: Use `.env` file for sensitive configuration
|
||||
5. **Logging**: Configure proper log aggregation
|
||||
6. **Health checks**: Add Docker health checks to compose.yaml
|
||||
|
||||
### Example Production Changes
|
||||
|
||||
```yaml
|
||||
# compose.yaml
|
||||
services:
|
||||
nginx:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
|
||||
submission.api:
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- MongoDbSettings__ConnectionString=${MONGODB_CONNECTION_STRING}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Nginx Documentation](https://nginx.org/en/docs/)
|
||||
- [ASP.NET Core in Docker](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/docker/)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check container logs: `docker compose logs -f`
|
||||
2. Verify all services are running: `docker compose ps`
|
||||
3. Review this documentation
|
||||
4. Check Docker Desktop is running and healthy
|
||||
@@ -7,6 +7,12 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<link rel="preconnect" href="https://challenges.cloudflare.com">
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -69,7 +75,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="form-message" class="form-message"></div>
|
||||
<div id="form-message" class="form-message"></div>
|
||||
|
||||
<!-- Cloudflare Turnstile: add callbacks so auto-render uses our handlers -->
|
||||
<div class="cf-turnstile" data-sitekey="0x4AAAAAACHH4QC3wIhkCuhd"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-expired-callback="onTurnstileExpired"></div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button type="button" id="clear-signature" class="btn-secondary" title="Clear signature" aria-label="Clear signature">
|
||||
@@ -84,10 +96,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tweet Prompt Modal -->
|
||||
<div id="tweet-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal">×</span>
|
||||
<h2 id="modal-title">Share this petition!</h2>
|
||||
<p id="modal-text">Help spread the word by tweeting about this petition.</p>
|
||||
<div class="modal-buttons">
|
||||
<button id="tweet-button" class="btn-primary">
|
||||
<i class="fab fa-twitter"></i> <span id="tweet-btn-text">Tweet Now</span>
|
||||
</button>
|
||||
<button id="skip-tweet" class="btn-secondary"><span id="skip-btn-text">Maybe Later</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API Configuration - Change this for different environments
|
||||
const API_CONFIG = {
|
||||
baseUrl: 'http://localhost:5299' // Change to production URL when deploying
|
||||
baseUrl: '' // Empty for same-origin requests (Nginx proxies /api/* to backend)
|
||||
};
|
||||
|
||||
let currentLang = 'en';
|
||||
@@ -161,6 +188,9 @@
|
||||
|
||||
// Display petition data
|
||||
function displayPetition(data) {
|
||||
// Store petition data for tweet generation
|
||||
currentPetitionData = data;
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('petition-content').style.display = 'block';
|
||||
|
||||
@@ -197,6 +227,49 @@
|
||||
let isDrawing = false;
|
||||
let signaturePaths = [];
|
||||
let currentPath = [];
|
||||
// Turnstile widget state
|
||||
let turnstileWidgetId = null;
|
||||
let turnstileToken = null;
|
||||
|
||||
function onTurnstileSuccess(token) {
|
||||
turnstileToken = token;
|
||||
}
|
||||
|
||||
function onTurnstileError() {
|
||||
turnstileToken = null;
|
||||
}
|
||||
|
||||
function onTurnstileExpired() {
|
||||
turnstileToken = null;
|
||||
}
|
||||
|
||||
// Try to render Turnstile widget when API is available
|
||||
function ensureTurnstileRendered() {
|
||||
const container = document.querySelector('.cf-turnstile');
|
||||
if (!container) return;
|
||||
// If Turnstile already auto-rendered an iframe inside the container, don't render again
|
||||
if (container.children.length > 0) {
|
||||
// mark as auto-rendered
|
||||
turnstileWidgetId = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.turnstile && turnstileWidgetId === null) {
|
||||
try {
|
||||
turnstileWidgetId = turnstile.render(container, {
|
||||
sitekey: container.getAttribute('data-sitekey'),
|
||||
callback: onTurnstileSuccess,
|
||||
'error-callback': onTurnstileError,
|
||||
'expired-callback': onTurnstileExpired
|
||||
});
|
||||
} catch (e) {
|
||||
// If render fails, leave the element as-is (script may auto-render)
|
||||
}
|
||||
} else if (!window.turnstile) {
|
||||
// Retry until turnstile script is available
|
||||
setTimeout(ensureTurnstileRendered, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function initSignaturePad() {
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
@@ -336,6 +409,55 @@
|
||||
const petitionId = getPetitionIdFromUrl();
|
||||
const apiUrl = `${API_CONFIG.baseUrl}/api/Sign/petition/${petitionId}`;
|
||||
|
||||
// Ensure Turnstile widget is rendered
|
||||
ensureTurnstileRendered();
|
||||
|
||||
// Obtain token: prefer the callback value, fall back to getResponse or execute
|
||||
let token = turnstileToken;
|
||||
if ((!token || token.length === 0) && window.turnstile && turnstileWidgetId !== null) {
|
||||
try {
|
||||
if (typeof turnstile.getResponse === 'function') {
|
||||
let resp = null;
|
||||
try {
|
||||
if (turnstileWidgetId !== null && turnstileWidgetId >= 0) {
|
||||
resp = turnstile.getResponse(turnstileWidgetId);
|
||||
} else {
|
||||
// try without id (auto-rendered widgets may be retrievable this way)
|
||||
resp = turnstile.getResponse();
|
||||
}
|
||||
} catch (e) {
|
||||
resp = null;
|
||||
}
|
||||
if (resp) token = resp;
|
||||
}
|
||||
|
||||
if ((!token || token.length === 0) && typeof turnstile.execute === 'function') {
|
||||
// Trigger challenge; wait up to ~5s for callback to set token
|
||||
try { turnstile.execute(turnstileWidgetId); } catch (err) { }
|
||||
token = await new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (turnstileToken) {
|
||||
clearInterval(interval);
|
||||
resolve(turnstileToken);
|
||||
} else if (attempts > 25) { // ~5 seconds
|
||||
clearInterval(interval);
|
||||
resolve(null);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
token = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
showFormMessage('Please complete the captcha challenge.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
@@ -346,7 +468,8 @@
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
idCard: idCard,
|
||||
signature: signature
|
||||
signature: signature,
|
||||
turnstileToken: token
|
||||
})
|
||||
});
|
||||
|
||||
@@ -355,6 +478,10 @@
|
||||
}
|
||||
|
||||
showFormMessage('Signature submitted successfully!', 'success');
|
||||
|
||||
// Show tweet prompt modal
|
||||
showTweetModal();
|
||||
|
||||
document.getElementById('signature-form').reset();
|
||||
clearSignature();
|
||||
|
||||
@@ -365,6 +492,14 @@
|
||||
|
||||
} catch (error) {
|
||||
showFormMessage(`Failed to submit signature: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// Reset the Turnstile widget so a fresh token is required next time
|
||||
try {
|
||||
if (window.turnstile && turnstileWidgetId !== null) {
|
||||
turnstile.reset(turnstileWidgetId);
|
||||
}
|
||||
} catch (e) { }
|
||||
turnstileToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +514,84 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Tweet Modal Functions
|
||||
let currentPetitionData = null;
|
||||
|
||||
function updateModalLanguage() {
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'Share this petition!',
|
||||
text: 'Help spread the word by tweeting about this petition.',
|
||||
tweetBtn: 'Tweet Now',
|
||||
skipBtn: 'Maybe Later'
|
||||
},
|
||||
dv: {
|
||||
title: 'Share this petition!',
|
||||
text: 'Help spread the word by tweeting about this petition.',
|
||||
tweetBtn: 'Tweet Now',
|
||||
skipBtn: 'Maybe Later'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[currentLang];
|
||||
|
||||
document.getElementById('modal-title').textContent = t.title;
|
||||
document.getElementById('modal-text').textContent = t.text;
|
||||
document.getElementById('tweet-btn-text').textContent = t.tweetBtn;
|
||||
document.getElementById('skip-btn-text').textContent = t.skipBtn;
|
||||
}
|
||||
|
||||
function showTweetModal() {
|
||||
updateModalLanguage();
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
function hideTweetModal() {
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
function generateTweetText() {
|
||||
if (!currentPetitionData) return '';
|
||||
|
||||
const petitionName = currentLang === 'dv' ? currentPetitionData.nameDhiv : currentPetitionData.nameEng;
|
||||
const petitionUrl = `${window.location.origin}${window.location.pathname}?id=${currentPetitionData.id}`;
|
||||
|
||||
return `I just signed "${petitionName}"! \n\nAdd your signature: ${petitionUrl}`;
|
||||
}
|
||||
|
||||
function openTwitterIntent() {
|
||||
const tweetText = generateTweetText();
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
|
||||
console.log(twitterUrl);
|
||||
window.open(twitterUrl, '_blank');
|
||||
hideTweetModal();
|
||||
}
|
||||
|
||||
function setupTweetModalListeners() {
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
const tweetButton = document.getElementById('tweet-button');
|
||||
const skipButton = document.getElementById('skip-tweet');
|
||||
const closeButton = document.querySelector('.close-modal');
|
||||
|
||||
// Tweet button click
|
||||
tweetButton.addEventListener('click', openTwitterIntent);
|
||||
|
||||
// Skip button click
|
||||
skipButton.addEventListener('click', hideTweetModal);
|
||||
|
||||
// Close X click
|
||||
closeButton.addEventListener('click', hideTweetModal);
|
||||
|
||||
// Click outside modal
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
hideTweetModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const petitionId = getPetitionIdFromUrl();
|
||||
@@ -397,10 +610,15 @@
|
||||
|
||||
// Initialize signature pad
|
||||
initSignaturePad();
|
||||
// Render Turnstile (retry until script available)
|
||||
ensureTurnstileRendered();
|
||||
|
||||
// Set up form submission
|
||||
document.getElementById('signature-form').addEventListener('submit', submitSignature);
|
||||
|
||||
// Set up tweet modal listeners
|
||||
setupTweetModalListeners();
|
||||
|
||||
fetchPetition(petitionId);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -332,3 +332,121 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tweet Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 24px;
|
||||
color: #222;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary {
|
||||
background-color: #1DA1F2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary:hover {
|
||||
background-color: #1a91da;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
padding: 30px 20px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,26 @@ namespace Submission.Api.Controllers
|
||||
{
|
||||
return Problem("Petitions Folder not found");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("petitions-list", Name = "GetPetitionsList")]
|
||||
public IActionResult GetPetitionsList()
|
||||
{
|
||||
var list = _petitionRepository.FilterBy(x => x.Id != null);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("create-petition-folder", Name = "CreatePetitionFolder")]
|
||||
public IActionResult create_petition_folder()
|
||||
{
|
||||
if (Directory.Exists("Petitions"))
|
||||
{
|
||||
return Ok("Petitions folder already exists");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory("Petitions");
|
||||
|
||||
@@ -2,9 +2,11 @@ using Ashi.MongoInterface.Service;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using Submission.Api.Dto;
|
||||
using Submission.Api.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Submission.Api.Controllers
|
||||
{
|
||||
@@ -17,62 +19,85 @@ namespace Submission.Api.Controllers
|
||||
private readonly IMongoRepository<PetitionDetail> _detailRepository;
|
||||
private readonly IMongoRepository<Signature> _signatureRepository;
|
||||
private readonly IMemoryCache _cache;
|
||||
public readonly TurnstileService _turnstileService;
|
||||
|
||||
public SignController(
|
||||
IMongoRepository<Author> authorRepository,
|
||||
IMongoRepository<PetitionDetail> detailRepository,
|
||||
IMongoRepository<Signature> signatureRepository,
|
||||
IMemoryCache cache)
|
||||
IMemoryCache cache, TurnstileService turnstileService)
|
||||
{
|
||||
_authorRepository = authorRepository;
|
||||
_detailRepository = detailRepository;
|
||||
_signatureRepository = signatureRepository;
|
||||
_cache = cache;
|
||||
_turnstileService = turnstileService;
|
||||
}
|
||||
|
||||
[HttpPost("petition/{petition_id}", Name = "SignPetition")]
|
||||
[EnableRateLimiting("SignPetitionPolicy")]
|
||||
public async Task<IActionResult> SignDisHoe([FromRoute] Guid petition_id, [FromBody] WidgetsDto body)
|
||||
{
|
||||
var cacheKey = $"petition_{petition_id}";
|
||||
var remoteip = HttpContext.Request.Headers["CF-Connecting-IP"].FirstOrDefault() ??
|
||||
HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault() ??
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var pet = await _detailRepository.FindByIdAsync(petition_id);
|
||||
if (body.turnstileToken == null)
|
||||
return BadRequest("Turnstile token is missing");
|
||||
|
||||
if (pet == null)
|
||||
return NotFound();
|
||||
Console.WriteLine("Token received: " + body.turnstileToken);
|
||||
|
||||
//TODO : add svg validation
|
||||
var validation = await _turnstileService.ValidateTokenAsync(body.turnstileToken, remoteip);
|
||||
|
||||
|
||||
//check to see if the same person signed the petition already
|
||||
//if dupe send error saying user already signed
|
||||
var dupe = await _signatureRepository.FindOneAsync(x => x.IdCard == body.IdCard);
|
||||
if (dupe != null)
|
||||
return Problem("You already signed this petition");
|
||||
|
||||
//add signature to the db
|
||||
await _signatureRepository.InsertOneAsync(new Signature
|
||||
if (validation.Success)
|
||||
{
|
||||
IdCard = body.IdCard,
|
||||
Name = body.Name,
|
||||
Signature_SVG = body.Signature,
|
||||
Timestamp = DateTime.Now,
|
||||
PetitionId = petition_id
|
||||
});
|
||||
//why??
|
||||
var cacheKey = $"petition_{petition_id}";
|
||||
|
||||
//update signature count
|
||||
if (pet.SignatureCount == null)
|
||||
{
|
||||
pet.SignatureCount = 0;
|
||||
var pet = await _detailRepository.FindByIdAsync(petition_id);
|
||||
|
||||
if (pet == null)
|
||||
return NotFound();
|
||||
|
||||
//TODO : add svg validation
|
||||
//fuck i still havent done this
|
||||
|
||||
|
||||
//check to see if the same person signed the petition already
|
||||
//if dupe send error saying user already signed
|
||||
var dupe = await _signatureRepository.FindOneAsync(x => x.IdCard == body.IdCard);
|
||||
if (dupe != null)
|
||||
return Problem("You already signed this petition");
|
||||
|
||||
//add signature to the db
|
||||
await _signatureRepository.InsertOneAsync(new Signature
|
||||
{
|
||||
IdCard = body.IdCard,
|
||||
Name = body.Name,
|
||||
Signature_SVG = body.Signature,
|
||||
Timestamp = DateTime.Now,
|
||||
PetitionId = petition_id
|
||||
});
|
||||
|
||||
//update signature count
|
||||
if (pet.SignatureCount == null)
|
||||
{
|
||||
pet.SignatureCount = 0;
|
||||
}
|
||||
|
||||
var count_update_filter = Builders<PetitionDetail>.Filter.Eq("_id", petition_id);
|
||||
var Countupdate = Builders<PetitionDetail>.Update.Inc("SignatureCount", 1);
|
||||
await _detailRepository.UpdateOneAsync(count_update_filter, Countupdate);
|
||||
|
||||
_cache.Remove(cacheKey);
|
||||
|
||||
return Ok("your signature has been submitted");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid token - reject submission
|
||||
return BadRequest($"Verification failed: {string.Join(", ", validation.ErrorCodes)}");
|
||||
}
|
||||
|
||||
var count_update_filter = Builders<PetitionDetail>.Filter.Eq("_id", petition_id);
|
||||
var Countupdate = Builders<PetitionDetail>.Update.Inc("SignatureCount", 1);
|
||||
await _detailRepository.UpdateOneAsync(count_update_filter, Countupdate);
|
||||
|
||||
_cache.Remove(cacheKey);
|
||||
|
||||
return Ok("your signature has been submitted");
|
||||
}
|
||||
|
||||
[HttpGet("petition/{petition_id}", Name = "GetPetition")]
|
||||
@@ -121,4 +146,63 @@ namespace Submission.Api.Controllers
|
||||
return Ok(dto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#region Turnstile Service
|
||||
public class TurnstileSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class TurnstileService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _secretKey;
|
||||
private const string SiteverifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
|
||||
public TurnstileService(HttpClient httpClient, IOptions<TurnstileSettings> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_secretKey = options?.Value?.SecretKey ?? throw new ArgumentNullException(nameof(options), "Turnstile:SecretKey must be configured in appsettings.json");
|
||||
}
|
||||
|
||||
public async Task<TurnstileResponse> ValidateTokenAsync(string token, string remoteip = null)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "secret", _secretKey },
|
||||
{ "response", token }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(remoteip))
|
||||
{
|
||||
parameters.Add("remoteip", remoteip);
|
||||
}
|
||||
|
||||
var postContent = new FormUrlEncodedContent(parameters);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsync(SiteverifyUrl, postContent);
|
||||
var stringContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonSerializer.Deserialize<TurnstileResponse>(stringContent);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new TurnstileResponse
|
||||
{
|
||||
Success = false,
|
||||
ErrorCodes = new[] { "internal-error" }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class TurnstileResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string[] ErrorCodes { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 9755
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
|
||||
@@ -7,12 +7,15 @@ public class WidgetsDto
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
public string Name { get; set; }
|
||||
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
[MaxLength(7)]
|
||||
public string IdCard { get; set; }
|
||||
|
||||
|
||||
[Required]
|
||||
public string Signature { get; set; }
|
||||
|
||||
[Required]
|
||||
public string turnstileToken { get; set; }
|
||||
}
|
||||
@@ -3,12 +3,14 @@ using Ashi.MongoInterface.Service;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Submission.Api.Configuration;
|
||||
using Submission.Api.Controllers;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.Configure<MongoDbSettings>(builder.Configuration.GetSection("MongoDbSettings"));
|
||||
builder.Services.Configure<PetitionSettings>(builder.Configuration.GetSection("PetitionSettings"));
|
||||
builder.Services.Configure<TurnstileSettings>(builder.Configuration.GetSection("Turnstile"));
|
||||
|
||||
builder.Services.AddSingleton<IMongoDbSettings>(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value);
|
||||
@@ -22,6 +24,9 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Register TurnstileService with typed HttpClient
|
||||
builder.Services.AddHttpClient<TurnstileService>();
|
||||
|
||||
// Add rate limiting
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
@@ -37,11 +42,11 @@ builder.Services.AddRateLimiter(options =>
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
//if (app.Environment.IsDevelopment())
|
||||
//{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
//}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
{
|
||||
"Turnstile": {
|
||||
"SecretKey": "your-turn"
|
||||
},
|
||||
"PetitionSettings": {
|
||||
"AllowPetitionCreation": true
|
||||
},
|
||||
"MongoDbSettings": {
|
||||
"ConnectionString": "mongodb://localhost:27017",
|
||||
"DatabaseName": "petition_database"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
23
compose.yaml
23
compose.yaml
@@ -4,4 +4,27 @@
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Submission.Api/Dockerfile
|
||||
networks:
|
||||
- app-network
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
- MongoDbSettings__ConnectionString=mongodb://host.docker.internal:27017
|
||||
|
||||
nginx:
|
||||
image: petition-nginx
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
ports:
|
||||
- "9755:80"
|
||||
depends_on:
|
||||
- submission.api
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
|
||||
12
nginx/Dockerfile
Normal file
12
nginx/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy static frontend files
|
||||
COPY Frontend/ /usr/share/nginx/html/
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
53
nginx/nginx.conf
Normal file
53
nginx/nginx.conf
Normal file
@@ -0,0 +1,53 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Serve static frontend files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://submission.api:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Optional: Proxy Swagger if you want it accessible
|
||||
location /swagger {
|
||||
proxy_pass http://submission.api:8080/swagger;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
startDate: 14-12-2025
|
||||
nameDhiv: "މިސާލު ނަން"
|
||||
nameEng: "Sample Petition Name"
|
||||
nameEng: "Petition for the Enactment of a Comprehensive Data Protection Act"
|
||||
author:
|
||||
name: "Fishie"
|
||||
nid: "AAAAA12345"
|
||||
@@ -13,5 +13,50 @@ author:
|
||||
|
||||
## Petition Body (English)
|
||||
|
||||
This is the English version of the petition body written in clear paragraphs.
|
||||
You can use normal Markdown formatting here such as **bold**, lists, and links.
|
||||
We, the citizens of the Republic of Maldives, respectfully submit this petition to address the critical lack of a modern, comprehensive framework to protect the fundamental rights, privacy, and digital security of the Maldivian people.
|
||||
|
||||
In the rapidly evolving digital age, personal data including names, contact details, location data, and communication records is constantly being collected, processed, and stored by both private companies and public entities. The current legal framework in the Maldives is inadequate to safeguard these assets, leaving citizens vulnerable to privacy breaches, misuse of data, and exploitation. The absence of robust regulation has already led to severe real-world consequences, including high-profile incidents involving the unauthorized access, sale, and mass leakage of citizens’ personal and confidential information.
|
||||
|
||||
The widespread practice of sending unsolicited promotional SMS and messages is a clear example of this data processing abuse, constituting a daily intrusion into the personal lives and privacy of citizens, leading to significant disruption and eroding the quality of mobile communication.
|
||||
|
||||
---
|
||||
|
||||
A. Enactment of a Comprehensive Data Protection Act (Inspired by Global Standards)
|
||||
|
||||
We respectfully request the People's Majlis to initiate, deliberate upon, and enact a dedicated Data Protection Act that establishes high standards for the lawful processing of personal data, drawing upon globally recognized best practices, such as the European Union’s General Data Protection Regulation (GDPR), to ensure the Maldives’ framework is robust and future-proof.
|
||||
|
||||
1 . The Act must include, but not be limited to, the following core principles:
|
||||
|
||||
- Fundamental Rights of the Data Subject: Guaranteeing the rights of individuals to:
|
||||
- Access their personal data.
|
||||
- Rectify inaccurate data.
|
||||
- Erase their data (Right to be Forgotten).
|
||||
- Portability of their data.
|
||||
|
||||
2 . Lawful Basis for Processing: Mandating that all processing of personal data must be based on a clear, explicit, and informed consent or other defined legal grounds. The use of pre-checked boxes or presumed consent must be prohibited.
|
||||
|
||||
3 . Specific Mandate on Electronic Marketing Consent (Unsolicited SMS):
|
||||
|
||||
- The Act must specifically define the use of personal data (such as a phone number) for promotional SMS or electronic messaging as a form of marketing that requires prior, specific, and informed Opt-In consent from the subscriber.
|
||||
|
||||
- This consent must be separate from any general terms and conditions, ensuring the default position for all promotional communication is Opt-In (where the sender must receive explicit consent) rather than Opt-Out.
|
||||
|
||||
- The Act must prohibit the sending of commercial, marketing, or promotional SMS and messages to any subscriber without this specific consent.
|
||||
|
||||
4 . Establishment of an Independent Authority: Creating a well-resourced and independent Data Protection Authority (DPA) with the power to:
|
||||
|
||||
- Enforce the Act.
|
||||
- Investigate complaints.
|
||||
- Impose significant and effective penalties for non-compliance.
|
||||
|
||||
4 . Data Breach Obligations: Making it mandatory for data controllers to promptly notify the DPA and affected individuals of any significant data breach.
|
||||
|
||||
---
|
||||
|
||||
We affirm that this petition is in full compliance with the Rules of Procedure of the People's Majlis (Article 257) and does not contain any of the prohibited content, including, but not limited to: anything contrary to the Constitution or laws of the Republic of Maldives; confidential business or financial information; any request to grant or strip honors, or give or dismiss employment; or anything that endangers national security.
|
||||
|
||||
---
|
||||
|
||||
We urge the Honourable People's Majlis to recognize the profound importance of digital privacy and the necessity of safeguarding citizens from intrusive communication. We respectfully pray that the Majlis accepts this petition, refers it to the appropriate Standing Committee for thorough review, and takes the necessary legislative steps to enact the requested Data Protection Act, incorporating the mandatory Opt-In policy for all promotional communication.
|
||||
|
||||
We look forward to the Majlis’s favourable consideration of this matter of national importance.
|
||||
Reference in New Issue
Block a user