@thinhnguyencth1204/nextcli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -24
- package/dist/cli.js +168 -107
- package/package.json +1 -1
- package/templates/next-base/.env +16 -0
- package/templates/next-base/.env.development +16 -0
- package/templates/next-base/.env.example +16 -0
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/messages/vi/auth.json +42 -0
- package/templates/next-base/messages/vi/common.json +34 -0
- package/templates/next-base/messages/vi/example.json +10 -0
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +24 -5
- package/templates/next-base/prisma/schema.prisma +84 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
- package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
- package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
- package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
- package/templates/next-base/src/app/layout.tsx +14 -6
- package/templates/next-base/src/app/page.tsx +2 -25
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
- package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
- package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
- package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
- package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
- package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
- package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
- package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
- package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
- package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +51 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
- package/templates/next-base/src/features/auth/validations.ts +14 -0
- package/templates/next-base/src/features/users/services.ts +132 -0
- package/templates/next-base/src/features/users/validations.ts +21 -0
- package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
- package/templates/next-base/src/hooks/use-mobile.ts +25 -0
- package/templates/next-base/src/i18n/config.ts +7 -0
- package/templates/next-base/src/i18n/namespaces.ts +5 -0
- package/templates/next-base/src/i18n/request.ts +25 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/api/axios.ts +145 -0
- package/templates/next-base/src/lib/api/response.ts +45 -0
- package/templates/next-base/src/lib/api/token-store.ts +13 -0
- package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
- package/templates/next-base/src/lib/auth/client.ts +7 -0
- package/templates/next-base/src/lib/auth/cookies.ts +15 -0
- package/templates/next-base/src/lib/auth/index.ts +1 -0
- package/templates/next-base/src/lib/auth/rbac.ts +59 -0
- package/templates/next-base/src/lib/auth/server.ts +21 -0
- package/templates/next-base/src/lib/constants.ts +10 -0
- package/templates/next-base/src/lib/db/prisma.ts +23 -0
- package/templates/next-base/src/lib/prisma.ts +23 -0
- package/templates/next-base/src/lib/supabase/client.ts +6 -0
- package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
- package/templates/next-base/src/lib/supabase/storage.ts +164 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { ApiErrorResponse, ApiMeta, ApiSuccess, ErrorCode } from "@/types";
|
|
3
|
+
|
|
4
|
+
type SuccessInit = {
|
|
5
|
+
status?: number;
|
|
6
|
+
requestId?: string;
|
|
7
|
+
meta?: Omit<ApiMeta, "timestamp" | "requestId">;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type FailInit = {
|
|
11
|
+
status?: number;
|
|
12
|
+
requestId?: string;
|
|
13
|
+
details?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function createTimestamp(): string {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ok<T>(data: T, init: SuccessInit = {}) {
|
|
21
|
+
const payload: ApiSuccess<T> = {
|
|
22
|
+
success: true,
|
|
23
|
+
data,
|
|
24
|
+
meta: {
|
|
25
|
+
timestamp: createTimestamp(),
|
|
26
|
+
requestId: init.requestId,
|
|
27
|
+
...init.meta,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return NextResponse.json(payload, { status: init.status ?? 200 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fail(code: ErrorCode, message: string, init: FailInit = {}) {
|
|
34
|
+
const payload: ApiErrorResponse = {
|
|
35
|
+
success: false,
|
|
36
|
+
error: {
|
|
37
|
+
code,
|
|
38
|
+
message,
|
|
39
|
+
details: init.details,
|
|
40
|
+
},
|
|
41
|
+
timestamp: createTimestamp(),
|
|
42
|
+
requestId: init.requestId,
|
|
43
|
+
};
|
|
44
|
+
return NextResponse.json(payload, { status: init.status ?? 500 });
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
let accessToken: string | null = null;
|
|
2
|
+
|
|
3
|
+
export function getAccessToken(): string | null {
|
|
4
|
+
return accessToken;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function setAccessToken(token: string | null): void {
|
|
8
|
+
accessToken = token;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearAccessToken(): void {
|
|
12
|
+
accessToken = null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { PrismaClient } from "@prisma/client";
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_ROLE_LEVEL,
|
|
4
|
+
ADMIN_ROLE_NAME,
|
|
5
|
+
DEFAULT_ADMIN_PASSWORD,
|
|
6
|
+
INTERNAL_EMAIL_DOMAIN,
|
|
7
|
+
SUPER_ADMIN_USERNAME,
|
|
8
|
+
} from "@/lib/constants";
|
|
9
|
+
import { auth } from "@/lib/auth";
|
|
10
|
+
import prisma from "@/lib/db/prisma";
|
|
11
|
+
|
|
12
|
+
function hasRoleModel(client: PrismaClient): boolean {
|
|
13
|
+
const delegate = (
|
|
14
|
+
client as PrismaClient & { role?: { findUnique?: unknown } }
|
|
15
|
+
).role;
|
|
16
|
+
return typeof delegate?.findUnique === "function";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function logBootstrapSkip(message: string): void {
|
|
20
|
+
console.warn(`[bootstrap] ${message}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runBootstrap(): Promise<void> {
|
|
24
|
+
if (!hasRoleModel(prisma)) {
|
|
25
|
+
logBootstrapSkip(
|
|
26
|
+
"Prisma client is missing the Role model. Run: bun run db:generate && bun run db:migrate",
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
let adminRole = await prisma.role.findUnique({
|
|
33
|
+
where: { name: ADMIN_ROLE_NAME },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!adminRole) {
|
|
37
|
+
adminRole = await prisma.role.create({
|
|
38
|
+
data: {
|
|
39
|
+
name: ADMIN_ROLE_NAME,
|
|
40
|
+
level: ADMIN_ROLE_LEVEL,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const existingAdmin = await prisma.user.findUnique({
|
|
46
|
+
where: { username: SUPER_ADMIN_USERNAME },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (existingAdmin) {
|
|
50
|
+
if (!existingAdmin.roleId) {
|
|
51
|
+
await prisma.user.update({
|
|
52
|
+
where: { id: existingAdmin.id },
|
|
53
|
+
data: { roleId: adminRole.id },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await auth.api.signUpEmail({
|
|
61
|
+
body: {
|
|
62
|
+
email: `${SUPER_ADMIN_USERNAME}@${INTERNAL_EMAIL_DOMAIN}`,
|
|
63
|
+
password: DEFAULT_ADMIN_PASSWORD,
|
|
64
|
+
name: "Administrator",
|
|
65
|
+
username: SUPER_ADMIN_USERNAME,
|
|
66
|
+
displayUsername: SUPER_ADMIN_USERNAME,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
// User may already exist from a partial bootstrap run.
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const adminUser = await prisma.user.findUnique({
|
|
74
|
+
where: { username: SUPER_ADMIN_USERNAME },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!adminUser) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await prisma.user.update({
|
|
82
|
+
where: { id: adminUser.id },
|
|
83
|
+
data: {
|
|
84
|
+
roleId: adminRole.id,
|
|
85
|
+
requirePasswordChange: true,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message =
|
|
90
|
+
error instanceof Error ? error.message : "Unknown bootstrap error";
|
|
91
|
+
logBootstrapSkip(
|
|
92
|
+
`Skipped admin seed (${message}). Ensure Postgres is running and run: bun run db:migrate`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createAuthClient } from "better-auth/react";
|
|
2
|
+
import { jwtClient, usernameClient } from "better-auth/client/plugins";
|
|
3
|
+
|
|
4
|
+
export const authClient = createAuthClient({
|
|
5
|
+
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
|
6
|
+
plugins: [jwtClient(), usernameClient()],
|
|
7
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const refreshCookieName = "nextcli_refresh_token";
|
|
2
|
+
|
|
3
|
+
export function refreshCookieOptions() {
|
|
4
|
+
return {
|
|
5
|
+
httpOnly: true,
|
|
6
|
+
secure: process.env.NODE_ENV === "production",
|
|
7
|
+
sameSite: "lax",
|
|
8
|
+
path: "/",
|
|
9
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
10
|
+
} as const;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getRefreshCookieName(): string {
|
|
14
|
+
return refreshCookieName;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { auth } from "./server";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import type { Role, User } from "@prisma/client";
|
|
3
|
+
import { auth } from "@/lib/auth";
|
|
4
|
+
import prisma from "@/lib/db/prisma";
|
|
5
|
+
import { SUPER_ADMIN_USERNAME } from "@/lib/constants";
|
|
6
|
+
|
|
7
|
+
export type SessionUser = User & { role: Role | null };
|
|
8
|
+
|
|
9
|
+
export async function getSessionUser(
|
|
10
|
+
requestHeaders?: Headers,
|
|
11
|
+
): Promise<SessionUser | null> {
|
|
12
|
+
const session = await auth.api.getSession({
|
|
13
|
+
headers: requestHeaders ?? (await headers()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!session?.user?.id) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return prisma.user.findUnique({
|
|
21
|
+
where: { id: session.user.id },
|
|
22
|
+
include: { role: true },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSuperAdmin(user: Pick<User, "username">): boolean {
|
|
27
|
+
return user.username === SUPER_ADMIN_USERNAME;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function canActOnUser(actor: SessionUser, target: SessionUser): boolean {
|
|
31
|
+
if (isSuperAdmin(target)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!actor.role || !target.role) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return actor.role.level > target.role.level;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canAssignRole(
|
|
43
|
+
actor: SessionUser,
|
|
44
|
+
targetRole: Pick<Role, "level">,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (!actor.role) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return actor.role.level > targetRole.level;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requireAuthenticated(
|
|
54
|
+
user: SessionUser | null,
|
|
55
|
+
): asserts user is SessionUser {
|
|
56
|
+
if (!user) {
|
|
57
|
+
throw new Error("UNAUTHORIZED");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth/minimal";
|
|
2
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
3
|
+
import { jwt, username } from "better-auth/plugins";
|
|
4
|
+
import prisma from "@/lib/db/prisma";
|
|
5
|
+
|
|
6
|
+
const socialProviders = {
|
|
7
|
+
// AUTO_GENERATED_AUTH_PROVIDERS_START
|
|
8
|
+
// AUTO_GENERATED_AUTH_PROVIDERS_END
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const auth = betterAuth({
|
|
12
|
+
database: prismaAdapter(prisma, {
|
|
13
|
+
provider: "postgresql",
|
|
14
|
+
}),
|
|
15
|
+
plugins: [jwt(), username()],
|
|
16
|
+
emailAndPassword: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
minPasswordLength: 8,
|
|
19
|
+
},
|
|
20
|
+
socialProviders,
|
|
21
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Built-in super-admin account username — protected from RBAC mutations. */
|
|
2
|
+
export const SUPER_ADMIN_USERNAME = "admin";
|
|
3
|
+
|
|
4
|
+
/** Dev-only bootstrap password (≥8 chars for Better Auth). Change on first login. */
|
|
5
|
+
export const DEFAULT_ADMIN_PASSWORD = "admin1234";
|
|
6
|
+
|
|
7
|
+
export const ADMIN_ROLE_NAME = "admin";
|
|
8
|
+
export const ADMIN_ROLE_LEVEL = 100;
|
|
9
|
+
|
|
10
|
+
export const INTERNAL_EMAIL_DOMAIN = "internal.local";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
+
import { PrismaClient } from "@prisma/client";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
var prisma: PrismaClient | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const connectionString = process.env.DATABASE_URL;
|
|
10
|
+
if (!connectionString) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"DATABASE_URL is missing. Please set it in your environment.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const adapter = new PrismaPg(new Pool({ connectionString }));
|
|
17
|
+
const prisma = global.prisma ?? new PrismaClient({ adapter });
|
|
18
|
+
|
|
19
|
+
if (process.env.NODE_ENV !== "production") {
|
|
20
|
+
global.prisma = prisma;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default prisma;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
+
import { PrismaClient } from "@prisma/client";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
var prisma: PrismaClient | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const connectionString = process.env.DATABASE_URL;
|
|
10
|
+
if (!connectionString) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"DATABASE_URL is missing. Please set it in your environment.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const adapter = new PrismaPg(new Pool({ connectionString }));
|
|
17
|
+
const prisma = global.prisma ?? new PrismaClient({ adapter });
|
|
18
|
+
|
|
19
|
+
if (process.env.NODE_ENV !== "production") {
|
|
20
|
+
global.prisma = prisma;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default prisma;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { removeResource } from "@/lib/supabase/storage";
|
|
2
|
+
import {
|
|
3
|
+
isSupabaseManagedImageUrl,
|
|
4
|
+
syncRemovedRichTextImages,
|
|
5
|
+
type RemoveManagedImageFn,
|
|
6
|
+
} from "@/lib/rich-text";
|
|
7
|
+
import type { SerializedEditorState } from "lexical";
|
|
8
|
+
|
|
9
|
+
export const removeSupabaseManagedImage: RemoveManagedImageFn = async (
|
|
10
|
+
_url,
|
|
11
|
+
ref,
|
|
12
|
+
) => {
|
|
13
|
+
await removeResource(ref.path, ref.bucket);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deletes Supabase storage objects for images removed from rich text content.
|
|
18
|
+
* Only URLs belonging to this project's configured Supabase bucket are removed.
|
|
19
|
+
*/
|
|
20
|
+
export async function syncRemovedSupabaseRichTextImages(
|
|
21
|
+
previous: SerializedEditorState | null | undefined,
|
|
22
|
+
next: SerializedEditorState | null | undefined,
|
|
23
|
+
) {
|
|
24
|
+
return syncRemovedRichTextImages(previous, next, {
|
|
25
|
+
isManagedUrl: isSupabaseManagedImageUrl,
|
|
26
|
+
removeManagedImage: removeSupabaseManagedImage,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const DEFAULT_STORAGE_BUCKET =
|
|
2
|
+
process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || "public";
|
|
3
|
+
|
|
4
|
+
export const STORAGE_RESOURCE_CATEGORIES = [
|
|
5
|
+
"images",
|
|
6
|
+
"documents",
|
|
7
|
+
"avatars",
|
|
8
|
+
"misc",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type StorageResourceCategory =
|
|
12
|
+
(typeof STORAGE_RESOURCE_CATEGORIES)[number];
|
|
13
|
+
|
|
14
|
+
const CATEGORY_DEFAULTS: Record<
|
|
15
|
+
StorageResourceCategory,
|
|
16
|
+
{ maxBytes: number; allowedMimePrefixes: string[] }
|
|
17
|
+
> = {
|
|
18
|
+
images: { maxBytes: 5 * 1024 * 1024, allowedMimePrefixes: ["image/"] },
|
|
19
|
+
documents: {
|
|
20
|
+
maxBytes: 10 * 1024 * 1024,
|
|
21
|
+
allowedMimePrefixes: [
|
|
22
|
+
"application/pdf",
|
|
23
|
+
"application/msword",
|
|
24
|
+
"application/vnd.",
|
|
25
|
+
"text/",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
avatars: { maxBytes: 2 * 1024 * 1024, allowedMimePrefixes: ["image/"] },
|
|
29
|
+
misc: { maxBytes: 10 * 1024 * 1024, allowedMimePrefixes: [] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function getStorageCategoryDefaults(category: StorageResourceCategory) {
|
|
33
|
+
return CATEGORY_DEFAULTS[category];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sanitizeStorageFileName(fileName: string): string {
|
|
37
|
+
const base = fileName.split(/[/\\]/).pop() ?? "file";
|
|
38
|
+
const sanitized = base
|
|
39
|
+
.normalize("NFKD")
|
|
40
|
+
.replace(/[^\w.\-]+/g, "-")
|
|
41
|
+
.replace(/-+/g, "-")
|
|
42
|
+
.replace(/^-+|-+$/g, "")
|
|
43
|
+
.slice(0, 120);
|
|
44
|
+
|
|
45
|
+
return sanitized || "file";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeStorageScope(scope: string): string {
|
|
49
|
+
const sanitized = scope
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
52
|
+
.replace(/-+/g, "-")
|
|
53
|
+
.slice(0, 64);
|
|
54
|
+
|
|
55
|
+
return sanitized || "shared";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildStorageObjectPath(input: {
|
|
59
|
+
scope: string;
|
|
60
|
+
category: StorageResourceCategory;
|
|
61
|
+
fileName: string;
|
|
62
|
+
uniqueId?: string;
|
|
63
|
+
}): string {
|
|
64
|
+
const scope = sanitizeStorageScope(input.scope);
|
|
65
|
+
const uniqueId = input.uniqueId ?? crypto.randomUUID();
|
|
66
|
+
const safeName = sanitizeStorageFileName(input.fileName);
|
|
67
|
+
|
|
68
|
+
return `${scope}/${input.category}/${uniqueId}-${safeName}`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { supabase } from "@/lib/supabase/client";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_STORAGE_BUCKET,
|
|
4
|
+
buildStorageObjectPath,
|
|
5
|
+
getStorageCategoryDefaults,
|
|
6
|
+
type StorageResourceCategory,
|
|
7
|
+
} from "@/lib/supabase/storage-config";
|
|
8
|
+
|
|
9
|
+
export type UploadResourceInput = {
|
|
10
|
+
file: File | Blob;
|
|
11
|
+
fileName: string;
|
|
12
|
+
/** Tenant/user/feature namespace, e.g. user id or `shared`. */
|
|
13
|
+
scope: string;
|
|
14
|
+
category?: StorageResourceCategory;
|
|
15
|
+
bucket?: string;
|
|
16
|
+
upsert?: boolean;
|
|
17
|
+
cacheControl?: string;
|
|
18
|
+
contentType?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type UploadResourceResult = {
|
|
22
|
+
bucket: string;
|
|
23
|
+
path: string;
|
|
24
|
+
publicUrl: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class StorageHelperError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly code:
|
|
31
|
+
| "NOT_CONFIGURED"
|
|
32
|
+
| "VALIDATION"
|
|
33
|
+
| "UPLOAD_FAILED"
|
|
34
|
+
| "REMOVE_FAILED",
|
|
35
|
+
readonly cause?: unknown,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "StorageHelperError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getClient() {
|
|
43
|
+
if (!supabase) {
|
|
44
|
+
throw new StorageHelperError(
|
|
45
|
+
"Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.",
|
|
46
|
+
"NOT_CONFIGURED",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return supabase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveContentType(
|
|
54
|
+
file: File | Blob,
|
|
55
|
+
override?: string,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (override) {
|
|
58
|
+
return override;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (file instanceof File && file.type) {
|
|
62
|
+
return file.type;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertCategoryRules(
|
|
69
|
+
category: StorageResourceCategory,
|
|
70
|
+
file: File | Blob,
|
|
71
|
+
contentType?: string,
|
|
72
|
+
): void {
|
|
73
|
+
const { maxBytes, allowedMimePrefixes } =
|
|
74
|
+
getStorageCategoryDefaults(category);
|
|
75
|
+
|
|
76
|
+
if (file.size > maxBytes) {
|
|
77
|
+
throw new StorageHelperError(
|
|
78
|
+
`File exceeds ${category} limit of ${maxBytes} bytes.`,
|
|
79
|
+
"VALIDATION",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (allowedMimePrefixes.length === 0) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mime = contentType ?? (file instanceof File ? file.type : "");
|
|
88
|
+
|
|
89
|
+
if (!mime || !allowedMimePrefixes.some((prefix) => mime.startsWith(prefix))) {
|
|
90
|
+
throw new StorageHelperError(
|
|
91
|
+
`File type "${mime || "unknown"}" is not allowed for category "${category}".`,
|
|
92
|
+
"VALIDATION",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getResourcePublicUrl(
|
|
98
|
+
path: string,
|
|
99
|
+
bucket: string = DEFAULT_STORAGE_BUCKET,
|
|
100
|
+
): string {
|
|
101
|
+
const client = getClient();
|
|
102
|
+
const { data } = client.storage.from(bucket).getPublicUrl(path);
|
|
103
|
+
|
|
104
|
+
return data.publicUrl;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function uploadResource(
|
|
108
|
+
input: UploadResourceInput,
|
|
109
|
+
): Promise<UploadResourceResult> {
|
|
110
|
+
const client = getClient();
|
|
111
|
+
const bucket = input.bucket ?? DEFAULT_STORAGE_BUCKET;
|
|
112
|
+
const category = input.category ?? "images";
|
|
113
|
+
const contentType = resolveContentType(input.file, input.contentType);
|
|
114
|
+
|
|
115
|
+
assertCategoryRules(category, input.file, contentType);
|
|
116
|
+
|
|
117
|
+
const path = buildStorageObjectPath({
|
|
118
|
+
scope: input.scope,
|
|
119
|
+
category,
|
|
120
|
+
fileName: input.fileName,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const { error } = await client.storage.from(bucket).upload(path, input.file, {
|
|
124
|
+
contentType,
|
|
125
|
+
cacheControl: input.cacheControl ?? "3600",
|
|
126
|
+
upsert: input.upsert ?? false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (error) {
|
|
130
|
+
throw new StorageHelperError(
|
|
131
|
+
error.message || "Upload failed.",
|
|
132
|
+
"UPLOAD_FAILED",
|
|
133
|
+
error,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
bucket,
|
|
139
|
+
path,
|
|
140
|
+
publicUrl: getResourcePublicUrl(path, bucket),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function uploadImage(
|
|
145
|
+
input: Omit<UploadResourceInput, "category">,
|
|
146
|
+
): Promise<UploadResourceResult> {
|
|
147
|
+
return uploadResource({ ...input, category: "images" });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function removeResource(
|
|
151
|
+
path: string,
|
|
152
|
+
bucket: string = DEFAULT_STORAGE_BUCKET,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const client = getClient();
|
|
155
|
+
const { error } = await client.storage.from(bucket).remove([path]);
|
|
156
|
+
|
|
157
|
+
if (error) {
|
|
158
|
+
throw new StorageHelperError(
|
|
159
|
+
error.message || "Remove failed.",
|
|
160
|
+
"REMOVE_FAILED",
|
|
161
|
+
error,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|