Compare commits

..

18 Commits

Author SHA1 Message Date
Alex Rennie-Lis
72f366f844 Updated 2025-06-12 15:09:19 +01:00
d5e17b7bef Update code/lib/handlers.js
Added Termination-Action response attribute.
2024-06-16 22:25:39 +01:00
b55e599ce2 Update code/lib/handlers.js
Fixed default vlan bug
2024-06-15 23:20:05 +01:00
0d8ea38b08 Update code/index.js
Change product name
2024-06-15 22:50:47 +01:00
c61d5b37eb Update code/index.js 2024-06-15 22:48:10 +01:00
b4556198c5 Update code/index.js
Changed default session duration to 600s
2024-06-15 22:47:20 +01:00
7855780f9c Update README.md 2024-06-15 22:46:06 +01:00
63d7704e0d Add docker-compose.yml 2024-06-08 13:07:02 +01:00
Alex Rennie-Lis
f051bed335 Multiple changes and documentation. 2024-06-08 11:11:20 +01:00
Alex Rennie-Lis
a3e0cc381b Added time rule flags 2024-06-06 23:41:46 +01:00
Alex Rennie-Lis
8265e89d69 Updated env names to sinatra 2024-06-06 23:15:04 +01:00
Alex Rennie-Lis
2c965a8447 Set default session duration 2024-06-06 23:11:09 +01:00
Alex Rennie-Lis
db8458122f Fixed workdir 2024-06-06 23:04:10 +01:00
Alex Rennie-Lis
da5d12342d Fixed container healthcheck 2024-05-28 16:46:39 +00:00
91dd8e2376 Update README.md 2024-05-27 23:14:29 +01:00
root
e9e774148b Fixed default API port to 8088 and fixed listening bug 2024-05-27 22:10:11 +00:00
Alex Rennie-Lis
3a58c19c44 Added session duration handling 2024-04-09 13:26:55 +01:00
Alex Rennie-Lis
2b7fbfdebd Issue 1, untested 2024-04-09 13:18:23 +01:00
79 changed files with 18185 additions and 72 deletions

6
Dockerfile Normal file → Executable file
View File

@@ -5,8 +5,10 @@ COPY ./config /config
RUN mkdir -p /data && \ RUN mkdir -p /data && \
apk --no-cache add curl apk --no-cache add curl
HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1 HEALTHCHECK CMD curl -f http://localhost:8088/health || exit 1
EXPOSE 8080 EXPOSE 8088
WORKDIR /data
CMD [ "node", "/app/index.js" ] CMD [ "node", "/app/index.js" ]

69
README.md Normal file → Executable file
View File

@@ -1,6 +1,6 @@
# Netradius # Sinatra
Network management RADIUS server SImple Network Access Tool with RAdius
RADIUS-based network access is common in prosumer/office networks for requiring pre-registered MAC adddresses and/or selectively assigning VLANs to devices based on MAC address. RADIUS-based network access is common in prosumer/office networks for requiring pre-registered MAC adddresses and/or selectively assigning VLANs to devices based on MAC address.
@@ -9,17 +9,70 @@ Such a setup _requires_ pre-registered MAC addresses, which can be difficult wit
Certain vendors' router hardware can provide simplistic RADIUS servers, e.g. a Ubiquiti Unifi Dream Machine, but they do not provide default VLAN assignment. It is also useful to be vendor agnostic. Certain vendors' router hardware can provide simplistic RADIUS servers, e.g. a Ubiquiti Unifi Dream Machine, but they do not provide default VLAN assignment. It is also useful to be vendor agnostic.
Netradius provides for this simple use case: Sinatra provides for this simple use case:
- A simple NodeJS-based RADIUS server that provides (MAC-based) authentication. - A simple NodeJS-based RADIUS server that provides (MAC-based) authentication, in any format.
- A simple REST API to add/update/delete reqistered users (MAC addresses). - A simple REST API to add/update/delete reqistered users (MAC addresses).
- A Dockerfile to encapsulate the server within a docker container. - A Dockerfile (and docker-compose) to encapsulate the server within a docker container.
- Optional default VLAN support to support unknown MAC addresses, e.g. into a guest network. - Optional default VLAN support to support unknown MAC addresses, e.g. into a guest network.
# Configuration
Optional configuration is via environment variables.
### SINATRA_PORT_RADIUS_AUTH
Sets the listening port for RADIUS authentication.
Default: 1812
### SINATRA_PORT_RADIUS_ACCT
Sets the listening port for RADIUS accounting
Default: 1813
### SINATRA_PORT_API
Sets the listening port for the API
Default: 8088
### SINATRA_STORAGE
Sets the storage type and location for data. The format is type://location.
Supported types are:
##### json
Uses a serialised JSON data file. e.g. json://./data
Default: json://./data
### SINATRA_CLIENT_SECRET
Sets the shared secret for RADIUS clients
Default: password
### SINATRA_DEFAULT_VLAN
Sets the default VLAN ID for unauthenticated users. If false, users must pass authentication.
Default: false
### SINATRA_MAC_AUTH_ONLY
Sets whether usernames and passwords should be processed as MAC addresses.
If true, then all input formats are normalised to lowercase alphanumeric strings, e.g. aabbccddeeff
Default: false
### SINATRA_SESSION_DURATION
Sets the RADIUS session duration in seconds.
Default: 600
### SINATRA_TIME_RULES
Sets whether time rules are to be processed.
If true, then all registered users must have at least one 'allow' rule defined.
Default: false
# Feature roadmap # Feature roadmap
- Mass-import from CSV - Mass-import from CSV
- Time-based authentication
- Time-limited access
- Connection accounting (with REST API endpoints for data access) - Connection accounting (with REST API endpoints for data access)

