init commit

This commit is contained in:
Andrea
2026-03-20 16:56:03 +01:00
commit 6771126324
33 changed files with 7310 additions and 0 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Drizzle
DATABASE_URL=file:local.db
ORIGIN=""
# Better Auth
# For production use 32 characters and generated with high entropy
# https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=""
STORAGE_PATH="./storage"

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
- Non modificare mai i file direttamente, proponi la soluzione nella chat.

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.8 create --template minimal --types ts --add sveltekit-adapter="adapter:auto" drizzle="database:sqlite+sqlite:libsql" better-auth="demo:password" devtools-json --install npm ./
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true,
strict: true
});

6590
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "gongnotes",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
"devDependencies": {
"@better-auth/cli": "~1.4.21",
"@libsql/client": "^0.17.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/node": "^22",
"better-auth": "~1.4.21",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0"
},
"dependencies": {
"imapflow": "^1.2.16",
"nanoid": "^5.1.7"
}
}

16
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import type { User, Session } from 'better-auth/minimal';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Locals { user?: User; session?: Session }
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

34
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,34 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { sequence } from '@sveltejs/kit/hooks';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
}
return svelteKitHandler({ event, resolve, auth, building });
};
const handleRouting: Handle = async ({ event, resolve }) => {
const isLoginRoute = event.url.pathname === '/login';
if (!isLoginRoute && !event.locals.user) {
throw redirect(302, '/login');
}
if (isLoginRoute && event.locals.user) {
throw redirect(302, '/');
}
return resolve(event);
}
export const handle: Handle = sequence(handleBetterAuth, handleRouting);

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,7 @@
<script lang="ts">
</script>
<style>
</style>

View 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>

View 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
View 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
View File

@@ -0,0 +1 @@
export type MediaProvider = 'youtube' | 'youtube-music' | 'spotify';

15
src/lib/server/auth.ts Normal file
View 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
});

View 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],
}),
}));

View 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 });

View 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
View 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)
});
}
)
}
});

11
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,11 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.ico';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}

7
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,7 @@
<script lang="ts">
</script>
<style>
</style>

View File

@@ -0,0 +1,31 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { auth } from '$lib/server/auth';
import { APIError } from 'better-auth/api';
export const actions: Actions = {
signInEmail: async (event) => {
const formData = await event.request.formData();
const name = formData.get('name')?.toString() ?? '';
const email = `${name}@gongbao.it`;
const password = formData.get('password')?.toString() ?? '';
try {
await auth.api.signInImap({
body: {
name,
email,
password
}
});
} catch (error) {
if (error instanceof APIError) {
return fail(400, { message: error.message || 'Signin failed' });
}
console.error('IMAP signin error:', error);
return fail(500, { message: 'Unexpected error' });
}
return redirect(302, '/');
}
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<h1>Login</h1>
<form method="post" action="?/signInEmail" use:enhance>
<label>
Login
<input type="text" name="name" required placeholder="user" />
<small>@gongbao.it</small>
</label>
<label>
Password
<input type="password" name="password" required />
</label>
<button type="submit">Accedi</button>
</form>
{#if form?.message}
<p style="color: red">{form.message}</p>
{/if}

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

13
svelte.config.js Normal file
View File

@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) => filename.includes('node_modules') ? undefined : { runes: true }
}
};
export default config;

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [sveltekit(), devtoolsJson()] });