Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions routes/profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { readdir, readFile, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import { buildProfileState } from '../shared/profile-domain.mjs';

const USERNAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/i;

const usernameToPath = (username, profileDir) => {
if (!USERNAME_RE.test(username)) return null;
return path.join(profileDir, `${username}.json`);
};

const readProfile = async (username, profileDir) => {
const filePath = usernameToPath(username, profileDir);
if (!filePath) return null;
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw);
};

const saveProfileState = async (username, incoming, profileDir) => {
const filePath = usernameToPath(username, profileDir);
if (!filePath) {
return {
ok: false,
status: 422,
payload: { ok: false, errors: { id: 'Invalid username' } },
};
}

const merged = { ...(incoming || {}), id: username };
const state = buildProfileState(merged);

if (!state.valid) {
return {
ok: false,
status: 422,
payload: {
ok: false,
profile: state.profile,
computed: state.computed,
errors: state.errors,
valid: false,
},
};
}

await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8');
return {
ok: true,
status: 200,
payload: {
ok: true,
profile: state.profile,
computed: state.computed,
errors: state.errors,
valid: true,
},
};
};

const getProfile = async (
req,
res,
{ username },
{ profileDir, sendJson, serveIndex, staticDir },
) => {
if (!USERNAME_RE.test(username)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
const accept = req.headers.accept || '';
if (accept.includes('text/html') && !accept.includes('application/json')) {
await serveIndex(res, staticDir);
return;
}
try {
const source = await readProfile(username, profileDir);
const state = buildProfileState(source);
sendJson(res, 200, {
ok: true,
profile: state.profile,
computed: state.computed,
errors: state.errors,
valid: state.valid,
});
} catch (error) {
if (error?.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
if (error instanceof SyntaxError) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Corrupt profile JSON');
return;
}
throw error;
}
};

const updateProfile = async (
req,
res,
{ username },
{ profileDir, sendJson, parseJsonBody },
) => {
if (!USERNAME_RE.test(username)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
let body;
try {
body = await parseJsonBody(req);
} catch (error) {
if (error.message === 'INVALID_JSON') {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid JSON');
return;
}
throw error;
}
const result = await saveProfileState(username, body, profileDir);
sendJson(res, result.status, result.payload);
};

const removeProfile = async (
req,
res,
{ username },
{ profileDir, sendJson },
) => {
const target = usernameToPath(username, profileDir);
if (!target) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
try {
await unlink(target);
sendJson(res, 200, { ok: true });
} catch (error) {
if (error?.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
throw error;
}
};

const listProfiles = async (req, res, _params, { profileDir, sendJson }) => {
const url = new URL(req.url, 'http://localhost');
const nameFilter = url.searchParams.get('name')?.toLowerCase() || '';
const emailFilter = url.searchParams.get('email')?.toLowerCase() || '';

const files = await readdir(profileDir).catch(() => []);
const jsonFiles = files.filter((f) => f.endsWith('.json'));

const profiles = await Promise.all(
jsonFiles.map(async (f) => {
try {
const raw = await readFile(path.join(profileDir, f), 'utf8');
const { profile, computed } = buildProfileState(JSON.parse(raw));
return {
id: profile.id,
displayName: computed.displayName,
email: profile.email,
};
} catch {
return null;
}
}),
);

const items = profiles
.filter(Boolean)
.filter(
(p) => !nameFilter || p.displayName.toLowerCase().includes(nameFilter),
)
.filter((p) => !emailFilter || p.email.toLowerCase().includes(emailFilter))
.sort((a, b) => a.id.localeCompare(b.id));

sendJson(res, 200, { ok: true, items });
};

export { USERNAME_RE, usernameToPath, readProfile };
export const routes = [
{ pattern: '/profile', handlers: { GET: listProfiles } },
{
pattern: '/profile/:username',
handlers: {
GET: getProfile,
POST: updateProfile,
PUT: updateProfile,
DELETE: removeProfile,
},
},
];
63 changes: 63 additions & 0 deletions routes/profiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { buildProfileState } from '../shared/profile-domain.mjs';
import { stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { USERNAME_RE } from './profile.js';

const createProfile = async (
req,
res,
_params,
{ profileDir, sendJson, parseJsonBody },
) => {
let body;
try {
body = await parseJsonBody(req);
} catch (error) {
if (error.message === 'INVALID_JSON') {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid JSON');
return;
}
throw error;
}

const requestedId = typeof body.id === 'string' ? body.id.trim() : '';
if (!USERNAME_RE.test(requestedId)) {
sendJson(res, 422, { ok: false, errors: { id: 'Invalid username' } });
return;
}

const filePath = path.join(profileDir, `${requestedId}.json`);
try {
await stat(filePath);
sendJson(res, 409, { ok: false, error: 'Profile already exists' });
return;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}

const merged = { ...body, id: requestedId };
const state = buildProfileState(merged);

if (!state.valid) {
sendJson(res, 422, {
ok: false,
profile: state.profile,
computed: state.computed,
errors: state.errors,
valid: false,
});
return;
}

await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8');
sendJson(res, 200, {
ok: true,
profile: state.profile,
computed: state.computed,
errors: state.errors,
valid: true,
});
};

export default { POST: createProfile };
47 changes: 47 additions & 0 deletions routes/static.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';

const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
};

const TEMPLATES_PLACEHOLDER = '<!-- {{templates}} -->';

const serveFile = async (res, filePath) => {
try {
const data = await readFile(filePath);
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
} catch (error) {
if (error?.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
throw error;
}
};

const serveIndex = async (res, staticDir) => {
const componentsDir = path.join(staticDir, 'components');
const [indexHtml, files] = await Promise.all([
readFile(path.join(staticDir, 'index.html'), 'utf8'),
readdir(componentsDir),
]);
const htmlFiles = files.filter((f) => f.endsWith('.html')).sort();
const parts = await Promise.all(
htmlFiles.map((f) => readFile(path.join(componentsDir, f), 'utf8')),
);
const html = indexHtml.replace(TEMPLATES_PLACEHOLDER, parts.join('\n'));
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
};

export { serveFile, serveIndex };
Loading