Skip to content
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Supabase Configuration
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key

# Sentry Configuration (Optional)
VITE_SENTRY_DSN=your_sentry_dsn

# Drawing Limit (Optional)
# Set the maximum number of drawings a user can create
# If not set, users can create unlimited drawings
# Example: VITE_MAX_DRAWINGS_PER_USER=10
# VITE_MAX_DRAWINGS_PER_USER=

# Unlimited Users (Optional)
# Comma-separated list of user emails that bypass the drawing limit
# These users can create unlimited drawings even if VITE_MAX_DRAWINGS_PER_USER is set
# Example: VITE_UNLIMITED_USERS=admin@example.com,manager@example.com
# VITE_UNLIMITED_USERS=
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Draw is a wrapper around Excalidraw, integrated with Supabase to save and sync y
* Cloud Sync: Uses Supabase for authentication and storage, ensuring secure access and synchronization of your drawings.
+ Folders: Organize drawings into folders.
+ Sidebar: Navigate and manage folders and drawings from a sleek sidebar.
+ Drawing Limit: Optional environment variable to limit the number of drawings per user.
- Clunky UI: Removed clunky, unnecessary ui components from the UI.
```

Expand All @@ -31,6 +32,27 @@ We have set up a one-click deploy to Vercel.

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/KainoaNewton/draw)

### Drawing Limit (Optional)

You can optionally limit the number of drawings a user can create by setting the `VITE_MAX_DRAWINGS_PER_USER` environment variable in your Vercel deployment (or any other hosting platform).

For example, to limit users to 10 drawings:
- In Vercel: Go to your project settings → Environment Variables → Add `VITE_MAX_DRAWINGS_PER_USER` with value `10`
- In `.env` file: Add `VITE_MAX_DRAWINGS_PER_USER=10`

If this environment variable is not set, users can create unlimited drawings (default behavior).

#### Unlimited Users Bypass List

You can also specify a list of user emails that bypass the drawing limit by setting the `VITE_UNLIMITED_USERS` environment variable. This is useful for administrators or premium users who should have unlimited access.

For example:
- In Vercel: Add `VITE_UNLIMITED_USERS` with value `admin@example.com,manager@example.com,premium@example.com`
- In `.env` file: Add `VITE_UNLIMITED_USERS=admin@example.com,manager@example.com`

Users in this list can create unlimited drawings even when `VITE_MAX_DRAWINGS_PER_USER` is set. The comparison is case-insensitive and supports multiple emails separated by commas.


If you want to deploy using Docker, you can use the provided docker-compose file, using the instruction in the [Docker](https://github.com/kainoanewton/draw/blob/main/docs/docker.md) section.

If you'd like to build the app yourself, run:
Expand Down
8 changes: 7 additions & 1 deletion src/components/SearchCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {

const response = await createNewPage(undefined, targetFolderId);
if (response.error) {
toast.error("Failed to create page");
if (response.error.code === 'DrawingLimitReached') {
toast.error("Drawing limit reached", {
description: response.error.message,
});
} else {
toast.error("Failed to create page");
}
return;
}

Expand Down
24 changes: 18 additions & 6 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,9 +468,15 @@ export default function Sidebar({ className }: SidebarProps) {
toast("Successfully created a new page!");
}
if (data.error) {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
if (data.error.code === 'DrawingLimitReached') {
toast.error("Drawing limit reached", {
description: data.error.message,
});
} else {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
}
}
}

Expand Down Expand Up @@ -499,9 +505,15 @@ export default function Sidebar({ className }: SidebarProps) {
toast("Successfully created a new drawing!");
}
if (data.error) {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
if (data.error.code === 'DrawingLimitReached') {
toast.error("Drawing limit reached", {
description: data.error.message,
});
} else {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
}
}
}

Expand Down
69 changes: 69 additions & 0 deletions src/db/draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ export type DBResponse = {
export const DB_NAME = "draw";
export const FOLDERS_DB_NAME = "folders";

// Cache parsed unlimited users list to avoid repeated parsing
let cachedUnlimitedUsers: string[] = [];
let cachedUnlimitedUsersEnv: string | undefined = undefined;

function getUnlimitedUsers(): string[] {
const unlimitedUsersEnv = import.meta.env.VITE_UNLIMITED_USERS;

// Return cached list if environment variable hasn't changed
if (unlimitedUsersEnv === cachedUnlimitedUsersEnv) {
return cachedUnlimitedUsers;
}

// Parse and cache the list
cachedUnlimitedUsersEnv = unlimitedUsersEnv;
if (unlimitedUsersEnv) {
cachedUnlimitedUsers = unlimitedUsersEnv.split(',').map((email: string) => email.trim().toLowerCase());
} else {
cachedUnlimitedUsers = [];
}

return cachedUnlimitedUsers;
}

export type Folder = {
folder_id: string;
name: string;
Expand Down Expand Up @@ -46,6 +69,52 @@ export async function createNewPage(
): Promise<DBResponse> {
const { data: profile, error: profileError } = await supabase.auth.getUser();
if (profile) {
// Check drawing limit if environment variable is set
const maxDrawings = import.meta.env.VITE_MAX_DRAWINGS_PER_USER;
if (maxDrawings) {
const limit = parseInt(maxDrawings, 10);
if (!isNaN(limit) && limit > 0) {
// Check if user is in the unlimited users list (bypass list)
const userEmail = profile.user?.email;
let isUnlimitedUser = false;

if (userEmail) {
const unlimitedUsers = getUnlimitedUsers();
isUnlimitedUser = unlimitedUsers.includes(userEmail.toLowerCase());
}

// Only enforce limit if user is not in the unlimited users list
if (!isUnlimitedUser) {
// Count existing non-deleted drawings for this user
// Using .not() to exclude only explicitly deleted drawings, including NULL as non-deleted
const { count, error: countError } = await supabase
.from(DB_NAME)
.select('page_id', { count: 'exact', head: true })
.eq("user_id", profile.user?.id)
.not('is_deleted', 'eq', true);
Comment on lines +90 to +94
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The code incorrectly accesses profile.user?.id to get the user ID. The profile variable is the user object itself, so profile.user is undefined, causing the check to fail.
Severity: HIGH

Suggested Fix

Correct the property access for the user ID. Instead of using profile.user?.id, use profile?.id to correctly reference the ID on the user object. This will ensure the database query to count drawings uses the correct user ID.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/db/draw.ts#L90-L94

Potential issue: The `createNewPage` function fetches the user with
`supabase.auth.getUser()` and assigns the `data` object to a variable named `profile`.
The code then attempts to access the user's ID via `profile.user?.id`. However, the
`profile` variable is the user object itself, not an object containing a `user`
property. This means `profile.user` is always `undefined`, and the database query to
count existing drawings is performed with an `undefined` user ID. This causes the query
to fail, and the error is caught, preventing any user from creating new drawings as the
limit check can never be successfully completed.

Did we get this right? 👍 / 👎 to inform future reviews.


if (countError) {
return { data: null, error: countError };
}

// Log for debugging
console.log(`Drawing limit check: user has ${count} drawings, limit is ${limit}`);

if (count !== null && count >= limit) {
// Create a custom error to indicate limit reached
const limitError: PostgrestError = {
message: `You have reached the maximum limit of ${limit} drawing${limit === 1 ? '' : 's'} (you currently have ${count}). Please delete some drawings to create new ones.`,
details: '',
hint: '',
code: 'DrawingLimitReached',
name: 'PostgrestError',
};
return { data: null, error: limitError };
}
}
}
}

let finalFolderId = folder_id;

// If no folder_id provided, get or create default folder
Expand Down
12 changes: 9 additions & 3 deletions src/views/Pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,15 @@ export default function Pages() {
navigate({ to: "/page/$id", params: { id: data.data[0].page_id } });
}
if (data.error) {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
if (data.error.code === 'DrawingLimitReached') {
toast.error("Drawing limit reached", {
description: data.error.message,
});
} else {
toast("An error occurred", {
description: `Error: ${data.error.message}`,
});
}
}
}

Expand Down