typescript + sqlite + more things

This commit is contained in:
Sofía Aritz 2024-06-30 18:04:54 +02:00
parent 3e78a67b04
commit 0e5b9aa324
Signed by: sofia
GPG key ID: 90B5116E3542B28F
33 changed files with 2123 additions and 318 deletions

View file

@ -60,8 +60,8 @@ app.get("/crypto/algo", () => {
app.put("/asset", {
async handler(request, reply) {
let user = await userFromSessionKey(request.query.session_key);
if (user.assets.length >= user.limits.assetCount) {
let { user, limits } = await userFromSessionKey(request.query.session_key);
if (user.assets.length >= limits.maxAssetCount) {
reply.code(403);
return "Max asset count reached. Contact support or upgrade your plan";
}
@ -102,7 +102,7 @@ app.put("/asset", {
app.get("/asset", {
async handler(request, reply) {
let user = await userFromSessionKey(request.query.session_key);
let { user, limits } = await userFromSessionKey(request.query.session_key);
if ('statusCode' in user) {
reply.code(500);

View file

@ -2,4 +2,5 @@ IDENTITY_API_LANDING_MESSAGE = "identity-api v1.0.0"
IDENTITY_API_JWT_SECRET = "cc7e0d44fd473002f1c42167459001140ec6389b7353f8088f4d9a95f2f596f2"
IDENTITY_API_JWT_ALG = "HS256"
IDENTITY_API_ASSET_API_ENDPOINT = "http://localhost:3001"
IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_MS = 60000
IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_MS = 60000
IDENTITY_SQLITE_PATH = ".database/identity.sqlite"

View file

@ -1,3 +1,5 @@
node_modules/
dist/
.yarn
.env
.env
.database

View file

@ -0,0 +1,76 @@
// You can render this DBML file using specialized software or websites such as:
// https://dbdiagram.io/
Table limits {
id varchar [primary key]
current_asset_count integer [not null]
max_asset_count integer [not null]
}
Table users {
id varchar [primary key]
created_at timestamp [not null]
last_connected_at timestamp [not null]
email varchar [not null]
password varchar [not null]
name varchar [not null]
limits varchar [not null]
assets varchar [not null, note: 'Comma separated list']
}
Table session_keys {
key varchar [primary key]
user_id varchar [not null]
}
Table heirs {
id varchar [primary key]
user_id varchar [not null]
created_at timestamp [not null]
name varchar [not null]
email varchar
}
Table entries {
id varchar [primary key]
user_id varchar [not null]
created_at timestamp [not null]
feelings text [not null, note: 'Comma separated JSON-encoded list']
assets text [not null, note: 'Comma separated list']
title text
description text
kind varchar [not null, note: 'Kind of entry']
music_entry varchar
location_entry varchar
date_entry varchar
}
Table music_entries {
id varchar [primary key]
artist varchar [not null]
title varchar [not null]
links text [not null, note: 'Comma separated list']
universal_ids text [not null, note: 'Comma separated JSON-encoded list of Universal IDs']
}
Table location_entries {
id varchar [primary key]
location_text text
location_coordinates varchar [note: 'JSON encoded location']
}
Table date_entries {
id varchar [primary key]
referenced_date timestamp
}
Ref: users.limits > limits.id
Ref: heirs.user_id > users.id
Ref: entries.user_id > users.id
Ref: session_keys.user_id > users.id
Ref: entries.music_entry > music_entries.id
Ref: entries.location_entry > location_entries.id
Ref: entries.date_entry > date_entries.id

View file

@ -8,19 +8,26 @@
"packageManager": "yarn@4.3.0",
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/type-provider-typebox": "^4.0.0",
"@sinclair/typebox": "^0.32.34",
"argon2": "^0.40.3",
"better-sqlite3": "^11.1.1",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.31.2",
"fastify": "^4.27.0",
"jose": "^5.4.0"
},
"scripts": {
"start": "node src/index.js",
"start": "tsc && node dist/index.js",
"lint:fix": "eslint --fix && prettier . --write",
"lint": "eslint && prettier . --check"
},
"devDependencies": {
"@eslint/js": "^9.5.0",
"@types/node": "^20.14.9",
"eslint": "9.x",
"globals": "^15.5.0",
"prettier": "3.3.2"
"prettier": "3.3.2",
"typescript": "^5.5.2"
}
}

View file

@ -14,8 +14,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
import Fastify from "fastify";
export default Fastify({
let app = Fastify({
logger: true,
});
}).withTypeProvider<TypeBoxTypeProvider>()
export type AppInterface = typeof app;
export default app;

File diff suppressed because one or more lines are too long

121
identity-api/src/auth.ts Normal file
View file

@ -0,0 +1,121 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as argon2 from "argon2";
import { randomUUID } from "node:crypto";
import * as Jose from "jose";
import { JWT_ALG, JWT_SECRET } from "./consts.js";
import { DatabaseInterface, toDBList } from "./database.js";
export type AuthInterface = Awaited<ReturnType<typeof startAuth>>;
export type User = {
id: string,
createdAt: number,
lastConnected: number,
name: string,
email: string,
password: string,
limitID: string,
}
export type NewUser = {
name: string,
email: string,
password: string,
}
export type UpdateUser = {
name?: string,
email?: string,
password?: string,
assets?: string[],
}
export async function startAuth(database: DatabaseInterface) {
let funcs = {
user: async (uid: string) => await database.user(uid) satisfies User,
findUserByEmail: async (email: string) => await database.findUserByEmail(email) satisfies User,
findUserBySessionKey: async (sessionKey: string) => {
let key = await database.sessionKey(sessionKey);
return await database.user(key.userID);
},
updateUser: async (uid: string, newUser: UpdateUser) => {
let user = newUser as any;
user.assets = toDBList(user.assets);
return await database.updateUser(uid, user);
},
addUser: async (user: NewUser) => {
let result = await database.insertUser({
id: randomUUID(),
createdAt: Date.now(),
lastConnected: Date.now(),
name: user.name,
email: user.email,
password: await argon2.hash(user.password),
assets: toDBList([]),
// FIXME: This shouldn't be required, the DB interface overwrites it.
limitID: "",
}, {
id: randomUUID(),
currentAssetCount: 0,
maxAssetCount: 5,
});
return result satisfies User;
},
verifyPassword: async (user: User, password: string) => {
return await argon2.verify(user.password, password);
},
createSessionKey: async (uid) => {
return await database.insertSessionKey({
userID: uid,
key: randomUUID(),
});
},
createJwt: async (uid) => {
let user = await funcs.user(uid);
return await new Jose.SignJWT({
uid: user.id,
email: user.email,
name: user.name,
})
.setProtectedHeader({ alg: JWT_ALG })
.setIssuedAt()
.setExpirationTime("4w")
.sign(JWT_SECRET);
},
verifyJwt: async (jwt) => {
return await Jose.jwtVerify<{
uid: string,
email: string,
name: string,
}>(jwt, JWT_SECRET);
},
cleanUser: (user: User) => {
let clean = user as any;
clean.password = undefined;
clean.limitID = undefined;
return clean;
}
};
return funcs;
}

View file

@ -17,7 +17,12 @@
import "dotenv/config";
import app from "./app.js";
const REQUIRED_VARS = ["IDENTITY_API_JWT_SECRET", "IDENTITY_API_ASSET_API_ENDPOINT", "IDENTITY_API_JWT_ALG"];
const REQUIRED_VARS = [
"IDENTITY_API_JWT_SECRET",
"IDENTITY_API_ASSET_API_ENDPOINT",
"IDENTITY_API_JWT_ALG",
"IDENTITY_SQLITE_PATH",
];
REQUIRED_VARS.forEach((element) => {
if (
@ -32,7 +37,8 @@ REQUIRED_VARS.forEach((element) => {
export const IDENTITY_API_LANDING_MESSAGE = process.env["IDENTITY_API_LANDING_MESSAGE"] || "identity-api v1.0.0";
export const JWT_SECRET = new TextEncoder().encode(process.env["IDENTITY_API_JWT_SECRET"]);
export const JWT_ALG = process.env["IDENTITY_API_JWT_ALG"];
export const LISTEN_PORT = process.env["IDENTITY_API_LISTEN_PORT"] || 3000;
export const LISTEN_PORT = Number(process.env["IDENTITY_API_LISTEN_PORT"]) || 3000;
export const ASSET_API_ENDPOINT = process.env["IDENTITY_API_ASSET_API_ENDPOINT"];
export const ASSET_API_M2M_REFRESH_INTERVAL =
process.env["IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_MS"] || 60 * 1000;
Number(process.env["IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_MS"]) || 60 * 1000;
export const SQLITE_PATH = process.env["IDENTITY_SQLITE_PATH"];

View file

@ -0,0 +1,316 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import { SQLITE_PATH } from "./consts.js";
import { sqliteTable } from "drizzle-orm/sqlite-core";
import { text, integer } from "drizzle-orm/sqlite-core";
import { asc, desc, eq, sql } from "drizzle-orm";
export type DatabaseInterface = Awaited<ReturnType<typeof startDatabase>>
export function toDBList(input: any[]): string {
return JSON.stringify(input);
}
export function fromDBList<T>(input: string): Array<T> {
return JSON.parse(input)
}
export async function startDatabase() {
let sqlite = new Database(SQLITE_PATH);
let database = drizzle(sqlite);
const limits = sqliteTable("limits", {
id: text("id").primaryKey(),
currentAssetCount: integer("current_asset_count").notNull(),
maxAssetCount: integer("max_asset_count").notNull(),
});
const users = sqliteTable("users", {
id: text("id").primaryKey(),
createdAt: integer("created_at").notNull(),
lastConnected: integer("last_connected_at").notNull(),
name: text("name").notNull(),
email: text("email").notNull(),
password: text("password").notNull(),
assets: text("assets").notNull(),
limitID: text("limits")
.notNull()
.references(() => limits.id),
});
const session_keys = sqliteTable("session_keys", {
key: text("key").primaryKey(),
userID: text("user_id")
.notNull()
.references(() => users.id),
});
const heirs = sqliteTable("heirs", {
id: text("id").primaryKey(),
userID: text("user_id")
.notNull()
.references(() => users.id),
createdAt: integer("created_at").notNull(),
name: text("name").notNull(),
email: text("email"),
});
const musicEntries = sqliteTable("music_entries", {
id: text("id").primaryKey(),
artist: text("artist").notNull(),
title: text("title").notNull(),
links: text("links").notNull(),
universalIDs: text("universal_ids").notNull(),
});
const locationEntries = sqliteTable("location_entries", {
id: text("id").primaryKey(),
locationText: text("location_text"),
locationCoordinates: text("location_coordinates"),
});
const dateEntries = sqliteTable("date_entries", {
id: text("id").primaryKey(),
referencedDate: integer("referenced_date"),
});
const entries = sqliteTable("entries", {
id: text("id").primaryKey(),
userID: text("user_id")
.notNull()
.references(() => users.id),
createdAt: integer("created_at").notNull(),
feelings: text("feelings").notNull(),
assets: text("assets").notNull(),
title: text("title"),
description: text("description"),
kind: text("kind").notNull(),
musicEntry: text("music_entry").references(() => musicEntries.id),
locationEntry: text("location_entry").references(() => locationEntries.id),
dateEntry: text("date_entry").references(() => dateEntries.id),
});
await runMigrations(database);
let funcs = {
insertHeir: async (heir: typeof heirs.$inferInsert) => {
let result = await database.insert(heirs).values(heir).returning({ id: heirs.id });
return result[0].id;
},
removeHeir: async (id: string) => {
await database.delete(heirs).where(eq(heirs.id, id));
},
listHeirs: async (userID: string) => {
return await database.select().from(heirs).where(eq(heirs.userID, userID));
},
insertUser: async (user: typeof users.$inferInsert, limit: typeof limits.$inferInsert) => {
let limitsResult = await database.insert(limits).values(limit).returning({ id: limits.id });
user.limitID = limitsResult[0].id;
let userResult = await database.insert(users).values(user).returning();
return userResult[0];
},
user: async (userID: string) => {
let result = await database.select().from(users).where(eq(users.id, userID));
return result[0];
},
userLimits: async (limitsID: string) => {
let result = await database.select().from(limits).where(eq(limits.id, limitsID));
return result[0];
},
updateUser: async (userID: string, newUser: {
name?: string,
email?: string,
password?: string,
}) => {
let result = await database.update(users).set(newUser).where(eq(users.id, userID)).returning();
return result[0];
},
findUserByEmail: async (email: string) => {
let result = await database.select().from(users).where(eq(users.email, email));
return result[0];
},
insertSessionKey: async (key: typeof session_keys.$inferInsert) => {
let result = await database.insert(session_keys).values(key).returning({ key: session_keys.key });
return result[0].key;
},
sessionKey: async (key: string) => {
let result = await database.select().from(session_keys).where(eq(session_keys.key, key));
return result[0];
},
findSessionKeyByUserID: async (userID: string) => {
return await database.select().from(session_keys).where(eq(session_keys.userID, userID));
},
insertEntry: async (entry: typeof entries.$inferInsert, musicEntry?: typeof musicEntries.$inferInsert, locationEntry?: typeof locationEntries.$inferInsert, dateEntry?: typeof dateEntries.$inferInsert) => {
if (entry.kind === "album" || entry.kind === "song") {
let result = await database.insert(musicEntries).values(musicEntry).returning({ id: musicEntries.id });
entry.musicEntry = result[0].id;
} else if (entry.kind === "environment") {
let result = await database
.insert(locationEntries)
.values(locationEntry)
.returning({ id: locationEntries.id });
entry.locationEntry = result[0].id;
} else if (entry.kind === "date") {
let result = await database.insert(dateEntries).values(dateEntry).returning({ id: dateEntries.id });
entry.dateEntry = result[0].id;
}
let result = await database.insert(entries).values(entry).returning({ id: entries.id });
return result[0].id;
},
removeEntry: async (entryID: string) => {
await database.delete(entries).where(eq(entries.id, entryID));
},
entryPage: async (userID: string, offset: number, limit: number) => {
let result = await database
.select()
.from(entries)
.where(eq(entries.userID, userID))
.limit(limit)
.offset(offset)
.orderBy(desc(entries.createdAt));
for (let key in result) {
if (!result.hasOwnProperty(key)) { continue; }
let entry = structuredClone(result[key]);
let base = {};
if (entry.musicEntry != null) {
let musicDetails = (await database.select().from(musicEntries).where(eq(musicEntries.id, entry.musicEntry)))[0];
(musicDetails["link"] as any) = fromDBList(musicDetails.links);
(musicDetails["id"] as any) = fromDBList(musicDetails.universalIDs);
musicDetails["links"] = undefined;
musicDetails["universalIDs"] = undefined;
base = musicDetails;
} else if (entry.locationEntry != null) {
let locationDetails = (await database.select().from(locationEntries).where(eq(locationEntries.id, entry.locationEntry)))[0];
if (locationDetails.locationCoordinates != null) {
base = { location: JSON.parse(locationDetails.locationCoordinates) }
} else if (locationDetails.locationText != null) {
base = { location: locationDetails.locationText }
}
} else if (entry.dateEntry != null) {
let dateDetails = (await database.select().from(dateEntries).where(eq(dateEntries.id, entry.dateEntry)))[0];
base = {
referencedDate: new Date(dateDetails["referencedDate"]).toISOString()
};
}
base["kind"] = entry.kind
result[key]["creationDate"] = new Date(entry.createdAt).toISOString();
(result[key] as any)["feelings"] = fromDBList(entry.feelings);
(result[key] as any)["assets"] = fromDBList(entry.assets);
result[key]["base"] = base;
result[key].kind = undefined;
result[key].userID = undefined;
result[key].musicEntry = undefined;
result[key].locationEntry = undefined;
result[key].dateEntry = undefined;
}
return result;
},
};
return funcs;
}
async function runMigrations(database) {
await database.run(sql`CREATE TABLE IF NOT EXISTS limits (
id varchar PRIMARY KEY,
current_asset_count integer NOT NULL,
max_asset_count integer NOT NULL
);`);
await database.run(sql`CREATE TABLE IF NOT EXISTS users (
id varchar PRIMARY KEY,
created_at timestamp NOT NULL,
last_connected_at timestamp NOT NULL,
email varchar NOT NULL,
password varchar NOT NULL,
name varchar NOT NULL,
limits varchar NOT NULL,
assets varchar NOT NULL,
FOREIGN KEY (limits) REFERENCES limits (id)
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS session_keys (
key varchar PRIMARY KEY,
user_id varchar NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS heirs (
id varchar PRIMARY KEY,
user_id varchar NOT NULL,
created_at timestamp NOT NULL,
name varchar NOT NULL,
email varchar,
FOREIGN KEY (user_id) REFERENCES users (id)
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS entries (
id varchar PRIMARY KEY,
user_id varchar NOT NULL,
created_at timestamp NOT NULL,
feelings text NOT NULL,
assets text NOT NULL,
title text,
description text,
kind varchar NOT NULL,
music_entry varchar,
location_entry varchar,
date_entry varchar,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (music_entry) REFERENCES music_entries (id),
FOREIGN KEY (location_entry) REFERENCES location_entries (id),
FOREIGN KEY (date_entry) REFERENCES date_entries (id)
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS music_entries (
id varchar PRIMARY KEY,
artist varchar NOT NULL,
title varchar NOT NULL,
links text NOT NULL,
universal_ids text NOT NULL
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS location_entries (
id varchar PRIMARY KEY,
location_text text,
location_coordinates varchar
);`);
await database.run(sql`
CREATE TABLE IF NOT EXISTS date_entries (
id varchar PRIMARY KEY,
referenced_date timestamp
);`);
}

View file

@ -20,8 +20,10 @@ import { startAuth } from "./auth.js";
import cors from "@fastify/cors";
import { registerRoutes } from "./routes/index.js";
import { startDatabase } from "./database.js";
let auth = await startAuth();
let database = await startDatabase();
let auth = await startAuth(database);
app.addSchema({
$id: "schema://identity/authorization",
@ -36,7 +38,7 @@ app.register(cors, {
origin: true,
});
registerRoutes(app, auth);
registerRoutes(app, auth, database);
app.get("/", async () => {
return IDENTITY_API_LANDING_MESSAGE;

View file

@ -14,10 +14,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { ASSET_API_ENDPOINT } from "../../consts.js";
export default function register(app) {
import type { AppInterface } from "../../app.js";
export default function register(app: AppInterface) {
app.get("/asset/endpoint", {
async handler() {
return ASSET_API_ENDPOINT;

View file

@ -14,9 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { AppInterface } from "../../app.js";
import endpoint from "./endpoint.js";
export default function registerRoutes(app, auth) {
endpoint(app, auth);
export default function registerRoutes(app: AppInterface) {
endpoint(app);
}

View file

@ -14,8 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
export default function register(app, auth) {
export default function register(app: AppInterface, auth: AuthInterface) {
app.get("/auth/account", {
async handler(request, reply) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
@ -27,9 +29,7 @@ export default function register(app, auth) {
}
let user = await auth.user(payload.uid);
user.password = undefined;
user.entries = undefined;
return user;
return auth.cleanUser(user);
},
schema: {
headers: { $ref: "schema://identity/authorization" },

View file

@ -14,8 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
export default function register(app, auth) {
export default function register(app: AppInterface, auth: AuthInterface) {
app.get("/auth/genkey", {
async handler(request) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();

View file

@ -1,41 +0,0 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export default function register(app, auth) {
app.post("/auth/heirs", {
async handler(request, reply) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
if (payload.uid == null) {
reply.status(401);
return;
}
let user = await auth.user(payload.uid);
user.heirs = request.body;
await auth.updateUser(payload.uid, user);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
body: {
type: "array",
},
},
});
}

View file

@ -0,0 +1,90 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { randomUUID } from "node:crypto";
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { DatabaseInterface } from "../../database.js";
import { Static, Type } from "@sinclair/typebox";
const Body = Type.Object({
contactMethod: Type.String(),
name: Type.String(),
value: Type.String(),
})
type BodyType = Static<typeof Body>;
export default function register(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
app.put<{ Body: BodyType }>("/auth/heirs", {
async handler(request, reply) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
if (payload.uid == null) {
reply.status(401);
return;
}
if (request.body.contactMethod !== "email") {
reply.status(400);
return;
}
await database.insertHeir({
id: randomUUID(),
createdAt: Date.now(),
userID: payload.uid,
name: request.body.name,
email: request.body.value,
});
return (await database.listHeirs(payload.uid))
.map(v => v["contactMethod"] = "email")
.map(v => v["value"] = v["email"])
.map(v => v["email"] = undefined);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
body: Body,
},
});
app.delete<{ Body: string }>("/auth/heirs", {
async handler(request, reply) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
if (payload.uid == null) {
reply.status(401);
return;
}
await database.removeHeir(request.body);
return (await database.listHeirs(payload.uid))
.map(v => v["contactMethod"] = "email")
.map(v => v["value"] = v["email"])
.map(v => v["email"] = undefined);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
body: {
type: "string",
},
},
});
}

View file

@ -14,6 +14,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { DatabaseInterface } from "../../database.js";
import account from "./account.js";
import genkey from "./genkey.js";
@ -21,10 +24,10 @@ import heirs from "./heirs.js";
import login from "./login.js";
import register from "./register.js";
export default function registerRoutes(app, auth) {
export default function registerRoutes(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
account(app, auth);
genkey(app, auth);
heirs(app, auth);
heirs(app, auth, database);
login(app, auth);
register(app, auth);
}

View file

@ -14,14 +14,24 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Static, Type } from "@sinclair/typebox";
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
export default function register(app, auth) {
app.post("/auth/login", {
const Body = Type.Object({
email: Type.String({ format: "email" }),
password: Type.String(),
});
type BodyType = Static<typeof Body>;
export default function register(app: AppInterface, auth: AuthInterface) {
app.post<{ Body: BodyType }>("/auth/login", {
async handler(request, reply) {
let user = await auth.findUserByEmail(request.body.email);
if (user != null && user.password == request.body.password) {
let token = await auth.createJwt(user.uid);
if (user != null && auth.verifyPassword(user, request.body.password)) {
let token = await auth.createJwt(user.id);
return {
token,
@ -34,14 +44,7 @@ export default function register(app, auth) {
};
},
schema: {
body: {
type: "object",
properties: {
email: { type: "string" },
password: { type: "string" },
},
},
required: ["email", "password"],
body: Body,
},
});
}

View file

@ -14,23 +14,29 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Static, Type } from "@sinclair/typebox";
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
export default function register(app, auth) {
app.post("/auth/register", {
const Body = Type.Object({
name: Type.String(),
email: Type.String({ format: "email" }),
password: Type.String(),
});
type BodyType = Static<typeof Body>;
export default function register(app: AppInterface, auth: AuthInterface) {
app.post<{ Body: BodyType }>("/auth/register", {
async handler(request, reply) {
if ((await auth.findUserByEmail(request.body.email)) == null) {
let user = await auth.addUser({
email: request.body.email,
password: request.body.password,
name: request.body.name,
assets: [],
limits: {
assetCount: 5,
},
entries: [],
});
return { token: await auth.createJwt(user.uid) };
return { token: await auth.createJwt(user.id) };
}
reply.code(400);
@ -39,15 +45,7 @@ export default function register(app, auth) {
};
},
schema: {
body: {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
password: { type: "string" },
},
required: ["name", "email", "password"],
},
body: Body,
},
});
}

View file

@ -1,68 +0,0 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { randomUUID } from "node:crypto";
import list from "./list.js";
export default function registerRoutes(app, auth) {
list(app, auth);
app.delete("/entry", {
async handler(request) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
let user = await auth.user(payload.uid);
user.entries = user.entries.filter((v) => v.id !== request.query.entry_id);
await auth.updateUser(payload.uid, user);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
query: {
type: "object",
properties: {
entry_id: { type: "string" },
},
required: ["entry_id"],
},
},
});
app.put("/entry", {
async handler(request) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
let user = await auth.user(payload.uid);
request.body.entry.id = randomUUID().toString();
user.entries = [request.body.entry, ...user.entries];
await auth.updateUser(payload.uid, user);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
body: {
type: "object",
properties: {
entry: { type: "object" },
},
required: ["entry"],
},
},
});
}

View file

@ -0,0 +1,145 @@
// Identity. Store your memories and mental belongings
// Copyright (C) 2024 Sofía Aritz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { randomUUID } from "node:crypto";
import list from "./list.js";
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { toDBList, type DatabaseInterface } from "../../database.js";
import { Static, Type } from "@sinclair/typebox";
const EntryIDQuery = Type.Object({
entry_id: Type.String(),
})
type EntryIDQueryType = Static<typeof EntryIDQuery>;
const PutEntryBody = Type.Object({
entry: Type.Object({
title: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
creationDate: Type.String(),
assets: Type.Array(Type.String()),
feelings: Type.Array(
Type.Union([
Type.String(),
Type.Object({
identifier: Type.String(),
description: Type.String(),
backgroundColor: Type.String(),
textColor: Type.String(),
}),
])
),
base: Type.Union([
Type.Object({
kind: Type.String(),
}),
Type.Object({
kind: Type.String(),
artist: Type.String(),
title: Type.String(),
link: Type.Array(Type.String()),
id: Type.Array(Type.Object({
provider: Type.String(),
id: Type.String(),
})),
}),
Type.Object({
kind: Type.String(),
location: Type.Union([
Type.String(),
Type.Object({
latitude: Type.Number(),
longitude: Type.Number(),
}),
]),
}),
Type.Object({
kind: Type.String(),
referencedDate: Type.String(),
}),
]),
})
});
type PutEntryBodyType = Static<typeof PutEntryBody>;
export default function registerRoutes(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
list(app, auth, database);
app.delete<{ Querystring: EntryIDQueryType }>("/entry", {
async handler(request) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
let user = await auth.user(payload.uid);
database.removeEntry(request.query.entry_id);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
querystring: EntryIDQuery,
},
});
app.put<{ Body: PutEntryBodyType }>("/entry", {
async handler(request) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
let entry = request.body.entry;
let musicEntry, locationEntry, dateEntry;
if ((entry.base.kind === "album" || entry.base.kind === "song") && 'artist' in entry.base) {
musicEntry = {
id: randomUUID(),
title: entry.base.title,
artist: entry.base.artist,
links: toDBList(entry.base.link),
universalIDs: toDBList(entry.base.id),
}
} else if (entry.base.kind === "environment" && 'location' in entry.base) {
locationEntry = {
id: randomUUID(),
locationText: typeof entry.base.location === "string" ? entry.base.location : undefined,
locationCoordinates: typeof entry.base.location === "string" ? undefined : JSON.stringify(entry.base.location),
}
} else if (entry.base.kind === "date" && 'referencedDate' in entry.base) {
dateEntry = {
id: randomUUID(),
referencedDate: Date.parse(entry.base.referencedDate),
}
}
await database.insertEntry({
id: randomUUID(),
userID: payload.uid,
feelings: toDBList(entry.feelings),
assets: toDBList(entry.assets),
title: entry.title,
description: entry.description,
kind: entry.base.kind,
createdAt: Date.now(),
}, musicEntry, locationEntry, dateEntry);
},
schema: {
headers: { $ref: "schema://identity/authorization" },
body: PutEntryBody,
},
});
}

View file

@ -14,9 +14,20 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Static, Type } from "@sinclair/typebox";
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import type { DatabaseInterface } from "../../database.js";
export default function register(app, auth) {
app.get("/entry/list", {
const Query = Type.Object({
limit: Type.Number(),
offset: Type.Number(),
})
type QueryType = Static<typeof Query>;
export default function register(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
app.get<{ Querystring: QueryType }>("/entry/list", {
async handler(request, reply) {
if (request.query.offset < 0 || request.query.limit <= 0) {
reply.status(400);
@ -26,19 +37,11 @@ export default function register(app, auth) {
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
let { payload } = await auth.verifyJwt(jwt);
let user = await auth.user(payload.uid);
return user.entries.slice(request.query.offset, request.query.offset + request.query.limit);
return await database.entryPage(payload.uid, request.query.offset, request.query.limit)
},
schema: {
headers: { $ref: "schema://identity/authorization" },
query: {
type: "object",
properties: {
limit: { type: "number" },
offset: { type: "number" },
},
required: ["limit", "offset"],
},
querystring: Query,
},
});
}

View file

@ -14,15 +14,18 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import m2m from "./m2m/index.js";
import asset from "./asset/index.js";
import entry from "./entry/index.js";
import authRoutes from "./auth/index.js";
export function registerRoutes(app, auth) {
m2m(app, auth);
asset(app, auth);
entry(app, auth);
authRoutes(app, auth);
import type { AppInterface } from "../app.js";
import type { DatabaseInterface } from "../database.js";
import type { AuthInterface } from "../auth.js";
export function registerRoutes(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
m2m(app, auth, database);
asset(app);
entry(app, auth, database);
authRoutes(app, auth, database);
}

View file

@ -14,24 +14,30 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { contentFromSigned, verifySignature } from "../../m2m.js";
export default function register(app, auth) {
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { DatabaseInterface, fromDBList } from "../../database.js";
export default function register(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
app.post("/m2m/account", {
async handler(request, reply) {
if (!verifySignature(request.body)) {
reply.statusCode(401);
reply.status(401);
return;
}
let body = JSON.parse(contentFromSigned(request.body));
let user = await auth.findUserBySessionKey(body.session_key);
user.password = undefined;
user.entries = undefined;
let limits = await database.userLimits(user.limitID);
(user.assets as any) = fromDBList(user.assets);
return user;
return {
user: auth.cleanUser(user),
limits,
};
},
});
}

View file

@ -14,23 +14,29 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { contentFromSigned, verifySignature } from "../../m2m.js";
export default function register(app, auth) {
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { fromDBList } from "../../database.js";
export default function register(app: AppInterface, auth: AuthInterface) {
app.put("/m2m/asset", {
async handler(request, reply) {
if (!verifySignature(request.body)) {
reply.statusCode(401);
reply.status(401);
return;
}
let body = JSON.parse(contentFromSigned(request.body));
let user = await auth.findUserBySessionKey(body.session_key);
user.assets.push(body.asset_id);
await auth.updateUser(user.uid, user);
let assets = fromDBList(user.assets) as string[];
assets.push(body.asset_id);
await auth.updateUser(user.id, {
assets,
});
app.log.info((await auth.findUserBySessionKey(body.session_key)).assets);
},
});

View file

@ -14,11 +14,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import asset from "./asset.js";
import account from "./account.js";
export default function registerRoutes(app, auth) {
import type { AppInterface } from "../../app.js";
import type { AuthInterface } from "../../auth.js";
import { DatabaseInterface } from "../../database.js";
export default function registerRoutes(app: AppInterface, auth: AuthInterface, database: DatabaseInterface) {
asset(app, auth);
account(app, auth);
account(app, auth, database);
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ES2021",
"moduleResolution": "NodeNext",
"sourceMap": true,
"outDir": "dist",
"types": ["node"],
"skipLibCheck": true
},
"include": ["./src/**/*"],
"exclude": ["./node_modules/**/*"]
}

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,14 @@ export type Credentials = {
token: string;
};
export type InsertHeir = {
contactMethod: 'email';
name: string;
value: string;
};
export type AccountHeir = {
id: string;
contactMethod: 'email';
name: string;
value: string;
@ -22,8 +29,8 @@ export type AccountHeir = {
export type Account = {
uid: string;
email: string;
name: string;
heirs: AccountHeir[];
};
function sendRequest(
@ -119,14 +126,21 @@ export async function deleteEntry(credentials: Credentials, entry_id: string): P
await sendRequest('/entry', credentials, { method: 'DELETE' }, `?entry_id=${entry_id}`);
}
export async function updateHeirs(credentials: Credentials, heirs: AccountHeir[]): Promise<void> {
await sendRequest('/auth/heirs', credentials, {
method: 'POST',
export async function insertHeir(credentials: Credentials, heirs: InsertHeir): Promise<AccountHeir[]> {
return await asJson(sendRequest('/auth/heirs', credentials, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(heirs)
});
}));
}
export async function removeHeir(credentials: Credentials, heirID: string): Promise<AccountHeir[]> {
return await asJson(sendRequest('/auth/heirs', credentials, {
method: 'DELETE',
body: heirID,
}));
}
export async function uploadAsset(session_key: string, file: File): Promise<string> {

View file

@ -5,7 +5,7 @@
<script lang="ts">
import { createForm } from 'felte';
import { account, credentials, refreshAccount } from '$lib/stores';
import { type AccountHeir, updateHeirs } from '$lib/api';
import { type AccountHeir, insertHeir, removeHeir } from '$lib/api';
credentials.subscribe(
(v) => v == null && setTimeout(() => (window.location.pathname = '/auth/login'), 200)
@ -24,7 +24,7 @@
let currentHeirs = structuredClone($account!.heirs);
let updatedHeirs = [heir, ...currentHeirs];
await updateHeirs($credentials!, updatedHeirs);
//await updateHeirs($credentials!, updatedHeirs);
await refreshAccount();
heirWizard = false;

View file

@ -124,6 +124,9 @@
{:then entries}
<div class="mt-3.5 flex justify-center">
<div class="flex w-[60%] flex-col">
<h1 class="pb-3.5 text-2xl">
Welcome back, <span class="font-bold">{$account?.name}</span>.
</h1>
{#if entries.length === 0}
<a
href="/entry/new"
@ -133,10 +136,6 @@
<h2 class="text-xl font-semibold">Add an entry</h2>
</a>
{:else}
<h1 class="pb-3.5 text-2xl">
Welcome back, <span class="font-bold">{$account?.name}</span>
.
</h1>
<div class="flex gap-2">
{#await overview}
<span>Loading...</span>