initial commit

This commit is contained in:
Ludwig Lehnert
2026-02-03 16:39:37 +01:00
commit 70fe6076a4
30 changed files with 2128 additions and 0 deletions

22
webui/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates sqlite3 \
&& rm -rf /var/lib/apt/lists/*
COPY package.json /app/package.json
RUN npm install --omit=dev
COPY src /app/src
COPY views /app/views
COPY public /app/public
COPY migrations /app/migrations
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
owner_upn TEXT NOT NULL,
created_at TEXT NOT NULL,
state TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT,
upn TEXT,
UNIQUE(type, name, upn)
);
CREATE TABLE IF NOT EXISTS memberships (
share_id INTEGER NOT NULL,
principal_id INTEGER NOT NULL,
role TEXT NOT NULL,
PRIMARY KEY (share_id, principal_id),
FOREIGN KEY (share_id) REFERENCES shares(id),
FOREIGN KEY (principal_id) REFERENCES principals(id)
);
CREATE TABLE IF NOT EXISTS group_members (
group_id INTEGER NOT NULL,
user_upn TEXT NOT NULL,
PRIMARY KEY (group_id, user_upn),
FOREIGN KEY (group_id) REFERENCES principals(id)
);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at TEXT NOT NULL,
user_upn TEXT NOT NULL,
action TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER,
share_id INTEGER,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_access_logs_occurred_at ON access_logs (occurred_at);
CREATE INDEX IF NOT EXISTS idx_access_logs_user ON access_logs (user_upn);
CREATE INDEX IF NOT EXISTS idx_access_logs_action ON access_logs (action);

18
webui/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "aad-files-webui",
"version": "1.0.0",
"private": true,
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.5.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-session": "^1.18.1",
"openid-client": "^6.3.3"
}
}

270
webui/public/styles.css Normal file
View File

@@ -0,0 +1,270 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap');
:root {
color-scheme: light;
--bg: #f5f2ea;
--bg-accent: #e8e1d3;
--ink: #1e1b16;
--muted: #5b5447;
--primary: #d26b2f;
--primary-dark: #b75522;
--danger: #a11f2c;
--panel: #fff9f0;
--shadow: 0 24px 45px rgba(34, 31, 26, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
background: radial-gradient(circle at top, #ffffff 0%, var(--bg) 45%, var(--bg-accent) 100%);
color: var(--ink);
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 36px;
border-bottom: 1px solid rgba(30, 27, 22, 0.1);
backdrop-filter: blur(10px);
}
.brand {
display: flex;
align-items: center;
gap: 16px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--primary);
color: #fff;
font-weight: 600;
border-radius: 14px;
box-shadow: var(--shadow);
}
.brand-title {
font-size: 20px;
font-weight: 600;
}
.brand-subtitle {
color: var(--muted);
font-size: 13px;
}
.user {
text-align: right;
display: grid;
gap: 6px;
justify-items: end;
}
.user-name {
font-weight: 600;
}
.user-upn {
color: var(--muted);
font-size: 12px;
}
.main {
padding: 32px 36px 60px;
display: grid;
gap: 24px;
}
.panel {
background: var(--panel);
padding: 24px;
border-radius: 18px;
box-shadow: var(--shadow);
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.form-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: 2fr 1fr auto;
align-items: center;
}
input,
select {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(30, 27, 22, 0.15);
background: #fff;
font-size: 14px;
}
button,
.primary,
.secondary {
border: none;
padding: 10px 16px;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.primary {
background: var(--primary);
color: #fff;
}
.primary:hover {
background: var(--primary-dark);
}
.secondary {
background: #efe7da;
color: var(--ink);
}
.danger {
background: var(--danger);
color: #fff;
}
.list {
list-style: none;
padding: 0;
margin: 16px 0 0;
display: grid;
gap: 12px;
}
.list.compact li {
padding: 8px 10px;
}
.list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(30, 27, 22, 0.08);
}
.badge {
background: #fff2db;
color: var(--primary-dark);
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.alert {
background: #ffe3d8;
color: #8c2b0c;
padding: 12px;
border-radius: 12px;
margin: 12px 0;
}
.hint {
color: var(--muted);
font-size: 12px;
margin-top: 10px;
}
.muted {
color: var(--muted);
}
.member-actions {
display: flex;
align-items: center;
gap: 8px;
}
.group-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-top: 16px;
}
.group-card {
background: #fff;
border: 1px solid rgba(30, 27, 22, 0.08);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 12px;
}
.group-title {
font-weight: 600;
}
.table-wrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th,
.table td {
text-align: left;
padding: 10px;
border-bottom: 1px solid rgba(30, 27, 22, 0.1);
}
.table th {
font-weight: 600;
color: var(--muted);
}
@media (max-width: 720px) {
.site-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.form-grid {
grid-template-columns: 1fr;
}
}

111
webui/src/auth.js Normal file
View File

@@ -0,0 +1,111 @@
const { Issuer, generators } = require('openid-client');
function parseAllowedSuffixes(value) {
if (!value) return [];
return value
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
}
function isAllowedUpn(upn, allowedSuffixes) {
if (!allowedSuffixes.length) return true;
const lower = upn.toLowerCase();
return allowedSuffixes.some((suffix) => lower.endsWith(suffix));
}
async function buildOidcClient() {
const tenantId = process.env.ENTRA_TENANT_ID;
const clientId = process.env.ENTRA_CLIENT_ID;
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
const redirectUri = process.env.ENTRA_REDIRECT_URI;
if (!tenantId || !clientId || !clientSecret || !redirectUri) {
throw new Error('Missing Entra ID OIDC configuration.');
}
const issuer = await Issuer.discover(
`https://login.microsoftonline.com/${tenantId}/v2.0/.well-known/openid-configuration`
);
return new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [redirectUri],
response_types: ['code']
});
}
function authMiddleware() {
return function requireAuth(req, res, next) {
if (req.session && req.session.user) return next();
return res.redirect('/login');
};
}
function registerAuthRoutes(app, oidcClient) {
const allowedSuffixes = parseAllowedSuffixes(process.env.ALLOWED_UPN_SUFFIX);
app.get('/login', (req, res) => {
res.render('login');
});
app.get('/auth/login', (req, res) => {
const state = generators.state();
const nonce = generators.nonce();
req.session.oidcState = state;
req.session.oidcNonce = nonce;
const authorizationUrl = oidcClient.authorizationUrl({
scope: 'openid profile email',
state,
nonce
});
res.redirect(authorizationUrl);
});
app.get('/auth/callback', async (req, res, next) => {
try {
const params = oidcClient.callbackParams(req);
const tokenSet = await oidcClient.callback(
process.env.ENTRA_REDIRECT_URI,
params,
{
state: req.session.oidcState,
nonce: req.session.oidcNonce
}
);
const claims = tokenSet.claims();
const upn = claims.preferred_username || claims.upn || claims.email;
if (!upn) {
return res.status(403).send('UPN missing in token.');
}
if (!isAllowedUpn(upn, allowedSuffixes)) {
return res.status(403).send('User UPN suffix not allowed.');
}
req.session.user = {
upn: upn.toLowerCase(),
name: claims.name || upn
};
return res.redirect('/');
} catch (error) {
return next(error);
}
});
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
}
module.exports = {
buildOidcClient,
registerAuthRoutes,
authMiddleware
};

42
webui/src/db.js Normal file
View File

@@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const MIGRATIONS_DIR = path.join(__dirname, '..', 'migrations');
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function initDb(dbPath) {
ensureDir(path.dirname(dbPath));
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.prepare(
'CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)'
).run();
const applied = new Set(
db.prepare('SELECT version FROM schema_migrations').all().map((row) => row.version)
);
const migrationFiles = fs
.readdirSync(MIGRATIONS_DIR)
.filter((name) => name.endsWith('.sql'))
.sort();
for (const file of migrationFiles) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
db.exec(sql);
db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(
file,
new Date().toISOString()
);
}
return db;
}
module.exports = { initDb };

481
webui/src/index.js Normal file
View File

@@ -0,0 +1,481 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
const bcrypt = require('bcryptjs');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const { initDb } = require('./db');
const {
validateShareName,
validateGroupName,
listSharesVisible,
getShare,
createShare,
markShareState,
addMembership,
removeMembership,
userRoleForShare,
renderSharesConfig,
writeSharesConfig,
listGroups,
getGroupMembers,
createGroup,
addGroupMember,
removeGroupMember
} = require('./shares');
const { buildOidcClient, registerAuthRoutes, authMiddleware } = require('./auth');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '..', 'views'));
app.set('trust proxy', 1);
app.use(cookieParser());
app.use(
session({
secret: process.env.WEBUI_SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: true
}
})
);
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/public', express.static(path.join(__dirname, '..', 'public')));
const dbPath = process.env.SQLITE_DB_PATH || '/var/lib/webui/app.db';
const db = initDb(dbPath);
const sambaGeneratedPath = process.env.SAMBA_GENERATED_PATH || '/samba-generated/shares.generated.conf';
const dataRoot = process.env.DATA_ROOT || '/data';
const filesvcGid = Number(process.env.FILESVC_GID || 10050);
fs.mkdirSync(path.dirname(sambaGeneratedPath), { recursive: true });
if (!fs.existsSync(sambaGeneratedPath)) {
fs.writeFileSync(sambaGeneratedPath, '', 'utf8');
}
function requireOwnerOrShareRole(req, res, next) {
const shareId = Number(req.params.id);
const shareData = getShare(db, shareId);
if (!shareData) return res.status(404).send('Share not found');
const { share } = shareData;
const userUpn = req.session.user.upn;
if (share.owner_upn === userUpn) {
req.shareData = shareData;
return next();
}
const role = userRoleForShare(db, shareId, userUpn);
if (role) {
req.shareData = shareData;
return next();
}
return res.status(403).send('Forbidden');
}
function requireOwner(req, res, next) {
const shareId = Number(req.params.id);
const shareData = getShare(db, shareId);
if (!shareData) return res.status(404).send('Share not found');
const { share } = shareData;
const userUpn = req.session.user.upn;
if (share.owner_upn !== userUpn) {
return res.status(403).send('Only owners can modify memberships.');
}
req.shareData = shareData;
return next();
}
function ensureOwnerForShareId(req, res, next) {
const shareId = Number(req.body.shareId || req.query.shareId);
if (!shareId) return res.status(400).send('Share id required.');
const shareData = getShare(db, shareId);
if (!shareData) return res.status(404).send('Share not found');
if (shareData.share.owner_upn !== req.session.user.upn) {
return res.status(403).send('Only owners can manage groups.');
}
req.shareData = shareData;
return next();
}
function requireAdmin(req, res, next) {
const adminHash = process.env.ADMIN_UPN_BCRYPT;
if (!adminHash) return res.status(403).send('Admin access not configured.');
const userUpn = req.session.user.upn;
const isAdmin = bcrypt.compareSync(userUpn, adminHash);
if (!isAdmin) return res.status(403).send('Admin access required.');
return next();
}
function logEvent({ userUpn, action, method, path, statusCode, shareId, details }) {
try {
db.prepare(
`INSERT INTO access_logs (occurred_at, user_upn, action, method, path, status_code, share_id, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
new Date().toISOString(),
userUpn,
action,
method,
path,
statusCode || null,
shareId || null,
details ? JSON.stringify(details) : null
);
} catch (err) {
console.error('Failed to log event', err.message);
}
}
function logAccessMiddleware(req, res, next) {
if (!req.session || !req.session.user) return next();
res.on('finish', () => {
logEvent({
userUpn: req.session.user.upn,
action: 'access',
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode
});
});
return next();
}
async function main() {
const oidcClient = await buildOidcClient();
registerAuthRoutes(app, oidcClient);
app.use(authMiddleware());
app.use(logAccessMiddleware);
const initialConfig = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, initialConfig);
app.get('/', (req, res) => {
const shares = listSharesVisible(db, req.session.user.upn);
const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn);
res.render('index', { user: req.session.user, shares, myShares, error: null });
});
app.get('/shares/:id', requireOwnerOrShareRole, (req, res) => {
const groups = listGroups(db).map((group) => ({
...group,
members: getGroupMembers(db, group.id)
}));
res.render('share', {
user: req.session.user,
share: req.shareData.share,
members: req.shareData.members,
groups,
error: null
});
});
app.post('/shares/:id/members', requireOwner, (req, res) => {
const shareId = Number(req.params.id);
const { principal, role, action, principalType } = req.body;
try {
if (!principal) throw new Error('Principal is required.');
if (action === 'remove') {
removeMembership(db, shareId, principalType || 'user', principal);
logEvent({
userUpn: req.session.user.upn,
action: 'remove_member',
method: req.method,
path: req.originalUrl,
shareId,
details: { principal, principalType: principalType || 'user' }
});
} else {
addMembership(db, shareId, principalType || 'user', principal, role);
logEvent({
userUpn: req.session.user.upn,
action: 'add_member',
method: req.method,
path: req.originalUrl,
shareId,
details: { principal, principalType: principalType || 'user', role }
});
}
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
return res.redirect(`/shares/${shareId}`);
} catch (error) {
const shareData = getShare(db, shareId);
return res.status(400).render('share', {
user: req.session.user,
share: shareData.share,
members: shareData.members,
groups: listGroups(db).map((group) => ({
...group,
members: getGroupMembers(db, group.id)
})),
error: error.message
});
}
});
app.post('/shares', async (req, res) => {
const { name } = req.body;
const error = validateShareName(name);
if (error) {
const shares = listSharesVisible(db, req.session.user.upn);
const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn);
return res.status(400).render('index', {
user: req.session.user,
shares,
myShares,
error
});
}
let shareId;
try {
shareId = createShare(db, name, req.session.user.upn);
} catch (err) {
const shares = listSharesVisible(db, req.session.user.upn);
const myShares = shares.filter((share) => share.owner_upn === req.session.user.upn);
return res.status(400).render('index', {
user: req.session.user,
shares,
myShares,
error: 'Share name already exists.'
});
}
try {
const shareDir = path.join(dataRoot, 'shares', name);
fs.mkdirSync(shareDir, { recursive: true });
fs.chownSync(shareDir, 0, filesvcGid);
fs.chmodSync(shareDir, 0o2770);
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
markShareState(db, shareId, 'ready');
logEvent({
userUpn: req.session.user.upn,
action: 'create_share',
method: req.method,
path: req.originalUrl,
shareId,
details: { name }
});
} catch (err) {
markShareState(db, shareId, 'error');
throw err;
}
return res.redirect('/');
});
app.post('/shares/:id/delete', requireOwner, (req, res) => {
const shareId = Number(req.params.id);
markShareState(db, shareId, 'deleted');
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
logEvent({
userUpn: req.session.user.upn,
action: 'delete_share',
method: req.method,
path: req.originalUrl,
shareId
});
return res.redirect('/');
});
app.get('/api/shares', (req, res) => {
res.json(listSharesVisible(db, req.session.user.upn));
});
app.get('/api/shares/:id', requireOwnerOrShareRole, (req, res) => {
res.json(req.shareData);
});
app.post('/api/shares', (req, res) => {
const { name } = req.body;
const error = validateShareName(name);
if (error) return res.status(400).json({ error });
let shareId;
try {
shareId = createShare(db, name, req.session.user.upn);
} catch (err) {
return res.status(400).json({ error: 'Share name already exists.' });
}
try {
const shareDir = path.join(dataRoot, 'shares', name);
fs.mkdirSync(shareDir, { recursive: true });
fs.chownSync(shareDir, 0, filesvcGid);
fs.chmodSync(shareDir, 0o2770);
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
markShareState(db, shareId, 'ready');
logEvent({
userUpn: req.session.user.upn,
action: 'create_share',
method: req.method,
path: req.originalUrl,
shareId,
details: { name }
});
} catch (err) {
markShareState(db, shareId, 'error');
return res.status(500).json({ error: err.message });
}
return res.status(201).json({ id: shareId });
});
app.post('/api/shares/:id/members', requireOwner, (req, res) => {
const shareId = Number(req.params.id);
const { principal, role, action, principalType } = req.body;
try {
if (!principal) throw new Error('Principal is required.');
if (action === 'remove') {
removeMembership(db, shareId, principalType || 'user', principal);
logEvent({
userUpn: req.session.user.upn,
action: 'remove_member',
method: req.method,
path: req.originalUrl,
shareId,
details: { principal, principalType: principalType || 'user' }
});
} else {
addMembership(db, shareId, principalType || 'user', principal, role);
logEvent({
userUpn: req.session.user.upn,
action: 'add_member',
method: req.method,
path: req.originalUrl,
shareId,
details: { principal, principalType: principalType || 'user', role }
});
}
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
return res.json({ ok: true });
} catch (error) {
return res.status(400).json({ error: error.message });
}
});
app.delete('/api/shares/:id', requireOwner, (req, res) => {
const shareId = Number(req.params.id);
markShareState(db, shareId, 'deleted');
const contents = renderSharesConfig(db);
writeSharesConfig(sambaGeneratedPath, contents);
logEvent({
userUpn: req.session.user.upn,
action: 'delete_share',
method: req.method,
path: req.originalUrl,
shareId
});
res.json({ ok: true });
});
app.post('/groups', ensureOwnerForShareId, (req, res) => {
const { name } = req.body;
const error = validateGroupName(name);
if (error) return res.status(400).send(error);
try {
createGroup(db, name);
} catch (err) {
return res.status(400).send('Group name already exists.');
}
logEvent({
userUpn: req.session.user.upn,
action: 'create_group',
method: req.method,
path: req.originalUrl,
shareId: req.shareData.share.id,
details: { name }
});
return res.redirect(`/shares/${req.shareData.share.id}`);
});
app.post('/groups/:id/members', ensureOwnerForShareId, (req, res) => {
const groupId = Number(req.params.id);
const { member, action } = req.body;
if (!member) return res.status(400).send('Member UPN required.');
if (action === 'remove') {
removeGroupMember(db, groupId, member);
logEvent({
userUpn: req.session.user.upn,
action: 'remove_group_member',
method: req.method,
path: req.originalUrl,
shareId: req.shareData.share.id,
details: { groupId, member }
});
} else {
addGroupMember(db, groupId, member);
logEvent({
userUpn: req.session.user.upn,
action: 'add_group_member',
method: req.method,
path: req.originalUrl,
shareId: req.shareData.share.id,
details: { groupId, member }
});
}
return res.redirect(`/shares/${req.shareData.share.id}`);
});
app.get('/admin', requireAdmin, (req, res) => {
const logs = db
.prepare(
`SELECT id, occurred_at, user_upn, action, method, path, status_code, share_id, details
FROM access_logs
ORDER BY occurred_at DESC
LIMIT 200`
)
.all();
const daily = db
.prepare(
`SELECT substr(occurred_at, 1, 10) AS day, COUNT(*) AS count
FROM access_logs
GROUP BY day
ORDER BY day DESC
LIMIT 14`
)
.all()
.reverse();
const actions = db
.prepare(
`SELECT action, COUNT(*) AS count
FROM access_logs
GROUP BY action
ORDER BY count DESC`
)
.all();
res.render('admin', {
user: req.session.user,
logs,
daily,
actions
});
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send('Unexpected error.');
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Web UI listening on ${port}`);
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

286
webui/src/shares.js Normal file
View File

@@ -0,0 +1,286 @@
const fs = require('fs');
const path = require('path');
const RESERVED_SHARE_NAMES = new Set([
'private',
'ipc$',
'print$',
'admin$',
'netlogon',
'sysvol'
]);
const SHARE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,30}$/;
const GROUP_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,40}$/;
function normalizeUpn(upn) {
return upn.trim().toLowerCase();
}
function validateShareName(name) {
if (!name || typeof name !== 'string') return 'Share name is required.';
if (!SHARE_NAME_REGEX.test(name)) {
return 'Share name must start with an alphanumeric character and be 1-31 chars using letters, numbers, dot, underscore, or dash.';
}
if (RESERVED_SHARE_NAMES.has(name.toLowerCase())) {
return `Share name ${name} is reserved.`;
}
return null;
}
function validateGroupName(name) {
const trimmed = name ? name.trim() : '';
if (!trimmed || typeof name !== 'string') return 'Group name is required.';
if (!GROUP_NAME_REGEX.test(trimmed)) {
return 'Group name must start with an alphanumeric character and be 1-41 chars using letters, numbers, dot, underscore, or dash.';
}
return null;
}
function normalizeGroupName(name) {
return name.trim().toLowerCase();
}
function ensurePrincipal(db, type, { upn, name }) {
const stmt = db.prepare(
'SELECT id FROM principals WHERE type = ? AND COALESCE(upn, "") = COALESCE(?, "") AND COALESCE(name, "") = COALESCE(?, "")'
);
const existing = stmt.get(type, upn || null, name || null);
if (existing) return existing.id;
const insert = db.prepare('INSERT INTO principals (type, name, upn) VALUES (?, ?, ?)');
const info = insert.run(type, name || null, upn || null);
return info.lastInsertRowid;
}
function listSharesVisible(db, upn) {
const normalized = normalizeUpn(upn);
const rows = db
.prepare(
`SELECT DISTINCT s.*
FROM shares s
LEFT JOIN memberships m ON s.id = m.share_id
LEFT JOIN principals p ON m.principal_id = p.id
LEFT JOIN group_members gm ON p.id = gm.group_id
WHERE s.state != 'deleted'
AND (s.owner_upn = ?
OR (p.type = 'user' AND p.upn = ?)
OR (p.type = 'group' AND gm.user_upn = ?))`
)
.all(normalized, normalized, normalized);
return rows;
}
function getShare(db, id) {
const share = db.prepare('SELECT * FROM shares WHERE id = ? AND state != ?').get(id, 'deleted');
if (!share) return null;
const members = db
.prepare(
`SELECT m.role, p.type, p.upn, p.name, p.id AS principal_id
FROM memberships m
JOIN principals p ON p.id = m.principal_id
WHERE m.share_id = ?
ORDER BY m.role, p.type, COALESCE(p.upn, p.name)`
)
.all(id);
return { share, members };
}
function createShare(db, name, ownerUpn) {
const now = new Date().toISOString();
const info = db
.prepare('INSERT INTO shares (name, owner_upn, created_at, state) VALUES (?, ?, ?, ?)')
.run(name, normalizeUpn(ownerUpn), now, 'creating');
return info.lastInsertRowid;
}
function markShareState(db, id, state) {
db.prepare('UPDATE shares SET state = ? WHERE id = ?').run(state, id);
}
function addMembership(db, shareId, principalType, principalValue, role) {
if (!role || !['owner', 'rw', 'ro'].includes(role)) {
throw new Error('Invalid role. Must be owner, rw, or ro.');
}
let principalId;
if (principalType === 'group') {
principalId = ensurePrincipal(db, 'group', { name: normalizeGroupName(principalValue) });
} else {
principalId = ensurePrincipal(db, 'user', { upn: normalizeUpn(principalValue) });
}
db.prepare(
'INSERT INTO memberships (share_id, principal_id, role) VALUES (?, ?, ?) ON CONFLICT(share_id, principal_id) DO UPDATE SET role = excluded.role'
).run(shareId, principalId, role);
}
function removeMembership(db, shareId, principalType, principalValue) {
const principal = principalType === 'group'
? db.prepare('SELECT id FROM principals WHERE type = ? AND name = ?').get('group', normalizeGroupName(principalValue))
: db.prepare('SELECT id FROM principals WHERE type = ? AND upn = ?').get('user', normalizeUpn(principalValue));
if (!principal) return;
db.prepare('DELETE FROM memberships WHERE share_id = ? AND principal_id = ?').run(shareId, principal.id);
}
function userRoleForShare(db, shareId, upn) {
const normalized = normalizeUpn(upn);
const roles = db
.prepare(
`SELECT m.role
FROM memberships m
JOIN principals p ON p.id = m.principal_id
LEFT JOIN group_members gm ON p.id = gm.group_id
WHERE m.share_id = ?
AND (p.type = 'user' AND p.upn = ?
OR (p.type = 'group' AND gm.user_upn = ?))`
)
.all(shareId, normalized, normalized)
.map((row) => row.role);
if (roles.includes('owner')) return 'owner';
if (roles.includes('rw')) return 'rw';
if (roles.includes('ro')) return 'ro';
return null;
}
function getExpandedMembers(db, shareId, ownerUpn) {
const memberships = db
.prepare(
`SELECT m.role, p.type, p.upn, p.name, p.id AS principal_id
FROM memberships m
JOIN principals p ON p.id = m.principal_id
WHERE m.share_id = ?`
)
.all(shareId);
const ownerSet = new Set([normalizeUpn(ownerUpn)]);
const rwSet = new Set();
const roSet = new Set();
for (const member of memberships) {
if (member.type === 'user') {
const upn = normalizeUpn(member.upn);
if (member.role === 'owner') ownerSet.add(upn);
if (member.role === 'rw') rwSet.add(upn);
if (member.role === 'ro') roSet.add(upn);
continue;
}
if (member.type === 'group') {
const rows = db
.prepare('SELECT user_upn FROM group_members WHERE group_id = ?')
.all(member.principal_id);
for (const row of rows) {
const upn = normalizeUpn(row.user_upn);
if (member.role === 'owner') ownerSet.add(upn);
if (member.role === 'rw') rwSet.add(upn);
if (member.role === 'ro') roSet.add(upn);
}
}
}
const validUsers = new Set([...ownerSet, ...rwSet, ...roSet]);
return {
validUsers: [...validUsers],
writeUsers: [...ownerSet, ...rwSet]
};
}
function renderSharesConfig(db) {
const shares = db.prepare('SELECT * FROM shares WHERE state != ?').all('deleted');
const lines = [];
for (const share of shares) {
if (share.state !== 'ready') continue;
const expanded = getExpandedMembers(db, share.id, share.owner_upn);
const validUsers = expanded.validUsers.join(' ');
const writeUsers = expanded.writeUsers.join(' ');
lines.push(`[${share.name}]`);
lines.push(`path = /data/shares/${share.name}`);
lines.push('browseable = yes');
lines.push('read only = yes');
lines.push(`valid users = ${validUsers}`);
lines.push(`write list = ${writeUsers}`);
lines.push('force user = filesvc');
lines.push('force group = filesvc');
lines.push('create mask = 0660');
lines.push('directory mask = 2770');
lines.push('nt acl support = no');
lines.push('dos filemode = no');
lines.push('inherit permissions = no');
lines.push('');
}
return lines.join('\n');
}
function listGroups(db) {
return db
.prepare(
`SELECT p.id, p.name, COUNT(gm.user_upn) AS member_count
FROM principals p
LEFT JOIN group_members gm ON gm.group_id = p.id
WHERE p.type = 'group'
GROUP BY p.id
ORDER BY p.name`
)
.all();
}
function getGroupMembers(db, groupId) {
return db
.prepare('SELECT user_upn FROM group_members WHERE group_id = ? ORDER BY user_upn')
.all(groupId);
}
function createGroup(db, name) {
const normalized = normalizeGroupName(name);
const existing = db.prepare('SELECT id FROM principals WHERE type = ? AND name = ?').get('group', normalized);
if (existing) throw new Error('Group already exists.');
const id = ensurePrincipal(db, 'group', { name: normalized });
return id;
}
function addGroupMember(db, groupId, userUpn) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_upn) VALUES (?, ?)').run(
groupId,
normalizeUpn(userUpn)
);
}
function removeGroupMember(db, groupId, userUpn) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_upn = ?').run(
groupId,
normalizeUpn(userUpn)
);
}
function writeSharesConfig(configPath, contents) {
const dir = path.dirname(configPath);
const tmpPath = path.join(dir, `.shares.generated.${Date.now()}.tmp`);
fs.writeFileSync(tmpPath, contents, 'utf8');
fs.renameSync(tmpPath, configPath);
}
module.exports = {
validateShareName,
validateGroupName,
normalizeUpn,
normalizeGroupName,
listSharesVisible,
getShare,
createShare,
markShareState,
addMembership,
removeMembership,
userRoleForShare,
renderSharesConfig,
writeSharesConfig,
listGroups,
getGroupMembers,
createGroup,
addGroupMember,
removeGroupMember
};

88
webui/views/admin.ejs Normal file
View File

@@ -0,0 +1,88 @@
<%- include('partials/header', { title: 'Admin', user }) %>
<section class="panel">
<h1>Access logs</h1>
<p class="muted">Every authenticated request and share operation is recorded.</p>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>User</th>
<th>Action</th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th>Share</th>
</tr>
</thead>
<tbody>
<% logs.forEach((log) => { %>
<tr>
<td><%= log.occurred_at %></td>
<td><%= log.user_upn %></td>
<td><%= log.action %></td>
<td><%= log.method %></td>
<td><%= log.path %></td>
<td><%= log.status_code || '' %></td>
<td><%= log.share_id || '' %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
<section class="grid">
<div class="panel">
<h2>Daily activity</h2>
<canvas id="dailyChart" height="200"></canvas>
</div>
<div class="panel">
<h2>Actions breakdown</h2>
<canvas id="actionChart" height="200"></canvas>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script>
const dailyLabels = <%- JSON.stringify(daily.map(item => item.day)) %>;
const dailyCounts = <%- JSON.stringify(daily.map(item => item.count)) %>;
const actionLabels = <%- JSON.stringify(actions.map(item => item.action)) %>;
const actionCounts = <%- JSON.stringify(actions.map(item => item.count)) %>;
new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: dailyLabels,
datasets: [{
label: 'Requests',
data: dailyCounts,
borderColor: '#d26b2f',
backgroundColor: 'rgba(210, 107, 47, 0.2)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } }
}
});
new Chart(document.getElementById('actionChart'), {
type: 'bar',
data: {
labels: actionLabels,
datasets: [{
label: 'Count',
data: actionCounts,
backgroundColor: '#b75522'
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } }
}
});
</script>
<%- include('partials/footer') %>

47
webui/views/index.ejs Normal file
View File

@@ -0,0 +1,47 @@
<%- include('partials/header', { title: 'Shares', user }) %>
<section class="panel">
<h1>Create share</h1>
<% if (error) { %>
<div class="alert"><%= error %></div>
<% } %>
<form action="/shares" method="post" class="form-row">
<input name="name" placeholder="share-name" required />
<button class="primary" type="submit">Create</button>
</form>
<div class="hint">Share names are 1-31 chars and must avoid reserved names like private or IPC$.</div>
</section>
<section class="grid">
<div class="panel">
<h2>My shares</h2>
<% if (!myShares.length) { %>
<p class="muted">You do not own any shares yet.</p>
<% } %>
<ul class="list">
<% myShares.forEach((share) => { %>
<li>
<a href="/shares/<%= share.id %>"><%= share.name %></a>
<span class="badge">Owner</span>
</li>
<% }) %>
</ul>
</div>
<div class="panel">
<h2>Shares you can access</h2>
<% if (!shares.length) { %>
<p class="muted">No shares are available to you yet.</p>
<% } %>
<ul class="list">
<% shares.forEach((share) => { %>
<li>
<a href="/shares/<%= share.id %>"><%= share.name %></a>
<% if (share.owner_upn === user.upn) { %>
<span class="badge">Owner</span>
<% } %>
</li>
<% }) %>
</ul>
</div>
</section>
<%- include('partials/footer') %>

7
webui/views/login.ejs Normal file
View File

@@ -0,0 +1,7 @@
<%- include('partials/header', { title: 'Sign in', user: null }) %>
<section class="panel">
<h1>Sign in</h1>
<p>Use Entra ID to access your file shares.</p>
<a class="primary" href="/auth/login">Continue with Entra ID</a>
</section>
<%- include('partials/footer') %>

View File

@@ -0,0 +1,3 @@
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<header class="site-header">
<div class="brand">
<span class="brand-mark">AAF</span>
<div>
<div class="brand-title">AAD File Shares</div>
<div class="brand-subtitle">AD-authenticated SMB shares</div>
</div>
</div>
<% if (user) { %>
<div class="user">
<div class="user-name"><%= user.name %></div>
<div class="user-upn"><%= user.upn %></div>
<a class="secondary" href="/admin">Admin</a>
<form action="/auth/logout" method="post">
<button class="secondary" type="submit">Sign out</button>
</form>
</div>
<% } %>
</header>
<main class="main">

117
webui/views/share.ejs Normal file
View File

@@ -0,0 +1,117 @@
<%- include('partials/header', { title: 'Share detail', user }) %>
<% const isOwner = share.owner_upn === user.upn; %>
<section class="panel">
<div class="panel-head">
<div>
<h1><%= share.name %></h1>
<div class="muted">Owner: <%= share.owner_upn %></div>
<div class="muted">State: <%= share.state %></div>
</div>
<% if (isOwner) { %>
<form action="/shares/<%= share.id %>/delete" method="post">
<button class="danger" type="submit">Disable share</button>
</form>
<% } %>
</div>
</section>
<section class="panel">
<h2>Members</h2>
<% if (error) { %>
<div class="alert"><%= error %></div>
<% } %>
<% if (isOwner) { %>
<form action="/shares/<%= share.id %>/members" method="post" class="form-grid">
<input name="principal" placeholder="user@domain" required />
<input type="hidden" name="principalType" value="user" />
<select name="role" required>
<option value="rw">RW</option>
<option value="ro">RO</option>
<option value="owner">Owner</option>
</select>
<button class="primary" type="submit">Add/Update</button>
</form>
<form action="/shares/<%= share.id %>/members" method="post" class="form-grid">
<input name="principal" placeholder="group-name" required />
<input type="hidden" name="principalType" value="group" />
<select name="role" required>
<option value="rw">RW</option>
<option value="ro">RO</option>
<option value="owner">Owner</option>
</select>
<button class="secondary" type="submit">Add Group</button>
</form>
<% } %>
<ul class="list">
<% members.forEach((member) => { %>
<li>
<div>
<div class="member"><%= member.upn || member.name %></div>
<div class="muted"><%= member.type %></div>
</div>
<div class="member-actions">
<span class="badge"><%= member.role.toUpperCase() %></span>
<% if (isOwner) { %>
<form action="/shares/<%= share.id %>/members" method="post">
<input type="hidden" name="principal" value="<%= member.upn || member.name %>" />
<input type="hidden" name="principalType" value="<%= member.type %>" />
<input type="hidden" name="action" value="remove" />
<button class="secondary" type="submit">Remove</button>
</form>
<% } %>
</div>
</li>
<% }) %>
</ul>
</section>
<section class="panel">
<h2>Local groups</h2>
<% if (isOwner) { %>
<form action="/groups" method="post" class="form-row">
<input name="name" placeholder="group-name" required />
<input type="hidden" name="shareId" value="<%= share.id %>" />
<button class="primary" type="submit">Create group</button>
</form>
<% } %>
<div class="hint">Groups are stored in SQLite and can be assigned roles per share.</div>
<div class="group-grid">
<% if (!groups.length) { %>
<div class="muted">No local groups yet.</div>
<% } %>
<% groups.forEach((group) => { %>
<div class="group-card">
<div class="group-head">
<div>
<div class="group-title"><%= group.name %></div>
<div class="muted"><%= group.member_count %> members</div>
</div>
</div>
<% if (isOwner) { %>
<form action="/groups/<%= group.id %>/members" method="post" class="form-row">
<input name="member" placeholder="user@domain" required />
<input type="hidden" name="shareId" value="<%= share.id %>" />
<button class="secondary" type="submit">Add member</button>
</form>
<% } %>
<ul class="list compact">
<% group.members.forEach((member) => { %>
<li>
<span><%= member.user_upn %></span>
<% if (isOwner) { %>
<form action="/groups/<%= group.id %>/members" method="post">
<input type="hidden" name="member" value="<%= member.user_upn %>" />
<input type="hidden" name="action" value="remove" />
<input type="hidden" name="shareId" value="<%= share.id %>" />
<button class="secondary" type="submit">Remove</button>
</form>
<% } %>
</li>
<% }) %>
</ul>
</div>
<% }) %>
</div>
</section>
<%- include('partials/footer') %>