diff --git a/bun.lockb b/bun.lockb index d0ca915..e4ae815 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/NavBar.vue b/components/NavBar.vue deleted file mode 100644 index 7dc5b0d..0000000 --- a/components/NavBar.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - \ No newline at end of file diff --git a/components/base/TextInput.vue b/components/base/TextInput.vue index 0efc357..6e87134 100644 --- a/components/base/TextInput.vue +++ b/components/base/TextInput.vue @@ -1,10 +1,11 @@ diff --git a/composables/useDatabase.ts b/composables/useDatabase.ts index e5486a3..342cfd1 100644 --- a/composables/useDatabase.ts +++ b/composables/useDatabase.ts @@ -1,11 +1,12 @@ import ".dotenv/config"; import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; +import * as schema from '../db/schema'; export default function useDatabase() { - const sqlite = new Database(process.env.DB_FILE); - const db = drizzle({ client: sqlite }); + const sqlite = new Database(useRuntimeConfig().database); + const db = drizzle({ client: sqlite, schema }); db.run("PRAGMA journal_mode = WAL;"); diff --git a/composables/useToast.ts b/composables/useToast.ts new file mode 100644 index 0000000..2367c7b --- /dev/null +++ b/composables/useToast.ts @@ -0,0 +1,4 @@ +export default function useToast() +{ + +} \ No newline at end of file diff --git a/composables/useUserSession.ts b/composables/useUserSession.ts new file mode 100644 index 0000000..d225abb --- /dev/null +++ b/composables/useUserSession.ts @@ -0,0 +1,40 @@ +import type { UserSession, UserSessionComposable } from '~/types/auth' + +const useSessionState = () => useState('nuxt-session', () => ({})) +const useAuthReadyState = () => useState('nuxt-auth-ready', () => false) + +/** + * Composable to get back the user session and utils around it. + * @see https://github.com/atinux/nuxt-auth-utils + */ +export function useUserSession(): UserSessionComposable { + const sessionState = useSessionState() + const authReadyState = useAuthReadyState() + return { + ready: computed(() => authReadyState.value), + loggedIn: computed(() => Boolean(sessionState.value.user)), + user: computed(() => sessionState.value.user || null), + session: sessionState, + fetch, + clear, + } +} + +async function fetch() { + const authReadyState = useAuthReadyState() + useSessionState().value = await useRequestFetch()('/api/auth/session', { + headers: { + Accept: 'text/json', + }, + retry: false, + }).catch(() => ({})) + if (!authReadyState.value) { + authReadyState.value = true + } +} + +async function clear() { + await $fetch('/api/auth/session', { method: 'DELETE' }) + useSessionState().value = {} + useRouter().go(0); +} \ No newline at end of file diff --git a/db.sqlite b/db.sqlite index a0daa29..1f9d0b7 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/db/schema.ts b/db/schema.ts index 1fd87c8..6d9ca1d 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,3 +1,4 @@ +import { relations } from 'drizzle-orm'; import { int, text, sqliteTable, type SQLiteTableExtraConfig, primaryKey, blob } from 'drizzle-orm/sqlite-core'; export const usersTable = sqliteTable("users", { @@ -5,11 +6,12 @@ export const usersTable = sqliteTable("users", { username: text().notNull().unique(), email: text().notNull().unique(), hash: text().notNull().unique(), - state: int().default(0), + state: int().notNull().default(0), }); export const usersDataTable = sqliteTable("users_data", { id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); export const userSessionsTable = sqliteTable("user_sessions", { @@ -17,8 +19,8 @@ export const userSessionsTable = sqliteTable("user_sessions", { user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, (table): SQLiteTableExtraConfig => { - return { - pk: primaryKey({ columns: [ table.id, table.user_id ] }), + return { + pk: primaryKey({ columns: [table.id, table.user_id] }), } }); @@ -30,4 +32,19 @@ export const explorerContentTable = sqliteTable("explorer_content", { content: blob({ mode: 'buffer' }), navigable: int({ mode: 'boolean' }).default(true), private: int({ mode: 'boolean' }).default(false), -}); \ No newline at end of file +}); + +export const usersRelation = relations(usersTable, ({one, many}) => ({ + data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }), + session: many(userSessionsTable), + content: many(explorerContentTable), +})); +export const usersDataRelation = relations(usersDataTable, ({one}) => ({ + users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }), +})); +export const userSessionsRelation = relations(userSessionsTable, ({one}) => ({ + users: one(usersTable, { fields: [userSessionsTable.user_id], references: [usersTable.id], }), +})); +export const explorerContentRelation = relations(explorerContentTable, ({one}) => ({ + users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }), +})); \ No newline at end of file diff --git a/drizzle/0000_youthful_ma_gnuci.sql b/drizzle/0000_lonely_the_renegades.sql similarity index 79% rename from drizzle/0000_youthful_ma_gnuci.sql rename to drizzle/0000_lonely_the_renegades.sql index 86df767..d442507 100644 --- a/drizzle/0000_youthful_ma_gnuci.sql +++ b/drizzle/0000_lonely_the_renegades.sql @@ -1,3 +1,6 @@ +-- Current sql file was generated after introspecting the database +-- If you want to run this migration please uncomment this code before executing migrations +/* CREATE TABLE `explorer_content` ( `path` text PRIMARY KEY NOT NULL, `owner` integer NOT NULL, @@ -30,6 +33,11 @@ CREATE TABLE `users` ( `state` integer DEFAULT 0 ); --> statement-breakpoint -CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_hash_unique` ON `users` (`hash`);--> statement-breakpoint CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint -CREATE UNIQUE INDEX `users_hash_unique` ON `users` (`hash`); \ No newline at end of file +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint +CREATE TABLE `__drizzle_migrations` ( + +); + +*/ \ No newline at end of file diff --git a/drizzle/0001_lush_selene.sql b/drizzle/0001_lush_selene.sql new file mode 100644 index 0000000..eab6ade --- /dev/null +++ b/drizzle/0001_lush_selene.sql @@ -0,0 +1,18 @@ +DROP TABLE `__drizzle_migrations`;--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `username` text NOT NULL, + `email` text NOT NULL, + `hash` text NOT NULL, + `state` integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_users`("id", "username", "email", "hash", "state") SELECT "id", "username", "email", "hash", "state" FROM `users`;--> statement-breakpoint +DROP TABLE `users`;--> statement-breakpoint +ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_hash_unique` ON `users` (`hash`);--> statement-breakpoint +ALTER TABLE `users_data` ADD `signin` integer NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 351290c..38c7c47 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,64 +1,65 @@ { + "id": "00000000-0000-0000-0000-000000000000", + "prevId": "", "version": "6", "dialect": "sqlite", - "id": "ddf5d5b3-bf1e-4d8d-89cb-230f8e90137a", - "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "explorer_content": { "name": "explorer_content", "columns": { "path": { + "autoincrement": false, "name": "path", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "owner": { + "autoincrement": false, "name": "owner", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "title": { + "autoincrement": false, "name": "title", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "type": { + "autoincrement": false, "name": "type", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "content": { + "autoincrement": false, "name": "content", "type": "blob", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "navigable": { + "default": true, + "autoincrement": false, "name": "navigable", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": true + "notNull": false }, "private": { + "default": false, + "autoincrement": false, "name": "private", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false + "notNull": false } }, + "compositePrimaryKeys": {}, "indexes": {}, "foreignKeys": { "explorer_content_owner_users_id_fk": { @@ -75,7 +76,6 @@ "onUpdate": "cascade" } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -83,25 +83,34 @@ "name": "user_sessions", "columns": { "id": { + "autoincrement": false, "name": "id", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { + "autoincrement": false, "name": "user_id", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "timestamp": { + "autoincrement": false, "name": "timestamp", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + } + }, + "compositePrimaryKeys": { + "user_sessions_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "user_sessions_id_user_id_pk" } }, "indexes": {}, @@ -120,15 +129,6 @@ "onUpdate": "cascade" } }, - "compositePrimaryKeys": { - "user_sessions_id_user_id_pk": { - "columns": [ - "id", - "user_id" - ], - "name": "user_sessions_id_user_id_pk" - } - }, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -136,13 +136,14 @@ "name": "users_data", "columns": { "id": { + "autoincrement": false, "name": "id", "type": "integer", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true } }, + "compositePrimaryKeys": {}, "indexes": {}, "foreignKeys": { "users_data_id_users_id_fk": { @@ -159,7 +160,6 @@ "onUpdate": "cascade" } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -167,47 +167,48 @@ "name": "users", "columns": { "id": { + "autoincrement": true, "name": "id", "type": "integer", "primaryKey": true, - "notNull": true, - "autoincrement": true + "notNull": true }, "username": { + "autoincrement": false, "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "email": { + "autoincrement": false, "name": "email", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "hash": { + "autoincrement": false, "name": "hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "state": { + "default": 0, + "autoincrement": false, "name": "state", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 + "notNull": false } }, + "compositePrimaryKeys": {}, "indexes": { - "users_username_unique": { - "name": "users_username_unique", + "users_hash_unique": { + "name": "users_hash_unique", "columns": [ - "username" + "hash" ], "isUnique": true }, @@ -218,16 +219,24 @@ ], "isUnique": true }, - "users_hash_unique": { - "name": "users_hash_unique", + "users_username_unique": { + "name": "users_username_unique", "columns": [ - "hash" + "username" ], "isUnique": true } }, "foreignKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "__drizzle_migrations": { + "name": "__drizzle_migrations", + "columns": {}, "compositePrimaryKeys": {}, + "indexes": {}, + "foreignKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} } @@ -238,8 +247,5 @@ "schemas": {}, "tables": {}, "columns": {} - }, - "internal": { - "indexes": {} } } \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..60b9c30 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,252 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0cca5664-1a0e-48ef-b70a-966b7a8142c7", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "explorer_content": { + "name": "explorer_content", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigable": { + "name": "navigable", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "private": { + "name": "private", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "explorer_content_owner_users_id_fk": { + "name": "explorer_content_owner_users_id_fk", + "tableFrom": "explorer_content", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_sessions": { + "name": "user_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_sessions_user_id_users_id_fk": { + "name": "user_sessions_user_id_users_id_fk", + "tableFrom": "user_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_sessions_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "user_sessions_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_data": { + "name": "users_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "signin": { + "name": "signin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_data_id_users_id_fk": { + "name": "users_data_id_users_id_fk", + "tableFrom": "users_data", + "tableTo": "users", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_hash_unique": { + "name": "users_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1da4d4d..b6090be 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "6", - "when": 1730124775172, - "tag": "0000_youthful_ma_gnuci", + "when": 1730822816801, + "tag": "0000_lonely_the_renegades", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1730826510693, + "tag": "0001_lush_selene", "breakpoints": true } ] diff --git a/drizzle/relations.ts b/drizzle/relations.ts new file mode 100644 index 0000000..1574ff6 --- /dev/null +++ b/drizzle/relations.ts @@ -0,0 +1,29 @@ +import { relations } from "drizzle-orm/relations"; +import { users, explorerContent, userSessions, usersData } from "./schema"; + +export const explorerContentRelations = relations(explorerContent, ({one}) => ({ + user: one(users, { + fields: [explorerContent.owner], + references: [users.id] + }), +})); + +export const usersRelations = relations(users, ({many}) => ({ + explorerContents: many(explorerContent), + userSessions: many(userSessions), + usersData: many(usersData), +})); + +export const userSessionsRelations = relations(userSessions, ({one}) => ({ + user: one(users, { + fields: [userSessions.userId], + references: [users.id] + }), +})); + +export const usersDataRelations = relations(usersData, ({one}) => ({ + user: one(users, { + fields: [usersData.id], + references: [users.id] + }), +})); \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts new file mode 100644 index 0000000..2fa3065 --- /dev/null +++ b/drizzle/schema.ts @@ -0,0 +1,46 @@ +import { sqliteTable, AnySQLiteColumn, foreignKey, text, integer, blob, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core" + import { sql } from "drizzle-orm" + +export const explorerContent = sqliteTable("explorer_content", { + path: text().primaryKey().notNull(), + owner: integer().notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ), + title: text().notNull(), + type: text().notNull(), + content: blob(), + navigable: integer().default(true), + private: integer().default(false), +}); + +export const userSessions = sqliteTable("user_sessions", { + id: integer().notNull(), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ), + timestamp: integer().notNull(), +}, +(table) => { + return { + pk0: primaryKey({ columns: [table.id, table.userId], name: "user_sessions_id_user_id_pk"}) + } +}); + +export const usersData = sqliteTable("users_data", { + id: integer().primaryKey().notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ), +}); + +export const users = sqliteTable("users", { + id: integer().primaryKey({ autoIncrement: true }).notNull(), + username: text().notNull(), + email: text().notNull(), + hash: text().notNull(), + state: integer().default(0), +}, +(table) => { + return { + hashUnique: uniqueIndex("users_hash_unique").on(table.hash), + emailUnique: uniqueIndex("users_email_unique").on(table.email), + usernameUnique: uniqueIndex("users_username_unique").on(table.username), + } +}); + +export const drizzleMigrations = sqliteTable("__drizzle_migrations", { +}); + diff --git a/layouts/default.vue b/layouts/default.vue index 0eba164..dfc2c8d 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,4 +1,59 @@ \ No newline at end of file + +
+
+ + + + Accueil +
+
+ + + +
+ +
+
+
+
+
+
+ +
+
+
+ + + +
+ + + +
+ +
+
+
+
+
+
+
+ Mentions légales +

Copyright Peaceultime - 2024

+
+
+
+ +
+
+ + + \ 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 @@ \ No newline at end of file + + Connexion + +
+ Connexion +
+ + + + Pas de compte ? + + +
+ + + \ 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 @@ \ No newline at end of file + + Inscription + +
+ Inscription +
+ + + +
+ Votre mot de passe doit respecter les critères de sécurité suivants + : + Entre 8 et 128 + caractères + Au moins + une minuscule et une majuscule + Au moins un + chiffre + Au moins un + caractère spécial parmis la liste suivante: +
! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~
+
+
+ + + Pas de compte ? + +
+ + + + + \ 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