Compare commits

..

No commits in common. "main" and "pre-rewrite" have entirely different histories.

14 changed files with 1350 additions and 957 deletions

2
.gitignore vendored
View file

@ -132,5 +132,3 @@ dist
*.db *.db
sessions/* sessions/*
.DS_Store .DS_Store
test/*

453
index.js
View file

@ -20,24 +20,9 @@ const ejs = require("ejs")
const mariadb = require('mariadb'); const mariadb = require('mariadb');
const bcrypt = require("bcrypt") const bcrypt = require("bcrypt")
const crypto = require("crypto") const crypto = require("crypto")
const dns = require("dns");
const app = express(); const app = express();
const port = process.env.SERVER_PORT || 3000; const port = process.env.SERVER_PORT || 3000;
const invalidBlocks = [
// Emergency number prefixes (112, 911, 999, 110, 117, 119, 113, 191, 111)
1120000, // UK, EU, etc
9110000, // US, Canada
9880000, // Suicide prevention (US)
9990000,
1100000,
1170000,
1190000,
1130000,
1910000,
1110000
]
const pool = mariadb.createPool({ const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1', host: process.env.DB_HOST || '127.0.0.1',
port: process.env.DB_PORT || 3306, port: process.env.DB_PORT || 3306,
@ -234,78 +219,6 @@ app.post('/admin/login', (req, res) => {
}); });
}) })
app.get("/admin/register/:inviteCode", async (req, res) => {
const inviteCode = req.params.inviteCode;
if (!inviteCode) {
res.status(400).send('Bad Request');
return;
}
const inviteData = await pool.query("SELECT * FROM admin_invites WHERE code = ?", [inviteCode]);
if (!inviteData || inviteData.length === 0) {
res.status(400).send('Bad or Expired Invite Code');
return;
}
const invite = inviteData[0];
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
res.status(400).send('Invite Code Expired');
return;
}
if (invite.uses >= invite.maxUses) {
res.status(400).send('Invite Code Max Uses Reached');
return;
}
res.render('admin/register', { inviteCode });
});
app.post("/admin/register/:inviteCode", async (req, res) => {
const inviteCode = req.params.inviteCode;
if (!inviteCode) {
res.render('admin/register', { error: 'Bad Request' });
return;
}
const inviteData = await pool.query("SELECT * FROM admin_invites WHERE code = ?", [inviteCode]);
if (!inviteData || inviteData.length === 0) {
res.render('admin/register', { error: 'Bad or Expired Invite Code' });
return;
}
const invite = inviteData[0];
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
res.render('admin/register', { error: 'Invite Code Expired' });
return;
}
if (invite.uses >= invite.maxUses) {
res.render('admin/register', { error: 'Invite Code Max Uses Reached' });
return;
}
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
res.render('admin/register', { error: 'Username and Password are required', inviteCode });
return;
}
const existingUser = await pool.query("SELECT * FROM users WHERE username = ?", [String(username)]);
if (existingUser && existingUser.length > 0) {
res.render('admin/register', { error: 'Username already exists', inviteCode });
return;
}
bcrypt.hash(password, saltRounds).then((hash) => {
const newUser = pool.query("INSERT INTO users (username, passwordHash) VALUES (?, ?)",
[String(username), hash]);
const updateInvite = pool.query("UPDATE admin_invites SET uses = uses + 1 WHERE code = ?", [inviteCode]);
Promise.all([newUser, updateInvite]).then(() => {
res.redirect('/admin/login');
}).catch(err => {
console.error('Error creating user:', err);
res.render('admin/register', { error: 'Internal server error', inviteCode });
});
}).catch(err => {
console.error('Error hashing password:', err);
res.render('admin/register', { error: 'Internal server error', inviteCode });
});
});
app.get('/api/v1/admin/routes', (req, res) => { // Get all routes app.get('/api/v1/admin/routes', (req, res) => { // Get all routes
if (!req.session.adminAuthenticated) { if (!req.session.adminAuthenticated) {
res.status(401).json({ error: 'Unauthorized' }); res.status(401).json({ error: 'Unauthorized' });
@ -590,32 +503,6 @@ app.put('/api/v1/user/route', (req, res) => { // Update route
}); });
}); });
app.patch('/api/v1/user/update', async (req, res) => { // Update users server, port, auth, or secret via API key instead of session. Used for automated scripts
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
if (!apiKey) {
res.status(401).json({ error: 'API Key is required!' });
return;
}
const oldData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
if (!oldData || oldData.length === 0) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const row = oldData[0];
const server = req.body.server || row.server;
const port = req.body.port || row.port;
const auth = req.body.auth || row.auth;
const secret = req.body.secret || row.secret;
const updateData = await pool.query('UPDATE routes SET server = ?, port = ?, auth = ?, secret = ? WHERE apiKey = ?',
[server, port, auth, secret, apiKey]);
if (updateData.affectedRows === 1) {
res.json({ message: 'Updated' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/api/v1/user/directory', (req, res) => { // Get directory entries created by user app.get('/api/v1/user/directory', (req, res) => { // Get directory entries created by user
if (!req.session.userAuthenticated) { if (!req.session.userAuthenticated) {
res.status(401).json({ error: 'Unauthorized' }); res.status(401).json({ error: 'Unauthorized' });
@ -708,153 +595,6 @@ app.delete('/api/v1/user/directory/:number', (req, res) => { // Delete a directo
}); });
}); });
// User directory management via API key, for automated scripts
app.post('/api/v1/user/dir/newEntry', async (req, res) => {
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
if (!apiKey) {
res.status(401).json({ error: 'API Key is required!' });
return;
}
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
if (!routeData || routeData.length === 0) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const route = routeData[0];
var number = Number(req.body.number);
var name = String(req.body.name);
if (!number || !name) {
res.status(400).json({ error: 'Bad Request' });
return;
}
if (number < route.block_start || number > route.block_start + route.block_length) {
res.status(403).json({ error: 'Forbidden' });
return;
}
// Remove html
name = require("escape-html")(name);
// If number already exists, update, otherwise insert
pool.query('SELECT * FROM directory WHERE number = ? AND route = ?', [number, route.id]).then((rows) => {
const row = rows[0];
if (row) {
pool.query('UPDATE directory SET name = ? WHERE number = ? AND route = ?',
[name, number, route.id]).then(() => {
res.json({ message: 'Updated' });
}
).catch(err => {
console.error('Error updating directory entry:', err);
res.status(500).json({ error: 'Internal server error' });
});
} else {
pool.query('INSERT INTO directory (number, name, route) VALUES (?, ?, ?)',
[number, name, route.id]).then(() => {
res.status(201).json({ message: 'Created' });
}
).catch(err => {
console.error('Error creating directory entry:', err);
res.status(500).json({ error: 'Internal server error' });
});
}
}).catch(err => {
console.error('Error checking for existing directory entry:', err);
res.status(500).json({ error: 'Internal server error' });
});
});
app.delete('/api/v1/user/dir/deleteEntry/:number', async (req, res) => {
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
if (!apiKey) {
res.status(401).json({ error: 'API Key is required!' });
return;
}
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
if (!routeData || routeData.length === 0) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const route = routeData[0];
const number = Number(req.params.number);
if (!number) {
res.status(400).json({ error: 'Bad Request' });
return;
}
// Check that the number is within the block range for the current user
if (number < route.block_start || number > route.block_start + route.block_length) {
res.status(403).json({ error: 'Forbidden' });
return;
}
pool.query('DELETE FROM directory WHERE number = ? AND route = ?', [number, route.id]).then(() => {
res.status(200).json({ message: 'Deleted' });
}).catch(err => {
console.error('Error deleting directory entry:', err);
res.status(500).json({ error: 'Internal server error' });
});
});
// User directory endpoint to mass update entries, with boolean 'replace' field to indicate if existing entries should be replaced with the new list.
app.post('/api/v1/user/dir/massUpdate', async (req, res) => {
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
if (!apiKey) {
res.status(401).json({ error: 'API Key is required!' });
return;
}
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
if (!routeData || routeData.length === 0) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
// Validate all entries, make sure we check that each number is within the block range for the current user
const route = routeData[0];
const entries = req.body.entries;
const replace = req.body.replace || false;
if (!Array.isArray(entries)) {
res.status(400).json({ error: 'Bad Request. Not array' });
return;
}
for (const entry of entries) {
const number = Number(entry.number);
const name = String(entry.name);
if (!number || !name) {
res.status(400).json({ error: 'Bad Request. Number Or Name' });
return;
}
if (number < route.block_start || number > route.block_start + route.block_length) {
res.status(403).json({ error: 'Forbidden' });
return;
}
}
if (replace) {
// Delete all existing entries for this route
await pool.query('DELETE FROM directory WHERE route = ?', [route.id]);
}
// Insert or update entries
for (const entry of entries) {
const number = Number(entry.number);
const name = String(entry.name);
// Remove html
const safeName = require("escape-html")(name);
// If number already exists, update, otherwise insert
const existingEntries = await pool.query('SELECT * FROM directory WHERE number = ? AND route = ?', [number, route.id]);
const row = existingEntries[0];
if (row) {
await pool.query('UPDATE directory SET name = ? WHERE number = ? AND route = ?',
[safeName, number, route.id]);
} else {
await pool.query('INSERT INTO directory (number, name, route) VALUES (?, ?, ?)',
[number, safeName, route.id]);
}
}
res.json({ message: 'Mass update completed' });
});
// == END USER ROUTES == // == END USER ROUTES ==
// == Directory routes == (unauthenticated) // == Directory routes == (unauthenticated)
@ -872,72 +612,6 @@ app.get("/api/v1/directory", (req, res) => {
}); });
}); });
// Function to find open number blocks
app.get("/api/v1/directory/openBlocks", (req, res) => {
pool.query("SELECT block_start, block_length FROM routes").then((rows) => {
console.log(JSON.stringify(rows)); // for testing
const takenBlocks = rows.map(row => {
return { start: row.block_start, end: row.block_start + row.block_length };
});
const openBlocks = [];
for (let i = 1000000; i <= 9999999; i += 10000) {
const blockStart = i;
const blockEnd = i + 9999;
// Check if block is invalid
if (invalidBlocks.includes(blockStart)) {
continue;
}
// Check if block overlaps with any taken blocks
const overlap = takenBlocks.some(taken => {
return (blockStart <= taken.end && blockEnd >= taken.start);
});
if (!overlap) {
openBlocks.push(blockStart);
}
}
res.json(openBlocks);
}).catch(err => {
console.error('Error getting open blocks:', err);
res.status(500).json({ error: 'Internal server error' });
});
});
app.get("/api/v1/provision/:apiKey", async (req, res) => {
const apiKey = req.params.apiKey;
if (!apiKey) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const serverData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
if (!serverData || serverData.length === 0) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const server = serverData[0];
// Do a quick DNS lookup on server.server to see if it matches the IP making the request. If it doesn't, add 'warning': "IP Mismatch. Are you running this on the right server?" to the response but continue to send all data
dns.lookup(server.server, (err, address, family) => {
if (err) {
console.error('Error looking up DNS:', err);
return res.status(500).json({ error: 'Internal server error' });
}
const responseData = {
server: server.server, // Informational
port: server.port, // Informational
inbound_context: server.auth, // IAX2 username and context
iax_secret: server.secret, // IAX2 password
block: server.block_start, // Used for generating context
api_key: server.apiKey // Used for authentication
};
if (address !== req.ip && address !== req.headers['x-forwarded-for']) {
responseData.warning = "IP Mismatch. Are you running this on the right server?";
}
res.json(responseData);
});
});
// Other public endpoints that need special handling // Other public endpoints that need special handling
discordInviteCache = { time: 0, url: "" }; discordInviteCache = { time: 0, url: "" };
@ -992,11 +666,9 @@ app.get("/footer", (req, res) => {
app.get("/api/v1/checkAvailability/:number", (req, res) => { app.get("/api/v1/checkAvailability/:number", (req, res) => {
// Check if the number is 7 digits // Check if the number is 7 digits
let number = Number(req.params.number); const number = Number(req.params.number);
// Round to nearest 10000 so it's always NXX0000 if (number < 2000000 || number > 9999999) {
number = Math.floor(number / 10000) * 10000; res.status(400).json({ error: `Number is outside valid range` });
if (!number || number < 1000000 || number > 9999999 || invalidBlocks.includes(number)) {
res.status(400).json({ error: `Number is outside valid range or is an invalid block` });
return; return;
} }
pool.getConnection().then(conn => { pool.getConnection().then(conn => {
@ -1042,73 +714,38 @@ const logCall = (caller, callee) => {
}); });
} }
const genCall = (req, res, apiKey, ani, number) => { // Query to get a route
app.get('/api/v1/route/:apiKey/:ani/:number', (req, res) => {
const apiKey = req.params.apiKey;
const number = Number(req.params.number);
const ani = Number(req.params.ani);
pool.getConnection().then(conn => { pool.getConnection().then(conn => {
//conn.query("SELECT * FROM routes WHERE apiKey = ? AND block_start <= ? AND block_start + block_length >= ?", [apiKey, ani, ani]).then((rows) => { //conn.query("SELECT * FROM routes WHERE apiKey = ? AND block_start <= ? AND block_start + block_length >= ?", [apiKey, ani, ani]).then((rows) => {
conn.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]).then((rows) => { // We'll try this Nick, if it doesn't work we'll go back to the original conn.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]).then((rows) => { // We'll try this Nick, if it doesn't work we'll go back to the original
if (process.env.DEBUG_MODE === "true") {
console.log(`API Key: ${apiKey}, ANI: ${ani}, Number: ${number}`);
console.log(`Found ${rows.length} routes for API Key`);
console.log(JSON.stringify(rows));
}
const row = rows[0]; const row = rows[0];
// If no row or error, return 401 // If no row or error, return 401
if (!row) { if (!row) {
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`) res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
return; return;
} }
// Validate the ani and number are 7 digit numbers
if (!ani || ani < 1000000 || ani > 9999999 || !number || number < 1000000 || number > 9999999) {
res.status(400).send(`${process.env.MSG_ROUTE_ADDRESS}/400`);
return;
}
// Validate the ani is owned by the apiKey
if (ani < row.block_start || ani > row.block_start + row.block_length) {
res.status(403).send(`${process.env.MSG_ROUTE_ADDRESS}/403`);
return;
}
conn.query('SELECT * FROM routes WHERE block_start <= ? AND block_start + block_length >= ?', [number, number]).then((rows) => { conn.query('SELECT * FROM routes WHERE block_start <= ? AND block_start + block_length >= ?', [number, number]).then((rows) => {
const row = rows[0]; const row = rows[0];
if (row) {
// Check blocklist. Type 1 is exact match, Type 2 is prefix match NNNXXXX where NNN is the prefix value. // Check if the ANI is within the block range
// Check if the ANI is blocked from calling this route // If it is, return `local`
const routeId = row ? row.id : null; console.log(`New Call: ${ani} -> ${number}`);
if (!routeId) { logCall(ani, number);
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`); // incriment estCallsMade analytics
return; addAnalytic("estCallsMade");
} dailyAnalytic("dailyCallsMade");
if (ani >= row.block_start && ani <= row.block_start + row.block_length) {
conn.query('SELECT * FROM blocklist WHERE (blockType = 1 AND blockValue = ?) OR (blockType = 2 AND ? BETWEEN blockValue AND blockValue + ?);', [ani, ani, row.block_length]).then((blockRows) => { res.status(200).send('local');
if (blockRows.length > 0) {
// ANI is blocked from calling this route
console.log(`Blocked Call Attempt: ${ani} -> ${number}`);
res.status(403).send(`${process.env.MSG_ROUTE_ADDRESS}/403`);
return;
}
if (row) {
// Check if the ANI is within the block range
// If it is, return `local`
console.log(`New Call: ${ani} -> ${number}`);
logCall(ani, number);
// incriment estCallsMade analytics
addAnalytic("estCallsMade");
dailyAnalytic("dailyCallsMade");
if (ani >= row.block_start && ani <= row.block_start + row.block_length) {
res.status(200).send('local');
} else {
res.status(200).send(`IAX2/${row.auth}:${row.secret}@${row.server}:${row.port}/${number}`);
}
} else { } else {
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`); res.status(200).send(`IAX2/${row.auth}:${row.secret}@${row.server}:${row.port}/${number}`);
} }
}).catch(err => { } else {
console.error('Error checking blocklist:', err); res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`);
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`); }
return;
});
}).catch(err => { }).catch(err => {
console.error('Error getting route:', err); console.error('Error getting route:', err);
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`) res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`)
@ -1120,22 +757,48 @@ const genCall = (req, res, apiKey, ani, number) => {
conn.release(); conn.release();
}); });
}); });
}
// Query to get a route
app.get('/api/v1/route/:apiKey/:ani/:number', (req, res) => {
const apiKey = req.params.apiKey;
const number = Number(req.params.number);
const ani = Number(req.params.ani);
genCall(req, res, apiKey, ani, number);
}); });
app.get('/api/v1', (req, res) => { // Backwards compatibility with TandmX cause why not, it's easy app.get('/api/v1', (req, res) => { // Backwards compatibility with TandmX cause why not, it's easy
const apiKey = req.query.auth; const apiKey = req.query.auth;
const number = Number(req.query.number); const number = Number(req.query.number);
const ani = Number(req.query.ani); const ani = Number(req.query.ani);
genCall(req, res, apiKey, ani, number); pool.getConnection().then(conn => {
conn.query("SELECT * FROM routes WHERE apiKey = ? AND block_start <= ? AND block_start + block_length >= ?", [apiKey, ani, ani]).then((rows) => {
const row = rows[0];
// If no row or error, return 401
if (!row) {
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
return;
}
conn.query('SELECT * FROM routes WHERE block_start <= ? AND block_start + block_length >= ?', [number, number]).then((rows) => {
const row = rows[0];
if (row) {
// Check if the ANI is within the block range
// If it is, return `local`
console.log(`New Call: ${ani} -> ${number}`);
logCall(ani, number);
addAnalytic("estCallsMade");
dailyAnalytic("dailyCallsMade");
if (ani >= row.block_start && ani <= row.block_start + row.block_length) {
res.status(200).send('local');
} else {
res.status(200).send(`IAX2/${row.auth}:${row.secret}@${row.server}:${row.port}/${number}`);
}
} else {
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`);
}
}).catch(err => {
console.error('Error getting route:', err);
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`)
});
}).catch(err => {
console.error(err);
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
}).finally(() => {
conn.release();
});
});
}); });
// Management Routes (Like restarting the server) // Management Routes (Like restarting the server)

View file

@ -59,7 +59,7 @@ function runMigrations(pool) {
resolve(); resolve();
}) })
.catch(err => { .catch(err => {
console.error('Error running migrations:', err); console.errorr('Error running migrations:', err);
reject(err); reject(err);
}) })
.finally(() => { .finally(() => {

View file

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS admin_invites (
code VARCHAR(36) PRIMARY KEY NOT NULL DEFAULT (UUID()),
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
maxUses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
expiresAt TIMESTAMP,
createdBy INTEGER,
FOREIGN KEY (createdBy) REFERENCES users(id) ON DELETE SET NULL
);

View file

@ -1,8 +0,0 @@
CREATE TABLE IF NOT EXISTS blocklist (
id INT AUTO_INCREMENT PRIMARY KEY,
ownerId INT NOT NULL,
blockType INT NOT NULL,
blockValue VARCHAR(255) NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ownerId) REFERENCES routes(id) ON DELETE CASCADE
);

1464
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,14 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"dotenv": "^16.6.1", "connect-sqlite": "^0.0.1",
"dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"mariadb": "^3.4.0", "mariadb": "^3.4.0",
"session-file-store": "^1.5.0" "session-file-store": "^1.5.0",
"sqlite3": "^5.1.7"
} }
} }

View file

@ -6,22 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/css/bootstrap.min.css"> <link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<title>AstroCom Directory</title> <title>AstroCom Directory</title>
<script>
(function (d, t) {
var BASE_URL = "https://support.chrischro.me";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
baseUrl: BASE_URL
})
}
})(document, "script");
</script>
</head> </head>
<body class="bg-dark text-white"> <body class="bg-dark text-white">

View file

@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<title>AstroCom API Docs</title>
<script>
(function (d, t) {
var BASE_URL = "https://support.chrischro.me";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
baseUrl: BASE_URL
})
}
})(document, "script");
</script>
<style>
.doc-section { max-width: 900px; margin: 1.5rem auto; }
.endpoint { background: rgba(255,255,255,0.03); padding: 1rem; border-radius: .4rem; margin-bottom: .75rem; }
.code { background:#0d1117; color:#9ad8ff; padding:.5rem; border-radius:.25rem; font-family:monospace; white-space:pre-wrap; }
.small-muted { color: rgba(255,255,255,0.6); font-size:.9rem; }
</style>
</head>
<body class="bg-dark text-white">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">AstroCom</a>
<span id="footer"></span>
</div>
<div class="ms-auto d-flex text-nowrap">
<a href="/user" class="btn btn-outline-light me-2">User Login</a>
<a href="/admin" class="btn btn-outline-light">Admin Login</a>
</div>
</nav>
<div class="container doc-section">
<h2 class="mb-1">API Documentation</h2>
<p class="small-muted">This page lists only endpoints that are fully unauthenticated or accept an API key via Bearer token.</p>
<h4 class="mt-4">Unauthenticated (public) endpoints</h4>
<div class="endpoint">
<h5>GET /api/v1/directory</h5>
<p class="small-muted">Returns all directory entries.</p>
<div class="mb-2"><strong>Request</strong></div>
<div class="code">curl -s -X GET https://astrocom.tel/api/v1/directory</div>
<div class="mb-2 mt-2"><strong>Response (200)</strong></div>
<div class="code">[{"id":1,"number":4472000,"name":"Example","route":2}, ...]</div>
</div>
<div class="endpoint">
<h5>GET /api/v1/directory/openBlocks</h5>
<p class="small-muted">Returns a list of available 10k blocks (block start numbers).</p>
<div class="code">curl -s https://astrocom.tel/api/v1/directory/openBlocks</div>
<div class="code">[1000000,1010000, ...]</div>
</div>
<div class="endpoint">
<h5>GET /api/v1/checkAvailability/:number</h5>
<p class="small-muted">Checks availability for a 7-digit number (rounded to NXX0000). Returns available: true/false.</p>
<div class="code">curl -s https://astrocom.tel/api/v1/checkAvailability/4472001</div>
<div class="code">{"available":true}</div>
</div>
<div class="endpoint">
<h5>GET /api/analytics</h5>
<p class="small-muted">Public analytics (total and daily counts).</p>
<div class="code">curl -s https://astrocom.tel/api/analytics</div>
<div class="code">{"total":[{"tag":"apiCalls","count":123}], "daily":[{"tag":"apiCalls","tag_date":"2025-10-27","count":10}]}</div>
</div>
<div class="endpoint">
<h5>GET /discord</h5>
<p class="small-muted">Redirects to the configured Discord invite (server-side fetch from WIDGET_URL).</p>
<div class="code">curl -i https://astrocom.tel/discord</div>
</div>
<div class="endpoint">
<h5>GET /api/v1/provision/:apiKey</h5>
<p class="small-muted">Provisioning info for a route identified by API key. Returns server/port/iax creds and block.</p>
<div class="code">curl -s https://astrocom.tel/api/v1/provision/REPLACE_API_KEY</div>
<div class="code">{
"server":"iax.example.net",
"port":4569,
"inbound_context":"from-astrocom",
"iax_secret":"...secret...",
"block":4470000,
"api_key":"REPLACE_API_KEY"
}</div>
<p class="small-muted">Response may include "warning" if DNS IP doesn't match requester.</p>
</div>
<div class="endpoint">
<h5>GET /api/v1/route/:apiKey/:ani/:number</h5>
<p class="small-muted">Primary routing endpoint. Returns "local" or an IAX2 dial string for the callee.</p>
<div class="code">curl -s https://astrocom.tel/api/v1/route/REPLACE_API_KEY/4472001/4473005</div>
<div class="code">local
-- or --
IAX2/from-astrocom:secret@iax.example.net:4569/4473005</div>
<p class="small-muted">Also available as legacy query form:</p>
<div class="code">GET /api/v1?auth=APIKEY&ani=4472001&number=4473005</div>
</div>
<hr class="border-secondary">
<h4 class="mt-4">Bearer token endpoints (Authorization: Bearer &lt;API_KEY&gt;)</h4>
<div class="endpoint">
<h5>PATCH /api/v1/user/update</h5>
<p class="small-muted">Update server/port/auth/secret for the route identified by Bearer API key (used by automated scripts).</p>
<div class="code">curl -s -X PATCH \
-H "Authorization: Bearer REPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"server":"iax.example.net","port":4569,"auth":"from-astrocom","secret":"new-secret"}' \
https://astrocom.tel/api/v1/user/update</div>
<div class="code">{"message":"Updated"}</div>
</div>
<div class="endpoint">
<h5>POST /api/v1/user/dir/newEntry</h5>
<p class="small-muted">Create or update a single directory entry for the route belonging to the API key.</p>
<div class="code">curl -s -X POST \
-H "Authorization: Bearer REPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"number":4472005,"name":"Alice"}' \
https://astrocom.tel/api/v1/user/dir/newEntry</div>
<div class="code">{"message":"Created"} or {"message":"Updated"}</div>
</div>
<div class="endpoint">
<h5>DELETE /api/v1/user/dir/deleteEntry/:number</h5>
<p class="small-muted">Delete a directory entry owned by the API key's route.</p>
<div class="code">curl -s -X DELETE -H "Authorization: Bearer REPLACE_API_KEY" https://astrocom.tel/api/v1/user/dir/deleteEntry/4472005</div>
<div class="code">{"message":"Deleted"}</div>
</div>
<div class="endpoint">
<h5>POST /api/v1/user/dir/massUpdate</h5>
<p class="small-muted">Mass-insert/update directory entries. Body must be {"entries":[{number,name},...],"replace":true|false}.</p>
<div class="code">curl -s -X POST \
-H "Authorization: Bearer REPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"replace":false,"entries":[{"number":4472001,"name":"Bob"},{"number":4472002,"name":"Carol"}]}' \
https://astrocom.tel/api/v1/user/dir/massUpdate</div>
<div class="code">{"message":"Mass update completed"}</div>
<p class="small-muted">All numbers must be within the route's block range.</p>
</div>
<hr class="border-secondary">
<p class="small-muted">Notes:</p>
<ul class="small-muted">
<li>Bearer endpoints accept header Authorization: Bearer &lt;API_KEY&gt;.</li>
<li>Unauthenticated endpoints that accept an API key in the path/query do not require Authorization header.</li>
<li>All numeric "number" and "ani" values must be 7-digit integers (1,000,0009,999,999) where applicable.</li>
</ul>
</div>
<script src="/assets/js/directory.js"></script>
<script src="/assets/js/bootstrap.min.js"></script>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/jquery.min.js"></script>
<script>
$(function() {
$("#footer").load("/footer");
});
</script>
</body>
</html>

View file

@ -20,22 +20,6 @@
<script type="application/ld+json"> <script type="application/ld+json">
{"name":"AstroCom","description":"Simplifying communication.","@type":"WebSite","url":"https://astrocom.tel/","headline":"AstroCom","@context":"http://schema.org"} {"name":"AstroCom","description":"Simplifying communication.","@type":"WebSite","url":"https://astrocom.tel/","headline":"AstroCom","@context":"http://schema.org"}
</script> </script>
<script>
(function (d, t) {
var BASE_URL = "https://support.chrischro.me";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
baseUrl: BASE_URL
})
}
})(document, "script");
</script>
</head> </head>
<body> <body>
@ -52,8 +36,6 @@
<div class="links"> <div class="links">
<a href="/about">About (WIP)</a><span> </span> <a href="/about">About (WIP)</a><span> </span>
<a href="/directory">Directory</a><span> </span> <a href="/directory">Directory</a><span> </span>
<a href="/validator">Block Availability</a> <span> </span>
<a href="/status" class="disabled" aria-disabled="true" tabindex="-1" style="pointer-events: none; opacity: 0.5;">Status (WIP)</a><span> </span>
<a href="/discord">Discord Server</a> <a href="/discord">Discord Server</a>
</div> </div>
</div> </div>

View file

@ -6,22 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/css/bootstrap.min.css"> <link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<title>AstroCom Availability Checker</title> <title>AstroCom Availability Checker</title>
<script>
(function (d, t) {
var BASE_URL = "https://support.chrischro.me";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
baseUrl: BASE_URL
})
}
})(document, "script");
</script>
</head> </head>
<body class="bg-dark text-white"> <body class="bg-dark text-white">
@ -50,47 +34,6 @@
<button type="submit" class="btn btn-primary mt-3">Submit</button> <button type="submit" class="btn btn-primary mt-3">Submit</button>
</form> </form>
</div> </div>
<div class="container mt-4" style="max-width: 400px;">
<h4>Available Blocks</h4>
<table class="table table-dark table-bordered">
<thead>
<tr>
<th id="availHeader" scope="col">Available Blocks</th>
</tr>
</thead>
<tbody id="availableBlocksTable">
<tr><td>Loading...</td></tr>
</tbody>
</table>
</div>
<script>
async function loadAvailableBlocks() {
const tableBody = document.getElementById('availableBlocksTable');
try {
const res = await fetch('/api/v1/directory/openBlocks');
const blocks = await res.json();
tableBody.innerHTML = '';
if (Array.isArray(blocks) && blocks.length > 0) {
blocks.forEach(block => {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.textContent = block;
row.appendChild(cell);
tableBody.appendChild(row);
});
// Set header text to "Available Blocks (X total)" where X is the number of available blocks
document.getElementById('availHeader').textContent = `${blocks.length} Available Blocks`;
} else {
tableBody.innerHTML = '<tr><td>No blocks available</td></tr>';
}
} catch {
tableBody.innerHTML = '<tr><td>Error loading blocks</td></tr>';
}
}
loadAvailableBlocks();
</script>
<script> <script>
document.querySelector('form').addEventListener('submit', async (e) => { document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();

View file

@ -1,19 +0,0 @@
#!/bin/bash
# AstroCom Dynamic IP Update Script
# Gets current public IP from https://myip.wtf/text and posts it to the AstroCom API
# Requires: curl
# Configuration
API_KEY="Your ASTROCOM API Key" # Replace with your AstroCom API Key!
# Get current IP
CURRENT_IP=$(curl -s https://myip.wtf/text)
if [[ -z "$CURRENT_IP" ]]; then
echo "Failed to retrieve current IP address."
exit 1
fi
echo "Current IP: $CURRENT_IP"
# Update IP via AstroCom API PATCH https://astrocom.tel/api/v1/user/update; JSON body: {"server": "current_ip"}
curl -s -X PATCH https://astrocom.tel/api/v1/user/update -H "Content-Type: application/json" -H "Authorization: Bearer $API_KEY" -d "{\"server\": \"$CURRENT_IP\"}"

View file

@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<title>AstroCom Admin Registration</title>
</head>
<body class="bg-dark">
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4">
<div class="card bg-dark text-light shadow">
<div class="card-body p-4">
<h2 class="text-center mb-4">Admin Registration</h2>
<% if (typeof notice !== 'undefined') { %>
<div class="alert alert-info text-center mb-3"><%= notice %></div>
<% } %>
<% if (typeof info !== 'undefined') { %>
<div class="alert alert-primary text-center mb-3"><%= info %></div>
<% } %>
<% if (typeof warn !== 'undefined') { %>
<div class="alert alert-warning text-center mb-3"><%= warn %></div>
<% } %>
<% if (typeof error !== 'undefined') { %>
<div class="alert alert-danger text-center mb-3"><%= error %></div>
<% } %>
<form action="#" method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username:</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password:</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Register</button>
</form>
</div>
</div>
</div>
<div id="footer" class="text-light mt-5"></div>
</div>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/jquery.min.js"></script>
<script>
$(function() {
$("#footer").load("/footer");
});
</script>
</body>
</html>

View file

@ -3,19 +3,3 @@
<span>&copy; <%= new Date().getFullYear() %> AstroCom <%= version %></span> <span>&copy; <%= new Date().getFullYear() %> AstroCom <%= version %></span>
</div> </div>
</footer> </footer>
<script>
(function (d, t) {
var BASE_URL = "https://support.chrischro.me";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
baseUrl: BASE_URL
})
}
})(document, "script");
</script>