init commit
This commit is contained in:
4
src/lib/assets/composer/file.svg
Normal file
4
src/lib/assets/composer/file.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 17H15M9 13H15M9 9H10M13 3H8.2C7.0799 3 6.51984 3 6.09202 3.21799C5.71569 3.40973 5.40973 3.71569 5.21799 4.09202C5 4.51984 5 5.0799 5 6.2V17.8C5 18.9201 5 19.4802 5.21799 19.908C5.40973 20.2843 5.71569 20.5903 6.09202 20.782C6.51984 21 7.0799 21 8.2 21H15.8C16.9201 21 17.4802 21 17.908 20.782C18.2843 20.5903 18.5903 20.2843 18.782 19.908C19 19.4802 19 18.9201 19 17.8V9M13 3L19 9M13 3V7.4C13 7.96005 13 8.24008 13.109 8.45399C13.2049 8.64215 13.3578 8.79513 13.546 8.89101C13.7599 9 14.0399 9 14.6 9H19" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
6
src/lib/assets/composer/image.svg
Normal file
6
src/lib/assets/composer/image.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 4C23 2.34315 21.6569 1 20 1H4C2.34315 1 1 2.34315 1 4V20C1 21.6569 2.34315 23 4 23H20C21.6569 23 23 21.6569 23 20V4ZM21 4C21 3.44772 20.5523 3 20 3H4C3.44772 3 3 3.44772 3 4V20C3 20.5523 3.44772 21 4 21H20C20.5523 21 21 20.5523 21 20V4Z" fill="#0F0F0F"/>
|
||||
<path d="M4.80665 17.5211L9.1221 9.60947C9.50112 8.91461 10.4989 8.91461 10.8779 9.60947L14.0465 15.4186L15.1318 13.5194C15.5157 12.8476 16.4843 12.8476 16.8682 13.5194L19.1451 17.5039C19.526 18.1705 19.0446 19 18.2768 19H5.68454C4.92548 19 4.44317 18.1875 4.80665 17.5211Z" fill="#0F0F0F"/>
|
||||
<path d="M18 8C18 9.10457 17.1046 10 16 10C14.8954 10 14 9.10457 14 8C14 6.89543 14.8954 6 16 6C17.1046 6 18 6.89543 18 8Z" fill="#0F0F0F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
BIN
src/lib/assets/favicon.ico
Normal file
BIN
src/lib/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
7
src/lib/components/Note.svelte
Normal file
7
src/lib/components/Note.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
34
src/lib/components/NoteEditor.svelte
Normal file
34
src/lib/components/NoteEditor.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { FileAttachment, ImageAttachment, Note } from "$lib/editor";
|
||||
import { nanoid } from "nanoid";
|
||||
import { onMount } from "svelte";
|
||||
import imageSvg from "$lib/assets/composer/image.svg"
|
||||
import fileSvg from "$lib/assets/composer/file.svg"
|
||||
|
||||
type Props = {
|
||||
note?: Note;
|
||||
onSubmit: (note: Note) => void;
|
||||
}
|
||||
|
||||
let { note, onSubmit } : Props = $props();
|
||||
|
||||
const palette = ["#ffffff", "#fef3c7", "#dbeafe", "#dcfce7", "#fee2e2", "#f3e8ff"];
|
||||
|
||||
onMount(() => {
|
||||
if (!note) {
|
||||
note = {
|
||||
id: nanoid(),
|
||||
title: "",
|
||||
content: "",
|
||||
color: palette[0],
|
||||
date: new Date(),
|
||||
images: [],
|
||||
files: []
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
28
src/lib/components/NoteNavbar.svelte
Normal file
28
src/lib/components/NoteNavbar.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import Note from "./Note.svelte";
|
||||
import NoteEditor from "./NoteEditor.svelte";
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
const onCreate = (note: Note) => {
|
||||
console.log("create note")
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onclick={() => {
|
||||
|
||||
}}
|
||||
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "n" && e.metaKey) {
|
||||
expanded = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if !expanded}
|
||||
<div>Scrivi una nota...</div>
|
||||
{:else}
|
||||
<NoteEditor note={undefined} onSubmit={onCreate} />
|
||||
{/if}
|
||||
25
src/lib/editor.ts
Normal file
25
src/lib/editor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Note = {
|
||||
id: string;
|
||||
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
date: Date;
|
||||
|
||||
images: ImageAttachment[];
|
||||
files: FileAttachment[];
|
||||
};
|
||||
|
||||
|
||||
export type ImageAttachment = NoteAttachment & { kind: "image" };
|
||||
export type FileAttachment = NoteAttachment & { kind: "file" };
|
||||
|
||||
interface NoteAttachment {
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
1
src/lib/media.ts
Normal file
1
src/lib/media.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type MediaProvider = 'youtube' | 'youtube-music' | 'spotify';
|
||||
15
src/lib/server/auth.ts
Normal file
15
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
import { imapAuth } from '$lib/server/imap';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(db, { provider: 'sqlite' }),
|
||||
emailAndPassword: { enabled: true },
|
||||
plugins: [imapAuth(), sveltekitCookies(getRequestEvent)] // make sure sveltekitCookies is the last plugin in the array
|
||||
});
|
||||
107
src/lib/server/db/auth.schema.ts
Normal file
107
src/lib/server/db/auth.schema.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||
.default(false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = sqliteTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
10
src/lib/server/db/index.ts
Normal file
10
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = createClient({ url: env.DATABASE_URL });
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
34
src/lib/server/db/schema.ts
Normal file
34
src/lib/server/db/schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { user } from './auth.schema';
|
||||
|
||||
export * from './auth.schema';
|
||||
|
||||
export const notes = sqliteTable('notes', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
||||
title: text('title').notNull(),
|
||||
content: text('text').notNull(),
|
||||
|
||||
x: real("x").default(0).notNull(),
|
||||
y: real("y").default(0).notNull(),
|
||||
z: integer("z").default(1).notNull(),
|
||||
|
||||
color: text("color").default("#ffffff").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
});
|
||||
|
||||
export const noteFiles = sqliteTable('note_files', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull().references(() => notes.id, { onDelete: 'cascade' }),
|
||||
|
||||
kind: text('kind', { enum: ['image', 'file'] }).notNull().default('file'),
|
||||
position: integer('position').notNull().default(0),
|
||||
|
||||
name: text('name').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
|
||||
});
|
||||
138
src/lib/server/imap.ts
Normal file
138
src/lib/server/imap.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createAuthEndpoint } from 'better-auth/api';
|
||||
import { setSessionCookie } from 'better-auth/cookies';
|
||||
import { parseUserOutput } from 'better-auth/db';
|
||||
import { APIError } from 'better-call';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import * as z from 'zod';
|
||||
|
||||
const signInImapBodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export async function authenticateImap(user: string, password: string): Promise<boolean> {
|
||||
const client = new ImapFlow({
|
||||
host: 'mail.gongbao.it',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user,
|
||||
pass: password
|
||||
},
|
||||
logger: false
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.logout();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('IMAP auth error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const imapAuth = () => ({
|
||||
id: 'imap-auth',
|
||||
endpoints: {
|
||||
signInImap: createAuthEndpoint(
|
||||
'/sign-in/imap',
|
||||
{
|
||||
method: 'POST',
|
||||
body: signInImapBodySchema
|
||||
},
|
||||
async (ctx) => {
|
||||
const name = ctx.body.name;
|
||||
const email = ctx.body.email.toLowerCase();
|
||||
const password = ctx.body.password;
|
||||
|
||||
const adapter = ctx.context.internalAdapter;
|
||||
const existingUser = await adapter.findUserByEmail(email, { includeAccounts: true });
|
||||
|
||||
const credentialAccount = existingUser?.accounts.find((account) => account.providerId === 'credential');
|
||||
const credentialPassword = credentialAccount?.password;
|
||||
const isDbPasswordValid =
|
||||
typeof credentialPassword === 'string' &&
|
||||
(await ctx.context.password.verify({
|
||||
hash: credentialPassword,
|
||||
password
|
||||
}));
|
||||
|
||||
if (isDbPasswordValid && existingUser) {
|
||||
const session = await adapter.createSession(existingUser.user.id);
|
||||
if (!session) {
|
||||
throw new APIError('INTERNAL_SERVER_ERROR', {
|
||||
message: 'Failed to create session'
|
||||
});
|
||||
}
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session,
|
||||
user: existingUser.user
|
||||
});
|
||||
|
||||
return ctx.json({
|
||||
token: session.token,
|
||||
user: parseUserOutput(ctx.context.options, existingUser.user)
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Fallback to IMAP auth.
|
||||
const isImapValid = await authenticateImap(name, password);
|
||||
if (!isImapValid) {
|
||||
throw new APIError('UNAUTHORIZED', {
|
||||
message: 'Invalid name or password'
|
||||
});
|
||||
}
|
||||
|
||||
// 3) IMAP success: create/update local credential account.
|
||||
const hash = await ctx.context.password.hash(password);
|
||||
let user = existingUser?.user;
|
||||
|
||||
if (!user) {
|
||||
user = await adapter.createUser({
|
||||
name,
|
||||
email,
|
||||
emailVerified: true
|
||||
});
|
||||
|
||||
await adapter.linkAccount({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
accountId: user.id,
|
||||
password: hash
|
||||
});
|
||||
} else if (credentialAccount) {
|
||||
await adapter.updateAccount(credentialAccount.id, {
|
||||
password: hash
|
||||
});
|
||||
} else {
|
||||
await adapter.linkAccount({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
accountId: user.id,
|
||||
password: hash
|
||||
});
|
||||
}
|
||||
|
||||
const session = await adapter.createSession(user.id);
|
||||
if (!session) {
|
||||
throw new APIError('INTERNAL_SERVER_ERROR', {
|
||||
message: 'Failed to create session'
|
||||
});
|
||||
}
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session,
|
||||
user
|
||||
});
|
||||
|
||||
return ctx.json({
|
||||
token: session.token,
|
||||
user: parseUserOutput(ctx.context.options, user)
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user