initial commit
This commit is contained in:
22
webui/Dockerfile
Normal file
22
webui/Dockerfile
Normal 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"]
|
||||
31
webui/migrations/001_init.sql
Normal file
31
webui/migrations/001_init.sql
Normal 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)
|
||||
);
|
||||
15
webui/migrations/002_logs.sql
Normal file
15
webui/migrations/002_logs.sql
Normal 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
18
webui/package.json
Normal 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
270
webui/public/styles.css
Normal 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
111
webui/src/auth.js
Normal 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
42
webui/src/db.js
Normal 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
481
webui/src/index.js
Normal 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
286
webui/src/shares.js
Normal 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
88
webui/views/admin.ejs
Normal 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
47
webui/views/index.ejs
Normal 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
7
webui/views/login.ejs
Normal 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') %>
|
||||
3
webui/views/partials/footer.ejs
Normal file
3
webui/views/partials/footer.ejs
Normal file
@@ -0,0 +1,3 @@
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
29
webui/views/partials/header.ejs
Normal file
29
webui/views/partials/header.ejs
Normal 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
117
webui/views/share.ejs
Normal 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') %>
|
||||
Reference in New Issue
Block a user