studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
220 lines (188 loc) • 6.19 kB
text/typescript
import { Password, User } from 'studiocms:auth/lib';
import { apiResponseLogger } from 'studiocms:logger';
import { Notifications } from 'studiocms:notifier';
import { SDKCore } from 'studiocms:sdk';
import { z } from 'astro/zod';
import {
AllResponse,
createEffectAPIRoutes,
createJsonResponse,
Effect,
genLogger,
OptionsResponse,
parseAPIContextJson,
Schema,
} from '../../../../../effect.js';
import { verifyAuthTokenFromHeader } from '../../utils/auth-token.js';
export class JSONData extends Schema.Class<JSONData>('JSONData')({
username: Schema.Union(Schema.String, Schema.Undefined),
password: Schema.Union(Schema.String, Schema.Undefined),
email: Schema.Union(Schema.String, Schema.Undefined),
displayname: Schema.Union(Schema.String, Schema.Undefined),
rank: Schema.Union(
Schema.Literal('owner'),
Schema.Literal('admin'),
Schema.Literal('editor'),
Schema.Literal('visitor'),
Schema.Undefined
),
}) {}
export const { ALL, GET, POST, OPTIONS } = createEffectAPIRoutes(
{
GET: (ctx) =>
genLogger('studioCMS:rest:v1:users:GET')(function* () {
const [sdk, user] = yield* Effect.all([SDKCore, verifyAuthTokenFromHeader(ctx)]);
if (user instanceof Response) {
return user;
}
const { rank } = user;
if (rank !== 'owner' && rank !== 'admin') {
return apiResponseLogger(401, 'Unauthorized');
}
const users = yield* sdk.GET.users.all();
let data = users.map(
({ avatar, createdAt, email, id, name, permissionsData, updatedAt, url, username }) => ({
avatar,
createdAt,
email,
id,
name,
rank: permissionsData?.rank ?? 'unknown',
updatedAt,
url,
username,
})
);
if (rank !== 'owner') {
data = data.filter((user) => user.rank !== 'owner');
}
const searchParams = ctx.url.searchParams;
const rankFilter = searchParams.get('rank');
const usernameFilter = searchParams.get('username');
const nameFilter = searchParams.get('name');
const usernameFilterLower = usernameFilter?.toLowerCase();
const nameFilterLower = nameFilter?.toLowerCase();
let filteredData = data;
if (rankFilter) {
filteredData = filteredData.filter((u) => u.rank === rankFilter);
}
if (usernameFilterLower) {
filteredData = filteredData.filter((u) =>
(u.username ?? '').toLowerCase().includes(usernameFilterLower)
);
}
if (nameFilterLower) {
filteredData = filteredData.filter((u) =>
(u.name ?? '').toLowerCase().includes(nameFilterLower)
);
}
return createJsonResponse(filteredData);
}),
POST: (ctx) =>
genLogger('studioCMS:rest:v1:users:POST')(function* () {
const [sdk, user, userUtils, passwordUtils, notifier] = yield* Effect.all([
SDKCore,
verifyAuthTokenFromHeader(ctx),
User,
Password,
Notifications,
]);
if (user instanceof Response) {
return user;
}
const { rank } = user;
if (rank !== 'owner' && rank !== 'admin') {
return apiResponseLogger(401, 'Unauthorized');
}
let {
username,
password,
email,
displayname,
rank: newUserRank,
} = yield* parseAPIContextJson(ctx, JSONData);
if (!username) {
return apiResponseLogger(400, 'Missing field: Username is required');
}
// Only owners can assign the 'owner' rank
if (newUserRank === 'owner' && rank !== 'owner') {
return apiResponseLogger(401, 'Unauthorized');
}
if (!password) {
password = yield* sdk.generateRandomPassword(12);
}
if (!email) {
return apiResponseLogger(400, 'Missing field: Email is required');
}
if (!displayname) {
return apiResponseLogger(400, 'Missing field: Display name is required');
}
if (!newUserRank) {
return apiResponseLogger(400, 'Missing field: Rank is required');
}
// Prevent privilege escalation: admins cannot assign owner rank
if (rank === 'admin' && newUserRank === 'owner') {
return apiResponseLogger(403, 'Forbidden: insufficient permission to assign owner rank');
}
// If the email is invalid, return an error
const checkEmail = z.coerce
.string()
.email({ message: 'Email address is invalid' })
.safeParse(email);
if (!checkEmail.success) {
return apiResponseLogger(400, `Invalid email: ${checkEmail.error.message}`);
}
const [verifyUsernameResponse, verifyPasswordResponse, { usernameSearch, emailSearch }] =
yield* Effect.all([
userUtils.verifyUsernameInput(username),
passwordUtils.verifyPasswordStrength(password),
sdk.AUTH.user.searchUsersForUsernameOrEmail(username, checkEmail.data),
]);
// If the username is invalid, return an error
if (verifyUsernameResponse !== true) {
return apiResponseLogger(400, verifyUsernameResponse);
}
// If the password is invalid, return an error(password);
if (verifyPasswordResponse !== true) {
return apiResponseLogger(400, verifyPasswordResponse);
}
if (usernameSearch.length > 0) {
return apiResponseLogger(400, 'Invalid username: Username is already in use');
}
if (emailSearch.length > 0) {
return apiResponseLogger(400, 'Invalid email: Email is already in use');
}
// Create a new user
const newUser = yield* userUtils.createLocalUser(
displayname,
username,
checkEmail.data,
password
);
const updateRank = yield* sdk.UPDATE.permissions({
user: newUser.id,
rank: newUserRank,
});
yield* notifier.sendAdminNotification('new_user', newUser.username);
return apiResponseLogger(
200,
JSON.stringify({
username,
email: checkEmail.data,
displayname,
rank: updateRank.rank,
password,
})
);
}).pipe(Notifications.Provide),
OPTIONS: () => Effect.try(() => OptionsResponse({ allowedMethods: ['GET', 'POST'] })),
ALL: () => Effect.try(() => AllResponse()),
},
{
cors: { methods: ['GET', 'POST', 'OPTIONS'] },
onError: (error) => {
console.error('API Error:', error);
return createJsonResponse({ error: 'Something went wrong' }, { status: 500 });
},
}
);