+
+
+
+
\ No newline at end of file
diff --git a/layouts/login.vue b/layouts/login.vue
new file mode 100644
index 0000000..87676c6
--- /dev/null
+++ b/layouts/login.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/migrate.ts b/migrate.ts
new file mode 100644
index 0000000..a0ee6e4
--- /dev/null
+++ b/migrate.ts
@@ -0,0 +1,7 @@
+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
+import { Database } from "bun:sqlite";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+
+const sqlite = new Database("db.sqlite");
+const db = drizzle(sqlite);
+await migrate(db, { migrationsFolder: "./drizzle" });
\ No newline at end of file
diff --git a/nuxt.config.ts b/nuxt.config.ts
index bd94dd9..42774c7 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -116,6 +116,12 @@ export default defineNuxtConfig({
tasks: true,
},
},
+ runtimeConfig: {
+ session: {
+ password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
+ },
+ database: 'db.sqlite'
+ },
security: {
rateLimiter: false,
headers: {
diff --git a/package.json b/package.json
index 002d981..651235b 100644
--- a/package.json
+++ b/package.json
@@ -7,17 +7,19 @@
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/tailwindcss": "^6.12.2",
"@vueuse/nuxt": "^11.1.0",
+ "dotenv": "^16.4.5",
"drizzle-orm": "^0.35.3",
"nuxt": "^3.13.2",
"nuxt-security": "^2.0.0",
"radix-vue": "^1.9.8",
"vue": "latest",
- "vue-router": "latest"
+ "vue-router": "latest",
+ "zod": "^3.23.8"
},
"devDependencies": {
"@types/bun": "^1.1.12",
"better-sqlite3": "^11.5.0",
- "bun-types": "^1.1.33",
+ "bun-types": "^1.1.34",
"drizzle-kit": "^0.26.2"
}
}
diff --git a/pages/user/login.vue b/pages/user/login.vue
index ccdba11..a797649 100644
--- a/pages/user/login.vue
+++ b/pages/user/login.vue
@@ -1,3 +1,86 @@
- Login
-
\ No newline at end of file
+
+ Connexion
+
+
+ Connexion
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/user/register.vue b/pages/user/register.vue
index ee39853..48d2bfb 100644
--- a/pages/user/register.vue
+++ b/pages/user/register.vue
@@ -1,3 +1,155 @@
- Register
-
\ No newline at end of file
+
+ Inscription
+
+
+ Inscription
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/schemas/login.ts b/schemas/login.ts
new file mode 100644
index 0000000..c1db54c
--- /dev/null
+++ b/schemas/login.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const schema = z.object({
+ usernameOrEmail: z.string({ required_error: "Nom d'utilisateur ou email obligatoire" }),
+ password: z.string({ required_error: "Mot de passe obligatoire" }),
+});
+
+export type Login = z.infer;
\ No newline at end of file
diff --git a/schemas/registration.ts b/schemas/registration.ts
new file mode 100644
index 0000000..b9bba67
--- /dev/null
+++ b/schemas/registration.ts
@@ -0,0 +1,56 @@
+import { z } from "zod";
+
+function securePassword(password: string, ctx: z.RefinementCtx): void {
+ const lowercase = password.toLowerCase();
+ const uppercase = password.toUpperCase();
+
+ if(lowercase === password)
+ {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Votre mot de passe doit contenir au moins une majuscule",
+ });
+ }
+ if(uppercase === password)
+ {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Votre mot de passe doit contenir au moins une minuscule",
+ });
+ }
+ if(!/[0-9]/.test(password))
+ {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Votre mot de passe doit contenir au moins un chiffre",
+ });
+ }
+ if(!" !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => password.includes(e)))
+ {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Votre mot de passe doit contenir au moins un symbole",
+ });
+ }
+}
+
+export const schema = z.object({
+ username: z.string({ required_error: "Nom d'utilisateur obligatoire" }).min(3, "Votre nom d'utilisateur doit contenir au moins 3 caractères").max(32, "Votre nom d'utilisateur doit contenir au plus 32 caractères").superRefine((user, ctx) => {
+ const test = z.string().email().safeParse(user);
+ if(test.success)
+ {
+ ctx.addIssue({
+ code: z.ZodIssueCode.invalid_string,
+ validation: 'email',
+ message: "Votre nom d'utilisateur ne peut pas être une addresse mail",
+ });
+ }
+ }),
+ email: z.string({ required_error: "Email obligatoire" }).email("Adresse mail invalide"),
+ password: z.string({ required_error: "Mot de passe obligatoire" }).min(8, "Votre mot de passe doit contenir au moins 8 caractères").max(128, "Votre mot de passe doit contenir au moins 8 caractères").superRefine(securePassword),
+ data: z.object({
+
+ }).partial().nullish(),
+});
+
+export type Registration = z.infer;
\ No newline at end of file
diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts
new file mode 100644
index 0000000..f85bbf1
--- /dev/null
+++ b/server/api/auth/login.post.ts
@@ -0,0 +1,105 @@
+import useDatabase from '~/composables/useDatabase';
+import { schema } from '~/schemas/login';
+import type { User, UserExtendedData, UserRawData, UserSession, UserSessionRequired } from '~/types/auth';
+import { ZodError } from 'zod';
+import { checkSession, logSession } from '~/server/utils/user';
+import { usersTable } from '~/db/schema';
+import { eq, or, sql } from 'drizzle-orm';
+import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
+
+interface SuccessHandler
+{
+ success: true;
+ session: UserSession;
+}
+interface ErrorHandler
+{
+ success: false;
+ error: Error | ZodError<{
+ usernameOrEmail: string;
+ password: string;
+ }>;
+}
+type Return = SuccessHandler | ErrorHandler;
+
+export default defineEventHandler(async (e): Promise => {
+ try
+ {
+ const session = await getUserSession(e);
+ const db = useDatabase();
+
+ const checkedSession = await checkSession(e, session);
+
+ if(checkedSession !== undefined)
+ return checkedSession;
+
+ const body = await readValidatedBody(e, schema.safeParse);
+
+ if (!body.success)
+ {
+ await clearUserSession(e);
+
+ setResponseStatus(e, 406);
+ return { success: false, error: body.error };
+ }
+
+ const hash = await Bun.password.hash(body.data.password);
+ const id = db.select({ id: usersTable.id, hash: usersTable.hash }).from(usersTable).where(or(eq(usersTable.username, sql.placeholder('username')), eq(usersTable.email, sql.placeholder('username')))).prepare().get({ username: body.data.usernameOrEmail });
+
+ if(!id || !id.id || !id.hash)
+ {
+ await clearUserSession(e);
+
+ setResponseStatus(e, 401);
+ return { success: false, error: new ZodError([{ code: 'custom', path: ['username'], message: 'Identifiant inconnu' }]) };
+ }
+
+ const valid = await Bun.password.verify(body.data.password, id.hash);
+
+ if(!valid)
+ {
+ await clearUserSession(e);
+
+ setResponseStatus(e, 401);
+ return { success: false, error: new ZodError([{ code: 'custom', path: ['password'], message: 'Mot de passe incorrect' }]) };
+ }
+
+ const user = db.query.usersTable.findFirst({
+ columns: {
+ id: true,
+ email: true,
+ username: true,
+ state: true,
+ },
+ with: {
+ data: true,
+ },
+ where: (table) => eq(table.id, sql.placeholder('id'))
+ }).prepare().get({ id: id.id });
+
+ if(!user)
+ {
+ setResponseStatus(e, 401);
+ return { success: false, error: new Error('Données utilisateur introuvable') };
+ }
+
+ const data = await logSession(e, await setUserSession(e, {
+ user: {
+ ...user.data,
+ email: user.email,
+ username: user.username,
+ state: user.state,
+ }
+ }) as UserSessionRequired);
+
+ setResponseStatus(e, 201);
+ return { success: true, session: data };
+ }
+ catch(err: any)
+ {
+ console.error(err);
+
+ await clearUserSession(e);
+ return { success: false, error: err as Error };
+ }
+});
\ No newline at end of file
diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts
new file mode 100644
index 0000000..f082710
--- /dev/null
+++ b/server/api/auth/register.post.ts
@@ -0,0 +1,87 @@
+import { count, eq, sql } from 'drizzle-orm';
+import { ZodError, type ZodIssue } from 'zod';
+import useDatabase from '~/composables/useDatabase';
+import { usersDataTable, usersTable } from '~/db/schema';
+import { schema } from '~/schemas/registration';
+import { checkSession, logSession } from '~/server/utils/user';
+import type { UserSession, UserSessionRequired } from '~/types/auth';
+
+interface SuccessHandler
+{
+ success: true;
+ session: UserSession;
+}
+interface ErrorHandler
+{
+ success: false;
+ error: Error | ZodError<{
+ username: string;
+ email: string;
+ password: string;
+ }>;
+}
+type Return = SuccessHandler | ErrorHandler;
+
+export default defineEventHandler(async (e): Promise => {
+ try
+ {
+ const session = await getUserSession(e);
+ const db = useDatabase();
+
+ const checkedSession = await checkSession(e, session);
+
+ if(checkedSession !== undefined)
+ return checkedSession;
+
+ const body = await readValidatedBody(e, schema.safeParse);
+
+ if (!body.success)
+ {
+ await clearUserSession(e);
+
+ setResponseStatus(e, 406);
+ return { success: false, error: body.error };
+ }
+
+ const checkUsername = db.select({ count: count() }).from(usersTable).where(eq(usersTable.username, sql.placeholder('username'))).prepare().get({ username: body.data.username });
+ const checkEmail = db.select({ count: count() }).from(usersTable).where(eq(usersTable.email, sql.placeholder('email'))).prepare().get({ email: body.data.email });
+
+ const errors: ZodIssue[] = [];
+ if(!checkUsername || checkUsername.count !== 0)
+ errors.push({ code: 'custom', path: ['username'], message: "Ce nom d'utilisateur est déjà utilisé" });
+ if(!checkEmail || checkEmail.count !== 0)
+ errors.push({ code: 'custom', path: ['email'], message: "Cette adresse mail est déjà utilisée" });
+
+ if(errors.length > 0)
+ {
+ setResponseStatus(e, 406);
+ return { success: false, error: new ZodError(errors) };
+ }
+ else
+ {
+ const hash = await Bun.password.hash(body.data.password);
+ db.insert(usersTable).values({ username: sql.placeholder('username'), email: sql.placeholder('email'), hash: sql.placeholder('hash'), state: sql.placeholder('state') }).prepare().run({ username: body.data.username, email: body.data.email, hash, state: 0 });
+ const id = db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.username, sql.placeholder('username'))).prepare().get({ username: body.data.username });
+
+ if(!id || !id.id)
+ {
+ setResponseStatus(e, 406);
+ return { success: false, error: new Error('Erreur de création de compte') };
+ }
+
+ db.insert(usersDataTable).values({ id: sql.placeholder('id') }).prepare().run({ id: id.id });
+
+ logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date() } }) as UserSessionRequired);
+
+ setResponseStatus(e, 201);
+ return { success: true, session };
+ }
+ }
+ catch(err: any)
+ {
+ console.error(err);
+
+ await clearUserSession(e);
+ return { success: false, error: err as Error };
+ }
+});
\ No newline at end of file
diff --git a/server/api/auth/session.delete.ts b/server/api/auth/session.delete.ts
new file mode 100644
index 0000000..a21b435
--- /dev/null
+++ b/server/api/auth/session.delete.ts
@@ -0,0 +1,8 @@
+import { eventHandler } from 'h3';
+import { clearUserSession } from '~/server/utils/session';
+
+export default eventHandler(async (event) => {
+ await clearUserSession(event);
+
+ return { loggedOut: true };
+})
\ No newline at end of file
diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts
new file mode 100644
index 0000000..a7245a9
--- /dev/null
+++ b/server/api/auth/session.get.ts
@@ -0,0 +1,13 @@
+import { eventHandler } from 'h3'
+import { getUserSession, sessionHooks } from '~/server/utils/session'
+import type { UserSessionRequired } from '~/types/auth'
+
+export default eventHandler(async (event) => {
+ const session = await getUserSession(event)
+
+ if (session.user) {
+ await sessionHooks.callHookParallel('fetch', session as UserSessionRequired, event)
+ }
+
+ return session
+})
\ No newline at end of file
diff --git a/server/api/project.get.ts b/server/api/project.get.ts
new file mode 100644
index 0000000..1d63086
--- /dev/null
+++ b/server/api/project.get.ts
@@ -0,0 +1,31 @@
+import useDatabase from '~/composables/useDatabase';
+import { ProjectSearch } from '~/types/api';
+
+export default defineEventHandler(async (e) => {
+ const query = getQuery(e);
+
+ const where = ["f.type != $type"];
+ const criteria: Record = { $type: "Folder" };
+
+ if(query && query.owner !== undefined)
+ {
+ where.push("owner = $owner");
+ criteria["$owner"] = query.owner;
+ }
+ if(query && query.name !== undefined)
+ {
+ where.push("name = $name");
+ criteria["$name"] = query.name;
+ }
+
+ const db = useDatabase();
+
+ const content = db.query(`SELECT p.*, u.username, COUNT(f.path) as pages FROM explorer_projects p LEFT JOIN users u ON p.owner = u.id LEFT JOIN explorer_files f ON f.project = p.id WHERE ${where.join(" AND ")} GROUP BY p.id`).all(criteria) as ProjectSearch[];
+
+ if(content.length > 0)
+ {
+ return content;
+ }
+
+ setResponseStatus(e, 404);
+});
\ No newline at end of file
diff --git a/server/api/project/[projectId].get.ts b/server/api/project/[projectId].get.ts
new file mode 100644
index 0000000..35af39f
--- /dev/null
+++ b/server/api/project/[projectId].get.ts
@@ -0,0 +1,20 @@
+import useDatabase from '~/composables/useDatabase';
+
+export default defineEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+
+ const where = ["id = $id"];
+ const criteria: Record = { $id: project };
+
+ if (!!project) {
+ const db = useDatabase();
+
+ const content = db.query(`SELECT * FROM explorer_projects WHERE ${where.join(" and ")}`).get(criteria) as Project;
+
+ if (content) {
+ return content;
+ }
+ }
+
+ setResponseStatus(e, 404);
+});
\ No newline at end of file
diff --git a/server/api/project/[projectId].patch.ts b/server/api/project/[projectId].patch.ts
new file mode 100644
index 0000000..82694f5
--- /dev/null
+++ b/server/api/project/[projectId].patch.ts
@@ -0,0 +1,20 @@
+import useDatabase from '~/composables/useDatabase';
+
+export default defineEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+
+ const where = ["project = $project"];
+ const criteria: Record = { $project: project };
+
+ if (!!project) {
+ const db = useDatabase();
+
+ const content = db.query(`SELECT * FROM explorer_projects WHERE ${where.join(" and ")}`).all(criteria) as Project[];
+
+ if (content.length > 0) {
+ return content;
+ }
+ }
+
+ setResponseStatus(e, 404);
+});
\ No newline at end of file
diff --git a/server/api/project/[projectId].post.ts b/server/api/project/[projectId].post.ts
new file mode 100644
index 0000000..82694f5
--- /dev/null
+++ b/server/api/project/[projectId].post.ts
@@ -0,0 +1,20 @@
+import useDatabase from '~/composables/useDatabase';
+
+export default defineEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+
+ const where = ["project = $project"];
+ const criteria: Record = { $project: project };
+
+ if (!!project) {
+ const db = useDatabase();
+
+ const content = db.query(`SELECT * FROM explorer_projects WHERE ${where.join(" and ")}`).all(criteria) as Project[];
+
+ if (content.length > 0) {
+ return content;
+ }
+ }
+
+ setResponseStatus(e, 404);
+});
\ No newline at end of file
diff --git a/server/api/project/[projectId]/access.post.ts b/server/api/project/[projectId]/access.post.ts
new file mode 100644
index 0000000..e69de29
diff --git a/server/api/project/[projectId]/comment.post.ts b/server/api/project/[projectId]/comment.post.ts
new file mode 100644
index 0000000..e69de29
diff --git a/server/api/project/[projectId]/file.get.ts b/server/api/project/[projectId]/file.get.ts
new file mode 100644
index 0000000..26db9fb
--- /dev/null
+++ b/server/api/project/[projectId]/file.get.ts
@@ -0,0 +1,54 @@
+import useDatabase from '~/composables/useDatabase';
+import type { File } from '~/types/api';
+
+export default defineCachedEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+ const query = getQuery(e);
+
+ if(!project)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+
+ const where = ["project = $project"];
+ const criteria: Record = { $project: project };
+
+ if(query && query.path !== undefined)
+ {
+ where.push("path = $path");
+ criteria["$path"] = query.path;
+ }
+ if(query && query.title !== undefined)
+ {
+ where.push("title = $title");
+ criteria["$title"] = query.title;
+ }
+ if(query && query.type !== undefined)
+ {
+ where.push("type = $type");
+ criteria["$type"] = query.type;
+ }
+ if (query && query.search !== undefined)
+ {
+ where.push("path LIKE $search");
+ criteria["$search"] = query.search;
+ }
+
+ if(where.length > 1)
+ {
+ const db = useDatabase();
+
+ const content = db.query(`SELECT * FROM explorer_files WHERE ${where.join(" and ")}`).all(criteria) as File[];
+
+ if(content.length > 0)
+ {
+ return content;
+ }
+ }
+
+ setResponseStatus(e, 404);
+}, {
+ maxAge: 60*60*24,
+ getKey: (e) => `${getRouterParam(e, "projectId")}-${JSON.stringify(getQuery(e))}`
+ });
\ No newline at end of file
diff --git a/server/api/project/[projectId]/file.post.ts b/server/api/project/[projectId]/file.post.ts
new file mode 100644
index 0000000..e69de29
diff --git a/server/api/project/[projectId]/file/[path].get.ts b/server/api/project/[projectId]/file/[path].get.ts
new file mode 100644
index 0000000..a03e65f
--- /dev/null
+++ b/server/api/project/[projectId]/file/[path].get.ts
@@ -0,0 +1,41 @@
+import useDatabase from '~/composables/useDatabase';
+import type { CommentedFile, CommentSearch, File } from '~/types/api';
+
+export default defineCachedEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+ const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
+
+ if(!project)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+ if(!path)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+
+ const where = ["project = $project", "path = $path"];
+ const criteria: Record = { $project: project, $path: path };
+
+ if(where.length > 1)
+ {
+ const db = useDatabase();
+
+ const content = db.query(`SELECT * FROM explorer_files WHERE ${where.join(" and ")}`).get(criteria) as File;
+
+ if(content !== undefined)
+ {
+ const comments = db.query(`SELECT comment.*, user.username FROM explorer_comments comment LEFT JOIN users user ON comment.user_id = user.id WHERE ${where.join(" and ")}`).all(criteria) as CommentSearch[];
+
+ return { ...content, comments } as CommentedFile;
+ }
+ }
+
+ setResponseStatus(e, 404);
+ return;
+}, {
+ maxAge: 60*60*24,
+ getKey: (e) => `file-${getRouterParam(e, "projectId")}-${getRouterParam(e, "path")}`
+ });
\ No newline at end of file
diff --git a/server/api/project/[projectId]/navigation.get.ts b/server/api/project/[projectId]/navigation.get.ts
new file mode 100644
index 0000000..0de8f58
--- /dev/null
+++ b/server/api/project/[projectId]/navigation.get.ts
@@ -0,0 +1,72 @@
+import useDatabase from '~/composables/useDatabase';
+import { Navigation } from '~/types/api';
+
+type NavigatioNExtension = Navigation & { owner: number, navigable: boolean };
+
+export default defineEventHandler(async (e) => {
+ const project = getRouterParam(e, "projectId");
+ const { user } = await getUserSession(e);
+
+ if(!project)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+
+ const db = useDatabase();
+
+ const content = db.query(`SELECT "path", "title", "type", "order", "private", "navigable", "owner" FROM explorer_files WHERE project = ?1`).all(project!).sort((a: any, b: any) => a.path.length - b.path.length) as NavigatioNExtension[];
+
+ if(content.length > 0)
+ {
+ const navigation: Navigation[] = [];
+
+ for(const idx in content)
+ {
+ const item = content[idx];
+ if(!!item.private && (user?.id ?? -1) !== item.owner || !item.navigable)
+ {
+ delete content[idx];
+ continue;
+ }
+
+ const parent = item.path.includes('/') ? item.path.substring(0, item.path.lastIndexOf('/')) : undefined;
+
+ if(parent && !content.find(e => e && e.path === parent))
+ {
+ delete content[idx];
+ continue;
+ }
+ }
+ for(const item of content.filter(e => !!e))
+ {
+ addChild(navigation, item);
+ }
+
+ return navigation;
+ }
+
+ setResponseStatus(e, 404);
+});
+
+function addChild(arr: Navigation[], e: Navigation): void
+{
+ const parent = arr.find(f => e.path.startsWith(f.path));
+
+ if(parent)
+ {
+ if(!parent.children)
+ parent.children = [];
+
+ addChild(parent.children, e);
+ }
+ else
+ {
+ arr.push({ title: e.title, path: e.path, type: e.type, order: e.order, private: e.private });
+ arr.sort((a, b) => {
+ if(a.order && b.order)
+ return a.order - b.order;
+ return a.title.localeCompare(b.title);
+ });
+ }
+}
\ No newline at end of file
diff --git a/server/api/project/[projectId]/tags/[tag].get.ts b/server/api/project/[projectId]/tags/[tag].get.ts
new file mode 100644
index 0000000..4b9f359
--- /dev/null
+++ b/server/api/project/[projectId]/tags/[tag].get.ts
@@ -0,0 +1,42 @@
+import useDatabase from '~/composables/useDatabase';
+import type { Tag } from '~/types/api';
+
+export default defineCachedEventHandler(async (e) => {
+ try
+ {
+ const project = getRouterParam(e, "projectId");
+ const tag = decodeURIComponent(getRouterParam(e, "tag") ?? '');
+
+ if(!project)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+ if(!tag)
+ {
+ setResponseStatus(e, 404);
+ return;
+ }
+
+ const where = ["project = $project", "tag = $tag"];
+ const criteria: Record = { $project: project, $tag: tag };
+
+ const db = useDatabase();
+ const content = db.query(`SELECT * FROM explorer_tags WHERE ${where.join(" and ")}`).get(criteria) as Tag;
+
+ if(content !== undefined)
+ {
+ return content;
+ }
+
+ setResponseStatus(e, 404);
+ }
+ catch(err)
+ {
+ console.error(err);
+ setResponseStatus(e, 500);
+ }
+}, {
+ maxAge: 60*60*24,
+ getKey: (e) => `tag-${getRouterParam(e, "projectId")}-${getRouterParam(e, "tag")}`
+ });
\ No newline at end of file
diff --git a/server/api/search.get.ts b/server/api/search.get.ts
new file mode 100644
index 0000000..265b3c1
--- /dev/null
+++ b/server/api/search.get.ts
@@ -0,0 +1,21 @@
+import useDatabase from '~/composables/useDatabase';
+
+export default defineEventHandler(async (e) => {
+ const query = getQuery(e);
+
+ if (query.search) {
+ const db = useDatabase();
+
+ const projects = db.query(`SELECT p.*, u.username, COUNT(f.path) as pages FROM explorer_projects p LEFT JOIN users u ON p.owner = u.id LEFT JOIN explorer_files f ON f.project = p.id WHERE name LIKE ?1 AND f.type != "Folder" GROUP BY p.id`).all(query.search) as ProjectSearch[];
+ const files = db.query(`SELECT f.*, u.username, count(c.path) as comments FROM explorer_files f LEFT JOIN users u ON f.owner = u.id LEFT JOIN explorer_comments c ON c.project = f.project AND c.path = f.path WHERE title LIKE ?1 AND private = 0 AND type != "Folder" GROUP BY f.project, f.path`).all(query.search) as FileSearch[];
+ const users = db.query(`SELECT id, username FROM users WHERE username LIKE ?1`).all(query.search) as UserSearch[];
+
+ return {
+ projects,
+ files,
+ users
+ } as Search;
+ }
+
+ setResponseStatus(e, 404);
+});
\ No newline at end of file
diff --git a/server/api/users/[id].get.ts b/server/api/users/[id].get.ts
new file mode 100644
index 0000000..1e65580
--- /dev/null
+++ b/server/api/users/[id].get.ts
@@ -0,0 +1,16 @@
+import useDatabase from "~/composables/useDatabase";
+import type { User } from "~/types/auth";
+
+export default defineEventHandler((e) => {
+ const id = getRouterParam(e, 'id');
+
+ if(!id)
+ {
+ setResponseStatus(e, 400);
+ return;
+ }
+
+ const db = useDatabase();
+
+ return db.query(`SELECT id, username, email, state, d.* FROM users u LEFT JOIN users_data d ON u.id = d.user_id WHERE u.id = ?1`).get(id) as User;
+});
\ No newline at end of file
diff --git a/server/api/users/[id]/comments.get.ts b/server/api/users/[id]/comments.get.ts
new file mode 100644
index 0000000..dd2956b
--- /dev/null
+++ b/server/api/users/[id]/comments.get.ts
@@ -0,0 +1,16 @@
+import useDatabase from "~/composables/useDatabase";
+import type { CommentSearch } from "~/types/api";
+
+export default defineEventHandler((e) => {
+ const id = getRouterParam(e, 'id');
+
+ if(!id)
+ {
+ setResponseStatus(e, 400);
+ return;
+ }
+
+ const db = useDatabase();
+
+ return db.query(`SELECT * FROM explorer_comments WHERE user_id = ?1`).all(id) as CommentSearch[];
+});
\ No newline at end of file
diff --git a/server/api/users/[id]/projects.get.ts b/server/api/users/[id]/projects.get.ts
new file mode 100644
index 0000000..74b3665
--- /dev/null
+++ b/server/api/users/[id]/projects.get.ts
@@ -0,0 +1,16 @@
+import useDatabase from "~/composables/useDatabase";
+import type { ProjectSearch } from "~/types/api";
+
+export default defineEventHandler((e) => {
+ const id = getRouterParam(e, 'id');
+
+ if(!id)
+ {
+ setResponseStatus(e, 400);
+ return;
+ }
+
+ const db = useDatabase();
+
+ return db.query(`SELECT p.*, count(f.path) as pages FROM explorer_projects p LEFT JOIN explorer_files f ON p.id = f.project WHERE p.owner = ?1`).all(id) as Omit[];
+});
\ No newline at end of file
diff --git a/server/plugins/session.ts b/server/plugins/session.ts
new file mode 100644
index 0000000..600ce9a
--- /dev/null
+++ b/server/plugins/session.ts
@@ -0,0 +1,45 @@
+import useDatabase from "~/composables/useDatabase";
+import { userSessionsTable as sessions } from "~/db/schema";
+import { eq, and } from "drizzle-orm";
+
+const monthAsMs = 60 * 60 * 24 * 30 * 1000;
+
+export default defineNitroPlugin(() => {
+ const db = useDatabase();
+
+ sessionHooks.hook('fetch', async (session, event) => {
+ const result = await db.query.userSessionsTable.findFirst({
+ columns: {
+ timestamp: true,
+ },
+ where: and(eq(sessions.id, session.id as unknown as number), eq(sessions.user_id, session.user.id))
+ });
+
+ if(!result)
+ {
+ await clearUserSession(event);
+ throw createError({ statusCode: 401, message: 'Unauthorized' });
+ }
+ else if(result && result.timestamp && result.timestamp.getTime() < Date.now() - monthAsMs)
+ {
+ await clearUserSession(event);
+ throw createError({ statusCode: 401, message: 'Session has expired' });
+ }
+ else
+ {
+ await db.update(sessions).set({
+ timestamp: new Date(),
+ }).where(and(eq(sessions.id, session.id as unknown as number), eq(sessions.user_id, session.user.id)));
+ }
+ });
+ sessionHooks.hook('clear', async (session, event) => {
+ if(session.id && session.user)
+ {
+ try
+ {
+ await db.delete(sessions).where(and(eq(sessions.id, session.id as unknown as number), eq(sessions.user_id, session.user.id)));
+ }
+ catch(e) { }
+ }
+ });
+});
\ No newline at end of file
diff --git a/server/tasks/sync.ts b/server/tasks/sync.ts
new file mode 100644
index 0000000..3354a2f
--- /dev/null
+++ b/server/tasks/sync.ts
@@ -0,0 +1,164 @@
+import useDatabase from "~/composables/useDatabase";
+import { extname, basename } from 'node:path';
+import type { File, FileType, Tag } from '~/types/api';
+import type { CanvasColor, CanvasContent } from "~/types/canvas";
+
+const typeMapping: Record = {
+ ".md": "Markdown",
+ ".canvas": "Canvas"
+};
+
+export default defineTask({
+ meta: {
+ name: 'sync',
+ description: 'Synchronise the project 1 with Obsidian',
+ },
+ async run(event) {
+ /* try {
+ const tree = await $fetch('https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/git/trees/master', {
+ method: 'get',
+ headers: {
+ accept: 'application/json',
+ },
+ params: {
+ recursive: true,
+ per_page: 1000,
+ }
+ }) as any;
+
+ const files: File[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e: any) => {
+ if(e.type === 'tree')
+ {
+ const title = basename(e.path);
+ const order = /(\d+)\. ?(.+)/gsmi.exec(title);
+ const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
+ return {
+ path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
+ order: order && order[1] ? order[1] : 50,
+ title: order && order[2] ? order[2] : title,
+ type: 'Folder',
+ content: null
+ }
+ }
+
+ const extension = extname(e.path);
+ const title = basename(e.path, extension);
+ const order = /(\d+)\. ?(.+)/gsmi.exec(title);
+ const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
+ const content = (await $fetch(`https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/raw/${encodeURIComponent(e.path)}`));
+
+ return {
+ path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
+ order: order && order[1] ? order[1] : 50,
+ title: order && order[2] ? order[2] : title,
+ type: (typeMapping[extension] ?? 'File'),
+ content: reshapeContent(content as string, typeMapping[extension] ?? 'File')
+ }
+ }));
+
+ let tags: Tag[] = [];
+ const tagFile = files.find(e => e.path === "tags");
+
+ if(tagFile)
+ {
+ const parsed = useMarkdown()(tagFile.content);
+ const titles = parsed.children.filter(e => e.type === 'element' && e.tagName.match(/h\d/));
+ for(let i = 0; i < titles.length; i++)
+ {
+ const start = titles[i].position?.start.offset ?? 0;
+ const end = titles.length === i + 1 ? tagFile.content.length : titles[i + 1].position.start.offset - 1;
+ tags.push({ tag: titles[i].properties.id, description: tagFile.content.substring(titles[i].position?.end.offset + 1, end) });
+ }
+ }
+
+ const db = useDatabase();
+
+ const oldFiles = db.prepare(`SELECT * FROM explorer_files WHERE project = ?1`).all('1') as File[];
+ const removeFiles = db.prepare(`DELETE FROM explorer_files WHERE project = ?1 AND path = ?2`);
+ db.transaction((data: File[]) => data.forEach(e => removeFiles.run('1', e.path)))(oldFiles.filter(e => !files.find(f => f.path = e.path)));
+ removeFiles.finalize();
+
+ const oldTags = db.prepare(`SELECT * FROM explorer_tags WHERE project = ?1`).all('1') as Tag[];
+ const removeTags = db.prepare(`DELETE FROM explorer_tags WHERE project = ?1 AND tag = ?2`);
+ db.transaction((data: Tag[]) => data.forEach(e => removeTags.run('1', e.tag)))(oldTags.filter(e => !tags.find(f => f.tag = e.tag)));
+ removeTags.finalize();
+
+ const insertFiles = db.prepare(`INSERT INTO explorer_files("project", "path", "owner", "title", "order", "type", "content") VALUES (1, $path, 1, $title, $order, $type, $content)`);
+ const updateFiles = db.prepare(`UPDATE explorer_files SET content = $content WHERE project = 1 AND path = $path`);
+ db.transaction((content) => {
+ for (const item of content) {
+ let order = item.order;
+
+ if (typeof order === 'string')
+ order = parseInt(item.order, 10);
+
+ if (isNaN(order))
+ order = 999;
+
+ if(oldFiles.find(e => item.path === e.path))
+ updateFiles.run({ $path: item.path, $content: item.content });
+ else
+ insertFiles.run({ $path: item.path, $title: item.title, $type: item.type, $content: item.content, $order: order });
+ }
+ })(files);
+
+ insertFiles.finalize();
+ updateFiles.finalize();
+
+ const insertTags = db.prepare(`INSERT INTO explorer_tags("project", "tag", "description") VALUES (1, $tag, $description)`);
+ const updateTags = db.prepare(`UPDATE explorer_tags SET description = $description WHERE project = 1 AND tag = $tag`);
+ db.transaction((content) => {
+ for (const item of content) {
+ if (oldTags.find(e => item.tag === e.tag))
+ updateTags.run({ $tag: item.tag, $description: item.description });
+ else
+ insertTags.run({ $tag: item.tag, $description: item.description });
+ }
+ })(tags);
+
+ insertTags.finalize();
+ updateTags.finalize();
+
+ useStorage('cache').clear();
+
+ return { result: true };
+ }
+ catch(e)
+ {
+ return { result: false };
+ } */
+ },
+})
+
+function reshapeContent(content: string, type: FileType): string | null
+{
+ switch(type)
+ {
+ case "Markdown":
+ case "File":
+ return content;
+ case "Canvas":
+ const data = JSON.parse(content) as CanvasContent;
+ data.edges.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
+ data.nodes.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
+ return JSON.stringify(data);
+ default:
+ case 'Folder':
+ return null;
+ }
+}
+function getColor(color: string): CanvasColor
+{
+ const colors: Record = {
+ '1': 'red',
+ '2': 'orange',
+ '3': 'yellow',
+ '4': 'green',
+ '5': 'cyan',
+ '6': 'purple',
+ };
+ if(colors.hasOwnProperty(color))
+ return { class: colors[color] };
+ else
+ return { hex: color };
+}
\ No newline at end of file
diff --git a/server/tsconfig.json b/server/tsconfig.json
index b9ed69c..1b107a3 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -1,3 +1,7 @@
{
- "extends": "../.nuxt/tsconfig.server.json"
-}
+ // https://nuxt.com/docs/guide/concepts/typescript
+ "extends": "../.nuxt/tsconfig.json",
+ "compilerOptions": {
+ "types": ["bun-types"],
+ }
+}
\ No newline at end of file
diff --git a/server/utils/session.ts b/server/utils/session.ts
new file mode 100644
index 0000000..6136a72
--- /dev/null
+++ b/server/utils/session.ts
@@ -0,0 +1,110 @@
+import type { H3Event, SessionConfig } from 'h3'
+import { useSession, createError } from 'h3'
+import { defu } from 'defu'
+import { createHooks } from 'hookable'
+import { useRuntimeConfig } from '#imports'
+import type { UserSession, UserSessionRequired } from '~/types/auth'
+
+export interface SessionHooks {
+ /**
+ * Called when fetching the session from the API
+ * - Add extra properties to the session
+ * - Throw an error if the session could not be verified (with a database for example)
+ */
+ fetch: (session: UserSessionRequired, event: H3Event) => void | Promise
+ /**
+ * Called before clearing the session
+ */
+ clear: (session: UserSession, event: H3Event) => void | Promise
+}
+
+export const sessionHooks = createHooks()
+
+/**
+ * Get the user session from the current request
+ * @param event The Request (h3) event
+ * @returns The user session
+ */
+export async function getUserSession(event: H3Event) {
+ const session = await _useSession(event);
+
+ if(!session.data || !session.data.id)
+ {
+ await session.update(defu({ id: session.id }, session.data));
+ }
+
+ return session.data;
+}
+/**
+ * Set a user session
+ * @param event The Request (h3) event
+ * @param data User session data, please only store public information since it can be decoded with API calls
+ * @see https://github.com/atinux/nuxt-auth-utils
+ */
+export async function setUserSession(event: H3Event, data: UserSession) {
+ const session = await _useSession(event)
+
+ await session.update(defu(data, session.data))
+
+ return session.data
+}
+
+/**
+ * Replace a user session
+ * @param event The Request (h3) event
+ * @param data User session data, please only store public information since it can be decoded with API calls
+ */
+export async function replaceUserSession(event: H3Event, data: UserSession) {
+ const session = await _useSession(event)
+
+ await session.clear()
+ await session.update(data)
+
+ return session.data
+}
+
+/**
+ * Clear the user session and removing the session cookie
+ * @param event The Request (h3) event
+ * @returns true if the session was cleared
+ */
+export async function clearUserSession(event: H3Event) {
+ const session = await _useSession(event)
+
+ await sessionHooks.callHookParallel('clear', session.data, event)
+ await session.clear()
+
+ return true
+}
+
+/**
+ * Require a user session, throw a 401 error if the user is not logged in
+ * @param event
+ * @param opts Options to customize the error message and status code
+ * @param opts.statusCode The status code to use for the error (defaults to 401)
+ * @param opts.message The message to use for the error (defaults to Unauthorized)
+ * @see https://github.com/atinux/nuxt-auth-utils
+ */
+export async function requireUserSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise {
+ const userSession = await getUserSession(event)
+
+ if (!userSession.user) {
+ throw createError({
+ statusCode: opts.statusCode || 401,
+ message: opts.message || 'Unauthorized',
+ })
+ }
+
+ return userSession as UserSessionRequired
+}
+
+let sessionConfig: SessionConfig
+
+function _useSession(event: H3Event) {
+ if (!sessionConfig) {
+ const runtimeConfig = useRuntimeConfig(event)
+
+ sessionConfig = runtimeConfig.session;
+ }
+ return useSession(event, sessionConfig)
+}
\ No newline at end of file
diff --git a/server/utils/user.ts b/server/utils/user.ts
new file mode 100644
index 0000000..4dd9a4d
--- /dev/null
+++ b/server/utils/user.ts
@@ -0,0 +1,34 @@
+import { eq, sql, and } from "drizzle-orm";
+import useDatabase from "~/composables/useDatabase";
+import { userSessionsTable } from "~/db/schema";
+import type { Return } from "~/types/api";
+import type { UserSession, UserSessionRequired } from "~/types/auth";
+
+export function checkSession(e: H3Event, session: UserSession): Return | undefined
+{
+ const db = useDatabase();
+
+ if(session.id && session.user?.id)
+ {
+ const sessionId = db.select({ user_id: userSessionsTable.user_id }).from(userSessionsTable).where(and(eq(userSessionsTable.id, sql.placeholder('id')), eq(userSessionsTable.user_id, sql.placeholder('user_id')))).prepare().get({ id: session.id, user_id: session.user.id })
+
+ if(sessionId && sessionId.user_id === session.user?.id)
+ {
+ return { success: true, session };
+ }
+ else
+ {
+ clearUserSession(e);
+
+ setResponseStatus(e, 406);
+ return { success: false, error: new Error('Vous êtes déjà connecté') };
+ }
+ }
+}
+export function logSession(e: H3Event, session: UserSessionRequired): UserSessionRequired
+{
+ const db = useDatabase();
+
+ db.insert(userSessionsTable).values({ id: sql.placeholder('id'), user_id: sql.placeholder('user_id'), timestamp: sql.placeholder('timestamp') }).prepare().execute({ id: session.id, user_id: session.user.id, timestamp: new Date()});
+ return session;
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index a746f2a..a2787e8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
- "extends": "./.nuxt/tsconfig.json"
-}
+ "extends": "./.nuxt/tsconfig.json",
+ "compilerOptions": {
+ "types": ["bun-types"],
+ }
+}
\ No newline at end of file
diff --git a/types/api.d.ts b/types/api.d.ts
new file mode 100644
index 0000000..e79d033
--- /dev/null
+++ b/types/api.d.ts
@@ -0,0 +1,87 @@
+export interface SuccessHandler
+{
+ success: true;
+ session: UserSession;
+}
+export interface ErrorHandler
+{
+ success: false;
+ error: Error | ZodError;
+}
+export type Return = SuccessHandler | ErrorHandler;
+
+export interface Project {
+ id: number;
+ name: string;
+ owner: number;
+ home: string;
+ summary: string;
+}
+export interface Navigation {
+ title: string;
+ path: string;
+ type: string;
+ order: number;
+ private: boolean;
+ children?: Navigation[];
+}
+export type FileMetadata = Record;
+export type FileType = 'Markdown' | 'Canvas' | 'File' | 'Folder';
+export interface File {
+ project: number;
+ path: string;
+ owner: number;
+ title: string;
+ order: number;
+ type: FileType;
+ content: string;
+ navigable: boolean;
+ private: boolean;
+ metadata: FileMetadata;
+}
+export interface Comment {
+ project: number;
+ path: number;
+ user_id: number;
+ sequence: number;
+ position: number;
+ length: number;
+ content: string;
+}
+export interface User {
+ id: number;
+ username: string;
+}
+export interface Tag {
+ tag: string;
+ project: number;
+ description: string;
+}
+
+
+export type ProjectSearch = Project &
+{
+ pages: number;
+ username: string;
+}
+export type FileSearch = Omit &
+{
+ comments: number;
+ username: string;
+}
+export type CommentSearch = Comment &
+{
+ username: string;
+}
+export type UserSearch = User &
+{
+}
+export type CommentedFile = File &
+{
+ comments: CommentSearch[];
+}
+export interface Search {
+ projects: ProjectSearch[];
+ files: FileSearch[];
+ users: UserSearch[];
+}
\ No newline at end of file
diff --git a/types/auth.d.ts b/types/auth.d.ts
new file mode 100644
index 0000000..8361487
--- /dev/null
+++ b/types/auth.d.ts
@@ -0,0 +1,71 @@
+import type { ComputedRef, Ref } from 'vue'
+
+import 'vue-router';
+declare module 'vue-router'
+{
+ interface RouteMeta
+ {
+ requiresAuth?: boolean;
+ guestsGoesTo?: string;
+ usersGoesTo?: string;
+ }
+}
+
+import 'nuxt';
+declare module 'nuxt'
+{
+ interface RuntimeConfig
+ {
+ session: SessionConfig;
+ }
+}
+
+export interface UserRawData {
+ id: number;
+ username: string;
+ email: string;
+ state: number;
+}
+
+export interface UserExtendedData {
+ signin: Date;
+}
+
+export type User = UserRawData & UserExtendedData;
+
+export interface UserSession {
+ user?: User;
+ id?: string;
+}
+
+export interface UserSessionRequired extends UserSession {
+ user: User;
+ id: string;
+}
+
+export interface UserSessionComposable {
+ /**
+ * Computed indicating if the auth session is ready
+ */
+ ready: ComputedRef
+ /**
+ * Computed indicating if the user is logged in.
+ */
+ loggedIn: ComputedRef
+ /**
+ * The user object if logged in, null otherwise.
+ */
+ user: ComputedRef
+ /**
+ * The session object.
+ */
+ session: Ref
+ /**
+ * Fetch the user session from the server.
+ */
+ fetch: () => Promise
+ /**
+ * Clear the user session and remove the session cookie.
+ */
+ clear: () => Promise
+}
\ No newline at end of file
diff --git a/types/canvas.d.ts b/types/canvas.d.ts
new file mode 100644
index 0000000..a126928
--- /dev/null
+++ b/types/canvas.d.ts
@@ -0,0 +1,34 @@
+export interface CanvasContent {
+ nodes: CanvasNode[];
+ edges: CanvasEdge[];
+ groups: CanvasGroup[];
+}
+export type CanvasColor = {
+ class?: string;
+} & {
+ hex?: string;
+}
+export interface CanvasNode {
+ type: 'group' | 'text';
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ color?: CanvasColor;
+ label?: string;
+ text?: any;
+};
+export interface CanvasEdge {
+ id: string;
+ fromNode: string;
+ fromSide: 'bottom' | 'top' | 'left' | 'right';
+ toNode: string;
+ toSide: 'bottom' | 'top' | 'left' | 'right';
+ color?: CanvasColor;
+ label?: string;
+};
+export interface CanvasGroup {
+ name: string;
+ nodes: string[];
+}
\ No newline at end of file