update logic and refactor code

This commit is contained in:
2025-07-16 12:15:55 +02:00
parent ed6443347a
commit 6d055f4fad
28 changed files with 435 additions and 727 deletions

View File

@ -6,7 +6,7 @@ This is the **frontend Nuxt.js** application that works with the [LXD Proxy API]
## 🌐 Overview
- Each user has a subdomain (e.g., `customer1.lxdapp.local`)
- Each user has a subdomain (e.g., `mitul.lxdapp.local`)
- On visiting the subdomain, a login page prompts for CAPTCHA
- Upon successful CAPTCHA, a request is made to the backend to:
- Create or start an LXD container
@ -34,11 +34,6 @@ This is the **frontend Nuxt.js** application that works with the [LXD Proxy API]
```bash
npm run dev
3. **Visit the subdomain**
http://customer1.lxdapp.local:3000
⚠️ Make sure this domain is mapped in /etc/hosts and that the backend is running on the correct origin.
**🗂 Project Structure**
@ -54,39 +49,3 @@ frontend/
├── package.json
└── README.md
**🔐 CAPTCHA Flow**
User opens subdomain: customer1.lxdapp.local
Enters the CAPTCHA value
Form submits:
POST /api/v1/proxy with JSON payload:
{
"source": "login",
"panswer": "abc123"
}
Adds header:
Origin: http://customer1.lxdapp.local
**Backend:**
Validates CAPTCHA
Creates or starts container
Responds with container IP proxy
**🧪 Development Notes**
config.json in backend maps subdomain to LXD container
This frontend app assumes the backend is on the same subdomain
If needed, configure proxy in nuxt.config.js or use relative API paths

View File

@ -1,6 +0,0 @@
Manually start a dnsmasq instance on localhost only
==========
sudo dnsmasq --no-daemon --keep-in-foreground --conf-dir=/etc/dnsmasq-local --listen-address=127.0.0.1 --bind-interfaces
==========

View File

@ -1,7 +0,0 @@
{
"folders": [
{
"path": ".."
}
]
}

View File

@ -1,94 +0,0 @@
server {
listen 8080;
server_name lxdapp.local *.lxdapp.local;
root /var/www/html/lxd-app/backend/app/public;
index index.php index.html;
access_log /var/log/nginx/lxdapp.access.log;
error_log /var/log/nginx/lxdapp.error.log;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_index index.php;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
server {
listen 8081 ssl;
server_name lxdapp.local;
ssl_certificate /etc/ssl/certs/lxdapp.local.crt;
ssl_certificate_key /etc/ssl/private/lxdapp.local.key;
root /var/www/html/lxd-app/backend/app/public;
index index.php index.html;
access_log /var/log/nginx/lxdapp.access.log;
error_log /var/log/nginx/lxdapp.error.log;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_index index.php;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
public function forward(Request $request, Response $response)
{
$source = $request->getQueryParams()['source'] ?? null;
$domain = $request->getUri()->getHost(); // e.g. customer1.lxdapp.local
$name = $this->mapDomainToContainer($domain); // Use your config mapping
$ip = $this->getContainerIP($name);
if ($source === 'login') {
// Trigger provisioning if requested from login
if (!$this->containerExists($name)) {
$this->createContainer($name);
$this->startContainer($name);
$this->installPackages($name);
// Wait for container to be ready
if (!$this->waitForPort($ip, 80)) {
return $this->json($response, ['status' => 'error', 'message' => 'Container not ready'], 500);
}
}
return $this->json($response, ['status' => 'success', 'ip' => $ip]);
}
// For non-login (main page access)
if (!$this->containerExists($name)) {
return $this->json($response, ['status' => 'not-found']);
}
// Otherwise proxy the request to container (assuming already up)
return $this->proxyToContainer($request, $response, $ip);
}

View File

@ -1,13 +1,23 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
app: {
baseURL: '/app/' // This replaces router.base from Nuxt 2
},
ssr: true,
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
runtimeConfig: {
public: {
siteUrl: process.env.SITE_URL,
backendUrl: process.env.BACKEND_URL,
apiVersion: process.env.API_VERSION,
apiUrl: `${process.env.BACKEND_URL}/${process.env.API_VERSION}`
apiUrl: `${process.env.BACKEND_URL}/api`
}
},
nitro: {
prerender: {
crawlLinks: true,
routes: ['/', '/login'], // Add known routes
ignore: ['/*.php'], // Or leave empty to catch with [...slug].vue
}
},
css: [

View File

@ -2,44 +2,97 @@
<div class="page-wrapper">
<Spinner :loading="loading" />
<div v-html="output" />
<!-- 👇 Only show form if access is allowed -->
<div v-if="allowAccess" class="login-container">
<form @submit.prevent="submitForm" class="login-form">
<h2 class="title">Login</h2>
<!-- Include Captcha -->
<Captcha v-model:captcha="captchaValue" />
<button type="submit" :disabled="loading || !captchaValue" class="btn">
<span v-if="!loading">Login</span>
<span v-else>Loading...</span>
</button>
<br/>
<p v-if="captchaError" class="error-text">{{ captchaError }}</p>
</form>
</div>
<!-- 👇 Show this if the user did not come from an approved source -->
<div v-else class="login-container">
<h2 class="title">Access Denied</h2>
<p>You must access this page through the proper login flow.</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import Spinner from '@/components/Spinner.vue';
import Captcha from '@/components/Captcha.vue';
const router = useRouter();
const loading = ref(true);
const output = ref('');
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const captchaValue = ref('');
const captchaError = ref('');
const redirectTo = ref('/');
const allowAccess = ref(false) // 🔐 This controls what to show
// 🟡 Grab redirect param on load
onMounted(() => {
const redirectParam = route.query.redirect;
const authParam = route.query.auth;
if (redirectParam && typeof redirectParam === 'string') {
redirectTo.value = decodeURIComponent(redirectParam);
}
// ✅ More reliable than document.referrer
if (authParam === 'ok') {
allowAccess.value = true;
}
});
const submitForm = async () => {
loading.value = true;
output.value = '';
const config = useRuntimeConfig();
onMounted(async () => {
try {
const config = useRuntimeConfig();
// fetch without forcing responseType to text, so it defaults to JSON if possible
const res = await $fetch(`${config.public.apiUrl}/proxy`);
const res = await $fetch(`${window.location.origin}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
source: 'login',
panswer: captchaValue.value,
redirect: redirectTo.value,
},
credentials: 'include',
throwHttpErrors: false, // important: do NOT throw on 401/4xx
});
if (typeof res === 'object') {
if(res?.status === "not-found"){
router.push('/login');
}else{
// pretty print JSON response
output.value = `<pre>${JSON.stringify(res, null, 2)}</pre>`;
if (res.status === 'success') {
captchaError.value = '';
if (redirectTo.value.startsWith('http://') || redirectTo.value.startsWith('https://')) {
window.location.href = redirectTo.value;
} else {
router.push(redirectTo.value);
}
} else if (res.status === 'error' && res.message === 'Invalid CAPTCHA') {
captchaError.value = '❌ Invalid CAPTCHA. Please try again.';
} else {
// treat as plain text or HTML
output.value = res;
captchaError.value = res.message || 'Login failed. Please try again.';
}
} catch (error) {
output.value = `<p style="color:red;">Container access failed.</p>`;
// This should rarely happen now because throwHttpErrors is false
captchaError.value = error.message || 'Network error.';
} finally {
loading.value = false;
}
});
};
</script>
<style scoped>
.page-wrapper {
width: 100%;
@ -48,8 +101,57 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0px;
margin: 0px;
padding: 0;
margin: 0;
text-align: center;
}
</style>
.login-container {
max-width: 100%;
width: 400px;
margin: 5rem auto;
padding: 2rem;
border-radius: 8px;
background: #f9fafb;
box-shadow: 0 4px 10px rgb(0 0 0 / 0.1);
}
.title {
text-align: center;
margin-bottom: 1.5rem;
font-weight: 700;
font-size: 1.8rem;
color: #222;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: 1.2rem;
}
.btn {
width: 100%;
padding: 0.8rem;
font-weight: 600;
font-size: 1.1rem;
background-color: #3b82f6; /* blue-500 */
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover:not(:disabled) {
background-color: #2563eb; /* blue-600 */
}
.btn:disabled {
background-color: #93c5fd; /* blue-300 */
cursor: not-allowed;
}
.error-text {
color: red;
font-size: 0.95rem;
margin-bottom: 1rem;
text-align: center;
}
</style>

View File

@ -1,130 +0,0 @@
<template>
<div class="page-wrapper">
<Spinner :loading="loading" />
<div v-html="output" />
<div class="login-container">
<form @submit.prevent="submitForm" class="login-form">
<h2 class="title">Login</h2>
<!-- Include Captcha -->
<Captcha v-model:captcha="captchaValue" />
<button type="submit" :disabled="loading || !captchaValue" class="btn">
<span v-if="!loading">Login</span>
<span v-else>Loading...</span>
</button>
<br/>
<p v-if="captchaError" class="error-text">{{ captchaError }}</p>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import Spinner from '@/components/Spinner.vue';
import Captcha from '@/components/Captcha.vue';
const output = ref('');
const router = useRouter();
const loading = ref(false);
const captchaValue = ref('');
const captchaError = ref('');
const submitForm = async () => {
loading.value = true;
output.value = '';
const config = useRuntimeConfig();
try {
const res = await $fetch(`${config.public.apiUrl}/proxy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
source: 'login',
panswer: captchaValue.value
},
credentials: 'include',
});
if (res?.status === 'success') {
captchaError.value = '';
router.push('/');
} else if (res?.error === 'invalid_captcha') {
captchaError.value = '❌ Invalid CAPTCHA. Please try again.';
} else {
captchaError.value = res.message;
}
} catch (error) {
captchaError.value = res.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.page-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
text-align: center;
}
.login-container {
max-width: 100%;
width: 400px;
margin: 5rem auto;
padding: 2rem;
border-radius: 8px;
background: #f9fafb;
box-shadow: 0 4px 10px rgb(0 0 0 / 0.1);
}
.title {
text-align: center;
margin-bottom: 1.5rem;
font-weight: 700;
font-size: 1.8rem;
color: #222;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: 1.2rem;
}
.btn {
width: 100%;
padding: 0.8rem;
font-weight: 600;
font-size: 1.1rem;
background-color: #3b82f6; /* blue-500 */
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover:not(:disabled) {
background-color: #2563eb; /* blue-600 */
}
.btn:disabled {
background-color: #93c5fd; /* blue-300 */
cursor: not-allowed;
}
.error-text {
color: red;
font-size: 0.95rem;
margin-bottom: 1rem;
text-align: center;
}
</style>