View File

@@ -1,11 +0,0 @@
{
"ports": {
"radius_authentication": 1812,
"radius_accounting": 1813
},
"client_secret": "password",
"storage": "json:./data.json",
"default_vlan_enabled": true,
"default_vlan_id": 90,
"mac_auth_only": true
}

5
code/data.json Normal file → Executable file
View File

@@ -9,11 +9,6 @@
"username": "AB:CD:EF:12:34:56", "username": "AB:CD:EF:12:34:56",
"password": "AB:CD:EF:12:34:56", "password": "AB:CD:EF:12:34:56",
"vlan": "123" "vlan": "123"
},
{
"username": "abcdef123456",
"password": "abcdef123456",
"vlan": "123"
} }
] ]
} }

48
code/index.js Normal file → Executable file
View File

@@ -1,6 +1,6 @@
// Baseline // Baseline
const product = 'NetRadius'; const product = 'Sinatra';
const version = '0.2.0'; const version = '0.4.1';
// Load dependencies // Load dependencies
const dgram = require ('dgram'); const dgram = require ('dgram');
@@ -13,38 +13,16 @@ const handlers = require ('./lib/handlers.js');
// Load configuration // Load configuration
log.write (product + ' v' + version); log.write (product + ' v' + version);
config = {}; config = { ports: {} };
try { config.ports.radius_authentication = process.env['SINATRA_PORT_RADIUS_AUTH'] || 1812;
config = JSON.parse (fs.readFileSync ('./config.json').toString ()); config.ports.radius_accounting = process.env['SINATRA_PORT_RADIUS_ACCT'] || 1813;
} config.ports.api = process.env['SINATRA_PORT_API'] || 8088;
catch (error) { config.storage = process.env['SINATRA_STORAGE'] || "json:./data.json";
log.write ('Cannot open or read configuration file.'); config.client_secret = process.env['SINATRA_CLIENT_SECRET'] || "password";
log.write ('Using defaults'); config.default_vlan = process.env['SINATRA_DEFAULT_VLAN'] || false;
config = { config.mac_auth_only = process.env['SINATRA_MAC_AUTH_ONLY'] || false;
ports: { config.session_duration = process.env['SINATRA_SESSION_DURATION'] || 600;
radius_authentication: 1812, config.time_rules = process.env['SINATRA_TIME_RULES'] || false;
radius_accounting: 1813,
api: 8080
},
storage: "json:./data.json",
client_secret: "password",
default_vlan_enabled: false,
mac_auth_only: false
}
}
if (process.env['NETRADIUS_PORT_RADIUS_AUTH']) config.ports.radius_authentication = process.env['NETRADIUS_PORT_RADIUS_AUTH'];
if (process.env['NETRADIUS_PORT_RADIUS_ACCT']) config.ports.radius_accounting = process.env['NETRADIUS_PORT_RADIUS_ACCT'];
if (process.env['NETRADIUS_PORT_API']) config.ports.api = process.env['NETRADIUS_PORT_API'];
if (process.env['NETRADIUS_STORAGE']) config.storage = process.env['NETRADIUS_STORAGE'];
if (process.env['NETRADIUS_DEFAULT_VLAN']) config.default_vlan_enabled = process.env['NETRADIUS_DEFAULT_VLAN'];
if (process.env['NETRADIUS_DEFAULT_VLAN_ID']) config.default_vlan_id = process.env['NETRADIUS_DEFAULT_VLAN_ID'];
if (process.env['NETRADIUS_CLIENT_SECRET']) config.client_secret = process.env['NETRADIUS_CLIENT_SECRET'];
if (process.env['NETRADIUS_MAC_AUTH_ONLY']) config.mac_auth_only = process.env['NETRADIUS_MAC_AUTH_ONLY'];
// Set defaults
if (!config.ports.radius_authentication) config.ports.radius_authentication = 1812;
if (!config.ports.radius_accounting) config.ports.radius_accounting = 1813;
if (!config.ports.api) config.ports.api = 8080;
// Display active configuration // Display active configuration
log.write ('Using configuration: ' + JSON.stringify (config)); log.write ('Using configuration: ' + JSON.stringify (config));
@@ -174,7 +152,7 @@ http.createServer (function (req, res) {
respond (res, "Not found", 404); respond (res, "Not found", 404);
} }
}).listen (8080); }).listen (config.ports.api);
log.write ("API listening on port " + config.ports.api); log.write ("API listening on port " + config.ports.api);
// Exit handles // Exit handles

