Wifiv2 / src /services /omadaClient.js
Mbonea's picture
Upload filled portal redirect HTML
c175f98
Raw
History Blame Contribute Delete
11.6 kB
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const https = require('https');
function trimSlash(value) {
return String(value || '').replace(/\/+$/, '');
}
function encodeQuery(query = {}) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query || {})) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
}
const text = params.toString();
return text ? `?${text}` : '';
}
function formatOmadaError(error, context = '') {
if (error.response) {
const data = error.response.data;
const body = typeof data === 'string' ? data : JSON.stringify(data);
return `${context}${error.response.status} ${error.response.statusText}: ${body}`;
}
if (error.omada) {
const detail = error.result ? ` ${JSON.stringify(error.result)}` : '';
return `${context}Omada error ${error.errorCode}: ${error.message}${detail}`;
}
return `${context}${error.message}`;
}
function summarizeOmadaResponse(data) {
if (!data || typeof data !== 'object') {
return '';
}
const parts = [];
if ('errorCode' in data) parts.push(`errorCode=${data.errorCode}`);
if (data.msg || data.message) parts.push(`message=${data.msg || data.message}`);
if (data.result && data.errorCode && data.errorCode !== 0) {
parts.push(`result=${JSON.stringify(data.result)}`);
}
return parts.length ? ` ${parts.join(' ')}` : '';
}
function logHttpResult(method, url, res) {
const status = res?.status || 'NO_STATUS';
console.log(`[Omada HTTP] ${String(method).toUpperCase()} ${url} -> ${status}${summarizeOmadaResponse(res?.data)}`);
}
class OmadaClient {
constructor(config) {
this.config = config || {};
this.controllerUrl = trimSlash(config.controllerUrl);
this.omadacId = config.omadacId;
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.accessToken = config.accessToken || null;
this.authHeaderStyle = config.authHeaderStyle || 'access-token';
this.controllerId = config.controllerId || config.omadacId || null;
this.csrfToken = null;
this.sessionCookies = '';
this.http = axios.create({
baseURL: this.controllerUrl,
timeout: Number(config.timeoutMs || 30000),
httpsAgent: new https.Agent({
rejectUnauthorized: config.rejectUnauthorized === true,
}),
validateStatus: () => true,
});
}
async requestWithLog(method, url, options = {}) {
try {
const res = await this.http.request({
method,
url,
...options,
});
logHttpResult(method, url, res);
return res;
} catch (err) {
console.warn(`[Omada HTTP] ${String(method).toUpperCase()} ${url} -> ERROR ${err.message}`);
throw err;
}
}
requireOpenApiConfig() {
const missing = [];
if (!this.controllerUrl) missing.push('OMADA_URL');
if (!this.omadacId) missing.push('OMADA_OPENAPI_OMADAC_ID');
if (!this.clientId) missing.push('OMADA_OPENAPI_CLIENT_ID');
if (!this.clientSecret) missing.push('OMADA_OPENAPI_CLIENT_SECRET');
if (missing.length) {
throw new Error(`Missing required Omada OpenAPI config: ${missing.join(', ')}`);
}
}
async authorize() {
this.requireOpenApiConfig();
const body = {
omadacId: this.omadacId,
client_id: this.clientId,
client_secret: this.clientSecret,
};
const url = '/openapi/authorize/token?grant_type=client_credentials';
const res = await this.requestWithLog('POST', url, { data: body });
const data = this.unwrap(res, 'authorize');
const token = data.result?.accessToken || data.result?.token || data.accessToken || data.token;
if (!token) {
throw new Error(`Authorize succeeded but no access token was found: ${JSON.stringify(data)}`);
}
this.accessToken = token;
return data;
}
authHeaders(style = this.authHeaderStyle) {
if (!this.accessToken) return {};
if (style === 'bearer-access-token') {
return { Authorization: `Bearer AccessToken=${this.accessToken}` };
}
if (style === 'bearer') {
return { Authorization: `Bearer ${this.accessToken}` };
}
return { Authorization: `AccessToken=${this.accessToken}` };
}
openApiPath(pathPart) {
const suffix = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`;
return `/openapi/v1/${this.omadacId}${suffix}`;
}
async request(method, url, { body = null, query = null, headers = {}, retry = true } = {}) {
if (!this.accessToken) {
await this.authorize();
}
const fullUrl = `${url}${encodeQuery(query)}`;
const styles = [this.authHeaderStyle, 'bearer-access-token', 'bearer']
.filter((style, index, arr) => arr.indexOf(style) === index);
let lastError = null;
for (const style of styles) {
const res = await this.requestWithLog(method, fullUrl, {
headers: {
...this.authHeaders(style),
...headers,
},
data: body ?? undefined,
});
try {
const data = this.unwrap(res, `${method} ${fullUrl}`);
this.authHeaderStyle = style;
return data;
} catch (err) {
lastError = err;
if (retry && this.isExpiredToken(err)) {
this.accessToken = null;
await this.authorize();
return this.request(method, url, { body, query, headers, retry: false });
}
if (!this.isAuthHeaderCandidate(err)) {
throw err;
}
}
}
throw lastError;
}
async openApi(method, pathPart, options = {}) {
return this.request(method, this.openApiPath(pathPart), options);
}
async tryOpenApiCandidates(method, pathParts, options = {}) {
const errors = [];
for (const pathPart of pathParts) {
try {
const data = await this.openApi(method, pathPart, options);
return { path: this.openApiPath(pathPart), data };
} catch (err) {
errors.push({ path: this.openApiPath(pathPart), error: err });
if (!this.isCandidateRetryable(err)) {
throw err;
}
}
}
const lines = errors.map((item) => `- ${item.path}: ${item.error.message}`).join('\n');
const err = new Error(`All OpenAPI voucher endpoint candidates failed:\n${lines}`);
err.candidates = errors;
throw err;
}
async controllerLogin() {
if (!this.controllerId) {
const info = await this.requestWithLog('GET', '/api/info');
const data = this.unwrap(info, 'GET /api/info');
this.controllerId = data.result?.omadacId;
if (!this.controllerId) {
throw new Error(`Controller ID not found in /api/info: ${JSON.stringify(data)}`);
}
}
const username = this.config.controllerUsername || process.env.OMADA_USER;
const password = this.config.controllerPassword || process.env.OMADA_PASS;
if (!username || !password) {
throw new Error('Missing OMADA_USER/OMADA_PASS for Omada controller fallback');
}
const loginUrl = `/${this.controllerId}/api/v2/login`;
const res = await this.requestWithLog('POST', loginUrl, { data: { username, password } });
const data = this.unwrap(res, 'controller login');
this.csrfToken = data.result?.token;
this.sessionCookies = (res.headers['set-cookie'] || []).map((item) => item.split(';')[0]).join('; ');
return data;
}
async controllerRequest(method, pathPart, body = null, retried = false) {
if (!this.controllerId || !this.csrfToken) {
await this.controllerLogin();
}
const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`;
const url = `/${this.controllerId}/api/v2${path}`;
const res = await this.requestWithLog(method, url, {
headers: {
'Csrf-Token': this.csrfToken,
Cookie: this.sessionCookies,
'Content-Type': 'application/json',
},
data: body ?? undefined,
});
try {
return this.unwrap(res, `${method} controller ${path}`);
} catch (err) {
if (!retried && this.isExpiredToken(err)) {
await this.controllerLogin();
return this.controllerRequest(method, pathPart, body, true);
}
throw err;
}
}
async controllerUploadDataField(pathPart, data, retried = false) {
if (!this.controllerId || !this.csrfToken) {
await this.controllerLogin();
}
const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`;
const form = new FormData();
form.append('data', JSON.stringify(data));
const url = `/${this.controllerId}/api/v2${path}`;
const res = await this.requestWithLog('POST', url, {
headers: {
'Csrf-Token': this.csrfToken,
Cookie: this.sessionCookies,
'X-Requested-With': 'XMLHttpRequest',
refresh: 'manual',
},
data: form,
});
try {
return this.unwrap(res, `POST controller ${path}`);
} catch (err) {
if (!retried && this.isExpiredToken(err)) {
await this.controllerLogin();
return this.controllerUploadDataField(pathPart, data, true);
}
throw err;
}
}
async controllerUploadFileWithData(pathPart, filePath, data = {}, fileName = null, retried = false) {
if (!this.controllerId || !this.csrfToken) {
await this.controllerLogin();
}
const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`;
const form = new FormData();
form.append('data', JSON.stringify(data));
const bytes = fs.readFileSync(filePath);
const blob = new Blob([bytes], { type: 'text/html' });
form.append('file', blob, fileName || filePath.split(/[\\/]/).pop() || 'portal-redirect.html');
const url = `/${this.controllerId}/api/v2${path}`;
const res = await this.requestWithLog('POST', url, {
headers: {
'Csrf-Token': this.csrfToken,
Cookie: this.sessionCookies,
'X-Requested-With': 'XMLHttpRequest',
refresh: 'manual',
},
data: form,
});
try {
return this.unwrap(res, `POST controller ${path}`);
} catch (err) {
if (!retried && this.isExpiredToken(err)) {
await this.controllerLogin();
return this.controllerUploadFileWithData(pathPart, filePath, data, fileName, true);
}
throw err;
}
}
fileMd5(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
unwrap(res, label) {
const data = res.data;
if (res.status < 200 || res.status >= 300) {
const err = new Error(`${label} failed with HTTP ${res.status}: ${typeof data === 'string' ? data : JSON.stringify(data)}`);
err.response = res;
throw err;
}
if (data && typeof data === 'object' && 'errorCode' in data && data.errorCode !== 0) {
const err = new Error(data.msg || data.message || `${label} failed`);
err.omada = true;
err.errorCode = data.errorCode;
err.result = data.result;
throw err;
}
return data;
}
isExpiredToken(err) {
return err.response?.status === 401 || [-1000, -44106, -44112].includes(err.errorCode);
}
isAuthHeaderCandidate(err) {
return err.response?.status === 401 || [-44106, -44112, -44113].includes(err.errorCode);
}
isNotFound(err) {
return err.response?.status === 404 || err.errorCode === -404 || /not found/i.test(err.message);
}
isCandidateRetryable(err) {
return this.isNotFound(err) || err.errorCode === -1;
}
}
module.exports = {
OmadaClient,
formatOmadaError,
};