Compare commits

...

6 commits

Author SHA1 Message Date
Francis Cao
d4ff7c8e3f tighten zod validation for segment parameters
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-22 12:53:44 -08:00
Francis Cao
6fd428683d update timezone zod validation to use timezoneParam 2026-01-22 12:02:08 -08:00
Francis Cao
a1efb2d86a check for explicit teamCreate permissions for global role in canCreateTeam 2026-01-22 11:53:11 -08:00
Francis Cao
8b2196b97a fix showDeleted reversed logic. Show non-deleted user as default query 2026-01-22 11:18:14 -08:00
Francis Cao
63d2bfe118 add name column to share table 2026-01-22 10:41:55 -08:00
Francis Cao
8f55ed9da9 security advisory fixes opened by kolega-ai-dev 2026-01-22 09:24:08 -08:00
13 changed files with 82 additions and 20 deletions

View file

@ -3,6 +3,7 @@ CREATE TABLE "share" (
"share_id" UUID NOT NULL, "share_id" UUID NOT NULL,
"entity_id" UUID NOT NULL, "entity_id" UUID NOT NULL,
"share_type" INTEGER NOT NULL, "share_type" INTEGER NOT NULL,
"name" VARCHAR(200) NOT NULL,
"slug" VARCHAR(100) NOT NULL, "slug" VARCHAR(100) NOT NULL,
"parameters" JSONB NOT NULL, "parameters" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
@ -21,9 +22,10 @@ CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id"); CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
-- MigrateData -- MigrateData
INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at) INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
SELECT gen_random_uuid(), SELECT gen_random_uuid(),
website_id, website_id,
name,
1, 1,
share_id, share_id,
'{}'::jsonb, '{}'::jsonb,

View file

@ -342,6 +342,7 @@ model Board {
model Share { model Share {
id String @id() @map("share_id") @db.Uuid id String @id() @map("share_id") @db.Uuid
entityId String @map("entity_id") @db.Uuid entityId String @map("entity_id") @db.Uuid
name String @db.VarChar(200)
shareType Int @map("share_type") @db.Integer shareType Int @map("share_type") @db.Integer
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
parameters Json parameters Json

View file

@ -10,6 +10,7 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
<FormField <FormField
label={formatMessage(labels.password)} label={formatMessage(labels.password)}
name="password" name="password"
rules={{ required: formatMessage(labels.required) }} rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
}}
> >
<PasswordField autoComplete="new-password" data-test="input-password" /> <PasswordField autoComplete="new-password" data-test="input-password" />
</FormField> </FormField>

View file

@ -1,7 +1,14 @@
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { ok } from '@/lib/response'; import { ok } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { error } = await parseRequest(request);
if (error) {
return error();
}
if (redis.enabled) { if (redis.enabled) {
const token = request.headers.get('authorization')?.split(' ')?.[1]; const token = request.headers.get('authorization')?.split(' ')?.[1];

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response'; import { json, serverError } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error(); return error();
} }
if (redis.enabled) { if (!redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400); return serverError({
message: 'Redis is disabled',
return json({ user: auth.user, token }); });
} }
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
} }

View file

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response'; import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema'; import { userRoleParam } from '@/lib/schema';
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255).optional(), username: z.string().max(255).optional(),
password: z.string().max(255).optional(), password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(), role: userRoleParam.optional(),
}); });
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId); const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {}; const data: any = {};
if (password) { if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions'; import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma'; import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
id: z.uuid().optional(), id: z.uuid().optional(),
username: z.string().max(255), username: z.string().max(255),
password: z.string(), password: z.string().min(8).max(255),
role: z.string().regex(/admin|user|view-only/i), role: userRoleParam,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; import { searchParams, segmentParametersSchema, segmentTypeParam } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createSegment, getWebsiteSegments } from '@/queries/prisma'; import { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({ const schema = z.object({
type: segmentTypeParam, type: segmentTypeParam,
name: z.string().max(200), name: z.string().max(200),
parameters: anyObjectParam, parameters: segmentParametersSchema,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -1,7 +1,6 @@
import debug from 'debug'; import debug from 'debug';
import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
import { secret } from '@/lib/crypto'; import { createAuthKey, secret } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { ensureArray } from '@/lib/utils'; import { ensureArray } from '@/lib/utils';
@ -53,7 +52,7 @@ export async function checkAuth(request: Request) {
} }
export async function saveAuth(data: any, expire = 0) { export async function saveAuth(data: any, expire = 0) {
const authKey = `auth:${getRandomChars(32)}`; const authKey = `auth:${createAuthKey()}`;
if (redis.enabled) { if (redis.enabled) {
await redis.client.set(authKey, data); await redis.client.set(authKey, data);

View file

@ -63,3 +63,7 @@ export function uuid(...args: any) {
return process.env.USE_UUIDV7 ? v7() : v4(); return process.env.USE_UUIDV7 ? v7() : v4();
} }
export function createAuthKey() {
return crypto.randomBytes(16).toString('hex');
}

View file

@ -104,6 +104,23 @@ export const reportTypeParam = z.enum([
'utm', 'utm',
]); ]);
export const operatorParam = z.enum([
'eq',
'neq',
's',
'ns',
'c',
'dnc',
't',
'f',
'gt',
'lt',
'gte',
'lte',
'bf',
'af',
]);
export const goalReportSchema = z.object({ export const goalReportSchema = z.object({
type: z.literal('goal'), type: z.literal('goal'),
parameters: z parameters: z
@ -157,7 +174,7 @@ export const retentionReportSchema = z.object({
parameters: z.object({ parameters: z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
timezone: z.string().optional(), timezone: timezoneParam.optional(),
}), }),
}); });
@ -175,7 +192,7 @@ export const revenueReportSchema = z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
unit: unitParam.optional(), unit: unitParam.optional(),
timezone: z.string().optional(), timezone: timezoneParam.optional(),
currency: z.string(), currency: z.string(),
}), }),
}); });
@ -231,3 +248,22 @@ export const reportResultSchema = z.intersection(
); );
export const segmentTypeParam = z.enum(['segment', 'cohort']); export const segmentTypeParam = z.enum(['segment', 'cohort']);
export const segmentParametersSchema = z.object({
filters: z
.array(
z.object({
name: z.string(),
operator: operatorParam,
value: z.string(),
}),
)
.optional(),
dateRange: z.string().optional(),
action: z
.object({
type: z.string(),
value: z.string(),
})
.optional(),
});

View file

@ -16,7 +16,7 @@ export async function canCreateTeam({ user }: Auth) {
return true; return true;
} }
return !!user; return hasPermission(user.role, PERMISSIONS.teamCreate);
} }
export async function canUpdateTeam({ user }: Auth, teamId: string) { export async function canUpdateTeam({ user }: Auth, teamId: string) {

View file

@ -18,7 +18,7 @@ async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOpt
...criteria, ...criteria,
where: { where: {
...criteria.where, ...criteria.where,
...(showDeleted && { deletedAt: null }), ...(showDeleted ? {} : { deletedAt: null }),
}, },
select: { select: {
id: true, id: true,