20
code/lib/data.js Normal file → Executable file
View File

@@ -1,4 +1,5 @@
const fs = require ('fs'); const fs = require ('fs');
const timeauth = require ('./time.js');
try { try {
var data = JSON.parse (fs.readFileSync ('./data.json').toString ()); var data = JSON.parse (fs.readFileSync ('./data.json').toString ());
@@ -19,7 +20,9 @@ const persistData = () => {
content.users.push ({ content.users.push ({
username: user, username: user,
password: users[user].password, password: users[user].password,
vlan: users[user].vlan vlan: users[user].vlan,
description: users[user].description,
rules: users[user].rules
}); });
}); });
fs.writeFileSync ('./data.json', JSON.stringify (content, null, 2)); fs.writeFileSync ('./data.json', JSON.stringify (content, null, 2));
@@ -29,7 +32,9 @@ users = {};
data.users.forEach ((e) => { data.users.forEach ((e) => {
users[e.username] = { users[e.username] = {
password: e.password, password: e.password,
vlan: e.vlan vlan: e.vlan,
description: e.description,
rules: e.rules
} }
}); });
@@ -40,6 +45,9 @@ module.exports = {
password = password.toLowerCase ().replace (/[:-]/g, ''); password = password.toLowerCase ().replace (/[:-]/g, '');
} }
if (users[username] && users[username].password == password) { if (users[username] && users[username].password == password) {
// Check time
var rules = users[username].rules || [];
if (timeauth.checkAuth (rules)) {
return { return {
vlan: users[username].vlan vlan: users[username].vlan
}; };
@@ -47,6 +55,10 @@ module.exports = {
else { else {
return false; return false;
} }
}
else {
return false;
}
}, },
createUser: (payload, callback) => { createUser: (payload, callback) => {
@@ -59,11 +71,13 @@ module.exports = {
password = password.toLowerCase ().replace (/[:-]/g, ''); password = password.toLowerCase ().replace (/[:-]/g, '');
} }
var description = payload.description || ""; var description = payload.description || "";
var rules = payload.rules || [];
var vlan = payload.vlan; var vlan = payload.vlan;
users[username] = { users[username] = {
password: password, password: password,
vlan: vlan, vlan: vlan,
description: description description: description,
rules: rules
}; };
persistData (); persistData ();
callback ("OK\n\n", null); callback ("OK\n\n", null);

9
code/lib/handlers.js Normal file → Executable file
View File

@@ -18,17 +18,18 @@ module.exports = {
} }
var user = data.authUser (username, password); var user = data.authUser (username, password);
var vlan = false; var vlan = false;
var code = 'Access-Reject';
if (user) { if (user) {
log.write (username + " access granted to VLAN " + user.vlan); log.write (username + " access granted to VLAN " + user.vlan);
code = 'Access-Accept'; code = 'Access-Accept';
vlan = user.vlan; vlan = user.vlan;
} }
else { else {
if (config.default_vlan_enabled && config.default_vlan_id) { if (config.default_vlan) {
// Permit into default vlan if enabled // Permit into default vlan if enabled
log.write (username + " unknown. Placing into default VLAN."); log.write (username + " unknown. Placing into default VLAN.");
code = 'Access-Accept'; code = 'Access-Accept';
vlan = config.default_vlan_id; vlan = config.default_vlan;
} }
else { else {
log.write (username + " access denied."); log.write (username + " access denied.");
@@ -42,7 +43,9 @@ module.exports = {
attributes: { attributes: {
"Tunnel-Medium-Type": 6, "Tunnel-Medium-Type": 6,
"Tunnel-Type": 13, "Tunnel-Type": 13,
"Tunnel-Private-Group-Id": vlan "Tunnel-Private-Group-Id": vlan,
"Session-Timeout": config.session_duration || 60,
"Termination-Action": 1
} }
}); });
callback (response, null); callback (response, null);

0
code/lib/logger.js Normal file → Executable file
View File

85
code/lib/time.js Executable file
View File

@@ -0,0 +1,85 @@
const rangeStringToArray = (str) => {
// Split the string by hyphen
const parts = str.split ("-");
// Validate input format (two numbers separated by hyphen)
if (parts.length !== 2 || isNaN (parts[0]) || isNaN (parts[1])) {
return null; // Return null for invalid format
}
// Convert strings to numbers
const start = parseInt (parts[0], 10);
const end = parseInt (parts[1], 10);
// Check if start is less than or equal to end (inclusive range)
if (start > end) {
return null; // Return null for invalid range
}
// Use Array.from() to create an array with sequence
return Array.from ({ length: end - start + 1 }, (_, i) => start + i);
};
const resolveRange = (str) => {
const numberSets = str.split (","); // Split by commas
const numbers = [];
for (const set of numberSets) {
// Check if it's a range
if (set.includes ("-")) {
const range = rangeStringToArray (set); // Use the previous function
if (range) {
numbers.push(...range); // Spread operator to add elements from range array
}
else {
const num = parseInt(set, 10);
// Check if conversion is successful (valid number)
if (!isNaN(num)) {
numbers.push(num);
}
}
}
}
return numbers;
}
module.exports = {
checkAuth: (rules = []) => {
var authorised = false;
if (config.time_rules) {
if (rules.length > 0) {
var now = new Date ();
var minuteOfDay = (now.getHours () * 60) + now.getMinutes (); // 0 - 1439
var day = now.getDay (); // 1 - 7
var date = now.getDate (); // 1 - 31
var month = now.getMonth () + 1; // 1 - 12
var actions = [];
rules.forEach ((rule) => {
var valid = false;
// Process rule
var r = {
startMinute: parseInt (rule.startTime.split (":")[0] * 60) + parseInt (rule.startTime.split (":")[1]),
endMinute: parseInt (rule.endTime.split (":")[0] * 60) + parseInt (rule.endTime.split (":")[1]),
days: resolveRange (rule.weekdays),
dates: resolveRange (rule.dates),
months: resolveRange (rule.months)
}
if (
minuteOfDay >= r.startMinute &&
minuteOfDay <= r.endMinute &&
r.days.indexOf (day) !== -1 &&
r.dates.indexOf (date) !== -1 &&
r.months.indexOf (month) !== -1
) {
actions.push (rule.action.toLowerCase ());
}
});
if (actions.indexOf ("allow") !== -1) {
authorised = true;
}
if (actions.indexOf ("deny") !== -1) {
authorised = false;
}
}
}
else {
authorised = true;
}
return authorised;
}
}

0
code/node_modules/.package-lock.json generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/.npmignore generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/.travis.yml generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/LICENSE generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/README.md generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/accounting.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/decode.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc2865 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc2866 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc2867 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc2868 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc2869 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc3162 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc3576 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc3580 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4072 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4372 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4603 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4675 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4679 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4818 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc4849 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc5090 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc5176 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc5580 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc5607 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/dictionaries/dictionary.rfc5904 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/examples/auth_client.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/examples/auth_server.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/lib/radius.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/package.json generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/short_password.js generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/aruba_mac_auth.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/cisco_accounting.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/cisco_accounting_response.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/cisco_mac_auth.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/cisco_mac_auth_reject.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/eap_request.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/invalid_register.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/captures/motorola_accounting.packet generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.airespace generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.aruba generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.number_vendor_name generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.test1 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.test2 generated vendored Normal file → Executable file
View File

0
code/node_modules/radius/test/dictionaries/dictionary.test_tunnel_type generated vendored Normal file → Executable file
View File

0
code/package-lock.json generated Normal file → Executable file
View File

0
code/package.json Normal file → Executable file
View File

99
code/ui.html Executable file
View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management</title>
<style>
body { font-family: Arial, sans-serif; }
.container { width: 80%; margin: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.form-input { margin-bottom: 10px; }
label { display: block; }
input[type="text"], input[type="password"] { width: 100%; padding: 8px; }
button { padding: 8px 16px; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<h2>User Management</h2>
<div id="user-form">
<div class="form-input">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
</div>
<div class="form-input">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
<div class="form-input">
<label for="description">Description:</label>
<input type="text" id="description" name="description">
</div>
<button onclick="createUser()">Create User</button>
</div>
<table id="users-table">
<thead>
<tr>
<th>Username</th>
<th>Password</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- User entries will be dynamically inserted here -->
</tbody>
</table>
</div>
<script>
// Function to make AJAX call to the RESTful service
function ajaxCall(method, url, data, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
callback(JSON.parse(xhr.responseText));
}
};
xhr.send(JSON.stringify(data));
}
// Function to create a new user
function createUser() {
var username = document.getElementById('username').value;
var password = document.getElementById('password').value;
var description = document.getElementById('description').value;
var data = { username: username, password: password, description: description };
ajaxCall('POST', 'http://example.com/api/users', data, function(response) {
// Handle response
console.log(response);
});
}
// Function to edit a user
function editUser(userId) {
// Get user data from form
// Make AJAX call to update user
}
// Function to delete a user
function deleteUser(userId) {
// Make AJAX call to delete user
}
// Function to fetch and display users
function fetchUsers() {
// Make AJAX call to get users
// Populate users table
}
// Initial fetch of users
fetchUsers();
</script>
</body>
</html>

0
config/config.json Normal file → Executable file
View File

0
data.json Normal file → Executable file
View File

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
server:
image: sinatra:latest
container_name: sinatra
environment:
- TZ=Etc/UTC
#- SINATRA_PORT_RADIUS_AUTH=1812
#- SINATRA_PORT_RADIUS_ACCT=1813
#- SINATRA_PORT_API=8088
#- SINATRA_STORAGE=json://data.json
#- SINATRA_CLIENT_SECRET=password
#- SINATRA_DEFAULT_VLAN=false
#- SINATRA_MAC_AUTH_ONLY=false
#- SINATRA_SESSION_DURATION=60
#- SINATRA_TIME_RULES=false
volumes:
- data:/data
network_mode: host
restart: unless-stopped

23
ui/sinatra-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
ui/sinatra-ui/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

17542
ui/sinatra-ui/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

39
ui/sinatra-ui/package.json Executable file
View File

@@ -0,0 +1,39 @@
{
"name": "sinatra-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
ui/sinatra-ui/src/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

35
ui/sinatra-ui/src/App.js Executable file
View File

@@ -0,0 +1,35 @@
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
function Div2() {
return (
<div>
<p>
This is a test function.
</p>
</div>
);
}
export {App, Div2};

View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

18
ui/sinatra-ui/src/index.js Executable file
View File

@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {App, Div2} from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
<Div2 />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';