more progress yipee
This commit is contained in:
parent
94b3cda887
commit
77816595f5
24 changed files with 989 additions and 411 deletions
|
@ -39,8 +39,13 @@ fastify.get("/crypto/algo", () => {
|
|||
});
|
||||
|
||||
fastify.put("/asset", {
|
||||
async handler(request) {
|
||||
await userFromSessionKey(request.query.session_key);
|
||||
async handler(request, reply) {
|
||||
let user = await userFromSessionKey(request.query.session_key);
|
||||
if (user.assets.length >= user.limits.assetCount) {
|
||||
reply.code(403);
|
||||
return "Max asset count reached. Contact support or upgrade your plan";
|
||||
}
|
||||
|
||||
let file = await request.file();
|
||||
|
||||
let id = randomUUID().toString();
|
||||
|
@ -75,12 +80,18 @@ fastify.get("/asset", {
|
|||
async handler(request, reply) {
|
||||
let user = await userFromSessionKey(request.query.session_key);
|
||||
|
||||
if ('statusCode' in user) {
|
||||
reply.code(500);
|
||||
return "Something failed, please try again later"
|
||||
}
|
||||
|
||||
if (user.assets.includes(request.query.asset_id)) {
|
||||
let path = join(ASSETS_FOLDER, request.query.asset_id);
|
||||
|
||||
reply.type(mime.getType(path));
|
||||
reply.send(await readFile(path));
|
||||
} else {
|
||||
reply.code(401);
|
||||
return "Not authorized";
|
||||
}
|
||||
},
|
||||
|
|
5
identity-api/.env.example
Normal file
5
identity-api/.env.example
Normal file
|
@ -0,0 +1,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
|
1
identity-api/.gitignore
vendored
1
identity-api/.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
.yarn
|
||||
.env
|
|
@ -1,266 +0,0 @@
|
|||
import { createVerify } from "node:crypto";
|
||||
import Fastify from "fastify";
|
||||
import * as Jose from "jose";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import assert from "node:assert";
|
||||
|
||||
const IDENTITY_API_LANDING_MESSAGE = "identity-api v1.0.0";
|
||||
const JWT_SECRET = new TextEncoder().encode("cc7e0d44fd473002f1c42167459001140ec6389b7353f8088f4d9a95f2f596f2");
|
||||
const JWT_ALG = "HS256";
|
||||
|
||||
const ASSET_API_ENDPOINT = "http://localhost:3001/";
|
||||
let ASSET_API_PUBKEY = await loadAssetPubkey();
|
||||
let ASSET_API_ALGORITHM = await loadAssetAlgo();
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
let pubkey = await loadAssetPubkey();
|
||||
let algo = await loadAssetAlgo();
|
||||
|
||||
if (pubkey != null && algo != null) {
|
||||
if (ASSET_API_PUBKEY !== pubkey) {
|
||||
console.warn("The M2M public key has changed!");
|
||||
}
|
||||
|
||||
if (ASSET_API_ALGORITHM !== algo) {
|
||||
console.warn("The M2M algorith has changed!");
|
||||
}
|
||||
|
||||
ASSET_API_PUBKEY = pubkey;
|
||||
ASSET_API_ALGORITHM = algo;
|
||||
console.debug("Successfully updated the M2M public key and algorithm");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to update the M2M public key", e);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
let session_keys = {
|
||||
"uid:005d6417-a23c-48bd-b348-eafeae649b94": "e381ba8c-e18a-4bca-afce-b212b37bc26b",
|
||||
"key:e381ba8c-e18a-4bca-afce-b212b37bc26b": "005d6417-a23c-48bd-b348-eafeae649b94",
|
||||
};
|
||||
|
||||
let users = {
|
||||
"jane@identity.net": {
|
||||
uid: "005d6417-a23c-48bd-b348-eafeae649b94",
|
||||
email: "jane@identity.net",
|
||||
password: "12345678901234567890",
|
||||
name: "Jane Doe",
|
||||
assets: ["f9d040d6-598c-4483-952f-08e7d35d5420.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
fastify.get("/", async () => {
|
||||
return IDENTITY_API_LANDING_MESSAGE;
|
||||
});
|
||||
|
||||
fastify.put("/m2m/asset", {
|
||||
async handler(request, reply) {
|
||||
if (!verifySignature(request.body, ASSET_API_PUBKEY)) {
|
||||
reply.statusCode(401);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = JSON.parse(getContentFromSigned(request.body));
|
||||
|
||||
let uid = session_keys[`key:${body.session_key}`];
|
||||
let user = Object.values(users).filter((v) => v.uid === uid);
|
||||
assert(user.length === 1);
|
||||
|
||||
users[user[0].email].assets.push(body.asset_id);
|
||||
console.log(users[user[0].email].assets);
|
||||
},
|
||||
});
|
||||
|
||||
fastify.post("/m2m/account", {
|
||||
async handler(request, reply) {
|
||||
if (!verifySignature(request.body, ASSET_API_PUBKEY)) {
|
||||
reply.statusCode(401);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = JSON.parse(getContentFromSigned(request.body));
|
||||
|
||||
let uid = session_keys[`key:${body.session_key}`];
|
||||
let user = Object.values(users).filter((v) => v.uid === uid);
|
||||
|
||||
assert(user.length === 1);
|
||||
user[0].password = undefined;
|
||||
|
||||
return user[0];
|
||||
},
|
||||
});
|
||||
|
||||
fastify.get("/auth/genkey", {
|
||||
async handler(request) {
|
||||
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
|
||||
let { payload } = await Jose.jwtVerify(jwt, JWT_SECRET);
|
||||
|
||||
let key = uuidv4();
|
||||
session_keys[`uid:${payload.uid}`] = key;
|
||||
session_keys[`key:${key}`] = payload.uid;
|
||||
|
||||
return {
|
||||
session_key: session_keys[payload.uid],
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
headers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.get("/auth/account", {
|
||||
async handler(request) {
|
||||
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
|
||||
let { payload } = await Jose.jwtVerify(jwt, JWT_SECRET);
|
||||
|
||||
let user = users[payload.email];
|
||||
user.password = undefined;
|
||||
return user;
|
||||
},
|
||||
schema: {
|
||||
headers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.post("/auth/login", {
|
||||
async handler(request, reply) {
|
||||
let user = users[request.body.email];
|
||||
if (user != null && user.password == request.body.password) {
|
||||
let token = await new Jose.SignJWT({
|
||||
uid: user.uid,
|
||||
email: request.body.email,
|
||||
name: user.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: JWT_ALG })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("4w")
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return {
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "invalid credentials",
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.post("/auth/register", {
|
||||
async handler(request, reply) {
|
||||
if (users[request.body.email] == null) {
|
||||
users[request.body.email] = {
|
||||
uid: uuidv4(),
|
||||
email: request.body.email,
|
||||
password: request.body.password,
|
||||
name: request.body.name,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
let user = users[request.body.email];
|
||||
let token = await new Jose.SignJWT({
|
||||
uid: user.uid,
|
||||
email: request.body.email,
|
||||
name: user.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: JWT_ALG })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("4w")
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "invalid data",
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.listen({ port: 3000 });
|
||||
|
||||
async function loadAssetPubkey() {
|
||||
let url = new URL(ASSET_API_ENDPOINT);
|
||||
url.pathname = "/crypto/cert";
|
||||
|
||||
let res = await fetch(url);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async function loadAssetAlgo() {
|
||||
let url = new URL(ASSET_API_ENDPOINT);
|
||||
url.pathname = "/crypto/algo";
|
||||
|
||||
let res = await fetch(url);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} content
|
||||
* @param {string} pubkeyText
|
||||
*/
|
||||
function verifySignature(content, pubkeyText) {
|
||||
let parts = content
|
||||
.replace("-----BEGIN SIGNED MESSAGE-----\n\n", "")
|
||||
.replace("\n-----END SIGNATURE-----", "")
|
||||
.split("\n\n-----BEGIN SIGNATURE-----\n\n");
|
||||
|
||||
assert(parts.length === 2);
|
||||
|
||||
let verify = createVerify(ASSET_API_ALGORITHM);
|
||||
verify.update(parts[0]);
|
||||
|
||||
let pubkey = Buffer.from(pubkeyText, "ascii");
|
||||
let digest = Buffer.from(parts[1], "base64");
|
||||
|
||||
return verify.verify(pubkey, digest);
|
||||
}
|
||||
|
||||
function getContentFromSigned(content) {
|
||||
let parts = content
|
||||
.replace("-----BEGIN SIGNED MESSAGE-----\n\n", "")
|
||||
.replace("\n-----END SIGNATURE-----", "")
|
||||
.split("\n\n-----BEGIN SIGNATURE-----\n\n");
|
||||
|
||||
assert(parts.length === 2);
|
||||
|
||||
return parts[0];
|
||||
}
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"name": "identity-api",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"main": "src/index.js",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "yarn@4.3.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.27.0",
|
||||
"jose": "^5.4.0",
|
||||
"uuid": "^10.0.0"
|
||||
"jose": "^5.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"start": "node src/index.js",
|
||||
"lint:fix": "eslint --fix && prettier . --write",
|
||||
"lint": "eslint && prettier . --check"
|
||||
},
|
||||
|
|
5
identity-api/src/app.js
Normal file
5
identity-api/src/app.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Fastify from "fastify";
|
||||
|
||||
export default Fastify({
|
||||
logger: true,
|
||||
});
|
159
identity-api/src/auth.js
Normal file
159
identity-api/src/auth.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
import assert from "node:assert";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as Jose from "jose";
|
||||
import { JWT_ALG, JWT_SECRET } from "./consts.js";
|
||||
|
||||
export async function startAuth() {
|
||||
let session_keys = {
|
||||
"uid:005d6417-a23c-48bd-b348-eafeae649b94": "e381ba8c-e18a-4bca-afce-b212b37bc26b",
|
||||
"key:e381ba8c-e18a-4bca-afce-b212b37bc26b": "005d6417-a23c-48bd-b348-eafeae649b94",
|
||||
};
|
||||
|
||||
let users = {
|
||||
"jane@identity.net": {
|
||||
uid: "005d6417-a23c-48bd-b348-eafeae649b94",
|
||||
email: "jane@identity.net",
|
||||
password: "12345678901234567890",
|
||||
name: "Jane Doe",
|
||||
assets: ["f9d040d6-598c-4483-952f-08e7d35d5420.jpg"],
|
||||
limits: {
|
||||
assetCount: 5,
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
id: "0",
|
||||
creationDate: new Date("2024-04-13"),
|
||||
feelings: ["active", "happy"],
|
||||
base: {
|
||||
kind: "song",
|
||||
id: [{ provider: "spotify", id: "53mChDyESfwn9Dz8poHRf6" }],
|
||||
link: ["https://open.spotify.com/track/53mChDyESfwn9Dz8poHRf6"],
|
||||
title: "Taking What's Not Yours",
|
||||
artist: "TV Girl",
|
||||
},
|
||||
assets: [],
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
creationDate: new Date("2024-04-13"),
|
||||
feelings: [],
|
||||
base: {
|
||||
kind: "album",
|
||||
id: [{ provider: "spotify", id: "1d2PspdXmwrBEcOtquCvzT" }],
|
||||
link: ["https://open.spotify.com/album/1d2PspdXmwrBEcOtquCvzT"],
|
||||
title: "CHASER",
|
||||
artist: "femtanyl",
|
||||
},
|
||||
assets: [],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
creationDate: new Date("2024-04-26"),
|
||||
feelings: ["excited"],
|
||||
title: "SalmorejoTech 2024",
|
||||
description: "SalmorejoTech is a great tech-event. I met some people and everything went great! :)",
|
||||
base: {
|
||||
kind: "event",
|
||||
},
|
||||
assets: [],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
creationDate: new Date("2024-06-26"),
|
||||
feelings: ["happy", "relaxed"],
|
||||
//title: "At the sunflower field with Ms. Violet",
|
||||
title: "Playing Minecraft with Mr. Pablo",
|
||||
//description: "Ms. Violet is my friend, she is a great friend. We spent a good time at the sunflower field. I am lucky to have a friend like her.",
|
||||
description: "Mr. Pablo is my friend, she is a great friend. We spent a good time playing Minecraft. I am lucky to have a friend like him.",
|
||||
base: {
|
||||
kind: "memory",
|
||||
},
|
||||
assets: [],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
creationDate: new Date("2024-01-01"),
|
||||
feelings: ["excited", "nervous"],
|
||||
description: "New Year, New me! I'm really excited about what's going to happen this year, lots of changes. Changes may be scary, but they usually are for good!",
|
||||
base: {
|
||||
kind: "feeling",
|
||||
},
|
||||
assets: ["f9d040d6-598c-4483-952f-08e7d35d5420.jpg"],
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
creationDate: new Date("2024-04-28"),
|
||||
feelings: ["happy", "relaxed"],
|
||||
title: "The park",
|
||||
description: "The park is a really chill place where I can go and relax for a bit before going to work.",
|
||||
base: {
|
||||
kind: "environment",
|
||||
},
|
||||
assets: [],
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
creationDate: new Date("2024-04-28"),
|
||||
feelings: ["happy"],
|
||||
description: "This day has been a great day! I've talked with my friends.",
|
||||
base: {
|
||||
kind: "date",
|
||||
referencedDate: new Date("2024-04-27"),
|
||||
},
|
||||
assets: [],
|
||||
}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let funcs = {
|
||||
user: async (uid) => {
|
||||
let user = Object.values(users).filter((v) => v.uid === uid);
|
||||
assert(user.length <= 1);
|
||||
|
||||
return structuredClone(user[0]);
|
||||
},
|
||||
findUserByEmail: async (email) => {
|
||||
return structuredClone(users[email]);
|
||||
},
|
||||
findUserBySessionKey: async (session_key) => {
|
||||
let uid = session_keys[`key:${session_key}`];
|
||||
return await funcs.user(uid);
|
||||
},
|
||||
updateUser: async (uid, newUser) => {
|
||||
let user = await funcs.user(uid);
|
||||
users[user.email] = newUser;
|
||||
},
|
||||
addUser: async (user) => {
|
||||
user.uid = randomUUID().toString();
|
||||
users[user.email] = user;
|
||||
|
||||
return structuredClone(users[user.email]);
|
||||
},
|
||||
createSessionKey: async (uid) => {
|
||||
let key = randomUUID().toString();
|
||||
session_keys[`uid:${uid}`] = key;
|
||||
session_keys[`key:${key}`] = uid;
|
||||
|
||||
return key
|
||||
},
|
||||
createJwt: async (uid) => {
|
||||
let user = await funcs.user(uid)
|
||||
|
||||
return await new Jose.SignJWT({
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: JWT_ALG })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("4w")
|
||||
.sign(JWT_SECRET);
|
||||
},
|
||||
verifyJwt: async (jwt) => {
|
||||
return await Jose.jwtVerify(jwt, JWT_SECRET);
|
||||
},
|
||||
}
|
||||
|
||||
return funcs;
|
||||
}
|
18
identity-api/src/consts.js
Normal file
18
identity-api/src/consts.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import "dotenv/config"
|
||||
import app from "./app.js";
|
||||
|
||||
const REQUIRED_VARS = ["IDENTITY_API_JWT_SECRET", "IDENTITY_API_ASSET_API_ENDPOINT", "IDENTITY_API_JWT_ALG"];
|
||||
|
||||
REQUIRED_VARS.forEach(element => {
|
||||
if (process.env[element] == null || (typeof process.env[element] === "string" && process.env[element].length === 0)) {
|
||||
app.log.error(`Required environment variable was not set: ${element}`);
|
||||
app.close().then(() => process.exit(1))
|
||||
}
|
||||
});
|
||||
|
||||
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 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
|
226
identity-api/src/index.js
Normal file
226
identity-api/src/index.js
Normal file
|
@ -0,0 +1,226 @@
|
|||
import app from "./app.js"
|
||||
import { ASSET_API_ENDPOINT, IDENTITY_API_LANDING_MESSAGE, LISTEN_PORT } from "./consts.js"
|
||||
import { contentFromSigned, verifySignature } from "./m2m.js";
|
||||
import { startAuth } from "./auth.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
let auth = await startAuth();
|
||||
|
||||
app.get("/", async () => {
|
||||
return IDENTITY_API_LANDING_MESSAGE;
|
||||
});
|
||||
|
||||
app.put("/m2m/asset", {
|
||||
async handler(request, reply) {
|
||||
if (!verifySignature(request.body)) {
|
||||
reply.statusCode(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);
|
||||
app.log.info((await auth.findUserBySessionKey(body.session_key)).assets);
|
||||
},
|
||||
});
|
||||
|
||||
app.post("/m2m/account", {
|
||||
async handler(request, reply) {
|
||||
if (!verifySignature(request.body)) {
|
||||
reply.statusCode(401);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = JSON.parse(contentFromSigned(request.body));
|
||||
|
||||
let user = await auth.findUserBySessionKey(body.session_key)
|
||||
user.password = undefined;
|
||||
user.entries = undefined;
|
||||
|
||||
return user;
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/asset/endpoint", {
|
||||
async handler() {
|
||||
return ASSET_API_ENDPOINT
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/entry", {
|
||||
async handler(request, reply) {
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
required: ["authorization"],
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entry: { type: "object" },
|
||||
},
|
||||
required: ["entry"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app.get("/entry/list", {
|
||||
async handler(request, reply) {
|
||||
if (request.query.offset < 0 || request.query.limit <= 0) {
|
||||
reply.status(400);
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
schema: {
|
||||
headers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
required: ["authorization"],
|
||||
},
|
||||
query: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number" },
|
||||
offset: { type: "number" },
|
||||
},
|
||||
required: ["limit", "offset"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app.get("/auth/genkey", {
|
||||
async handler(request) {
|
||||
let jwt = request.headers["authorization"].replace("Bearer", "").trim();
|
||||
let { payload } = await auth.verifyJwt(jwt);
|
||||
|
||||
let session_key = await auth.createSessionKey(payload.uid);
|
||||
|
||||
return {
|
||||
session_key,
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
headers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
required: ["authorization"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/auth/account", {
|
||||
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.password = undefined;
|
||||
user.entries = undefined;
|
||||
return user;
|
||||
},
|
||||
schema: {
|
||||
headers: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authorization: { type: "string" },
|
||||
},
|
||||
required: ["authorization"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.post("/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);
|
||||
|
||||
return {
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "invalid credentials",
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["email", "password"],
|
||||
},
|
||||
});
|
||||
|
||||
app.post("/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) };
|
||||
}
|
||||
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "invalid data",
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
required: ["name", "email", "password"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.listen({ port: LISTEN_PORT });
|
76
identity-api/src/m2m.js
Normal file
76
identity-api/src/m2m.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { createVerify } from "node:crypto";
|
||||
import assert from "node:assert"
|
||||
import app from "./app.js";
|
||||
import { ASSET_API_ENDPOINT, ASSET_API_M2M_REFRESH_INTERVAL } from "./consts.js";
|
||||
|
||||
let assetPubKey = await fetchAssetPubkey()
|
||||
let assetAlgorithm = await fetchAssetAlgorithm()
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
let pubkey = await fetchAssetPubkey();
|
||||
let algo = await fetchAssetAlgorithm();
|
||||
|
||||
if (pubkey != null && algo != null) {
|
||||
if (assetPubKey !== pubkey) {
|
||||
app.log.warn("The M2M public key has changed!")
|
||||
}
|
||||
|
||||
if (assetAlgorithm !== algo) {
|
||||
app.log.warn("The M2M algorith has changed!");
|
||||
}
|
||||
|
||||
assetPubKey = pubkey;
|
||||
assetAlgorithm = algo;
|
||||
app.log.debug("Successfully updated the M2M credentials");
|
||||
} else {
|
||||
app.log.warn("Failed to retrieve the M2M credentials");
|
||||
}
|
||||
} catch (e) {
|
||||
app.log.warn("Failed to update the M2M credentials");
|
||||
app.log.warn(e);
|
||||
}
|
||||
}, ASSET_API_M2M_REFRESH_INTERVAL)
|
||||
|
||||
async function fetchAssetPubkey() {
|
||||
let url = new URL(ASSET_API_ENDPOINT);
|
||||
url.pathname = "/crypto/cert";
|
||||
|
||||
let res = await fetch(url);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async function fetchAssetAlgorithm() {
|
||||
let url = new URL(ASSET_API_ENDPOINT);
|
||||
url.pathname = "/crypto/algo";
|
||||
|
||||
let res = await fetch(url);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
function partsFromSigned(content) {
|
||||
let parts = content
|
||||
.replace("-----BEGIN SIGNED MESSAGE-----\n\n", "")
|
||||
.replace("\n-----END SIGNATURE-----", "")
|
||||
.split("\n\n-----BEGIN SIGNATURE-----\n\n");
|
||||
|
||||
assert(parts.length === 2);
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
export function verifySignature(content) {
|
||||
let parts = partsFromSigned(content)
|
||||
|
||||
let verify = createVerify(assetAlgorithm);
|
||||
verify.update(parts[0]);
|
||||
|
||||
let pubkey = Buffer.from(assetPubKey, "ascii");
|
||||
let digest = Buffer.from(parts[1], "base64");
|
||||
|
||||
return verify.verify(pubkey, digest);
|
||||
}
|
||||
|
||||
export function contentFromSigned(content) {
|
||||
return partsFromSigned(content)[0];
|
||||
}
|
|
@ -379,6 +379,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^16.4.5":
|
||||
version: 16.4.5
|
||||
resolution: "dotenv@npm:16.4.5"
|
||||
checksum: 10c0/48d92870076832af0418b13acd6e5a5a3e83bb00df690d9812e94b24aff62b88ade955ac99a05501305b8dc8f1b0ee7638b18493deb6fe93d680e5220936292f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"escape-string-regexp@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "escape-string-regexp@npm:4.0.0"
|
||||
|
@ -706,12 +713,12 @@ __metadata:
|
|||
resolution: "identity-api@workspace:."
|
||||
dependencies:
|
||||
"@eslint/js": "npm:^9.5.0"
|
||||
dotenv: "npm:^16.4.5"
|
||||
eslint: "npm:9.x"
|
||||
fastify: "npm:^4.27.0"
|
||||
globals: "npm:^15.5.0"
|
||||
jose: "npm:^5.4.0"
|
||||
prettier: "npm:3.3.2"
|
||||
uuid: "npm:^10.0.0"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
@ -1285,15 +1292,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "uuid@npm:10.0.0"
|
||||
bin:
|
||||
uuid: dist/bin/uuid
|
||||
checksum: 10c0/eab18c27fe4ab9fb9709a5d5f40119b45f2ec8314f8d4cf12ce27e4c6f4ffa4a6321dc7db6c515068fa373c075b49691ba969f0010bf37f44c37ca40cd6bf7fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which@npm:^2.0.1":
|
||||
version: 2.0.2
|
||||
resolution: "which@npm:2.0.2"
|
||||
|
|
|
@ -36,8 +36,10 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/svelte-fontawesome": "^0.2.2",
|
||||
"felte": "^1.2.14"
|
||||
"felte": "^1.2.14",
|
||||
"mime": "^4.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import type { Entry, IdlessEntry } from "./entry"
|
||||
|
||||
const ENDPOINT = 'http://localhost:3000/'
|
||||
|
||||
export type Credentials = {
|
||||
|
@ -9,13 +11,14 @@ export type Account = {
|
|||
name: string,
|
||||
}
|
||||
|
||||
function sendRequest(path: string, request: RequestInit = {}, credentials?: Credentials) {
|
||||
function sendRequest(path: string, credentials?: Credentials, request: RequestInit = {}, params: string = "") {
|
||||
if (typeof request !== "string" && credentials != null) {
|
||||
request.headers = { 'Authorization': `Bearer ${credentials.token}`, ...request.headers }
|
||||
}
|
||||
|
||||
let url = new URL(ENDPOINT);
|
||||
url.pathname = path;
|
||||
url.search = params
|
||||
|
||||
return fetch(url, request)
|
||||
}
|
||||
|
@ -30,7 +33,7 @@ export function login(credentials: {
|
|||
email: string,
|
||||
password: string,
|
||||
}): Promise<{ token: string, } | { error: string, }> {
|
||||
return asJson(sendRequest('/auth/login', {
|
||||
return asJson(sendRequest('/auth/login', undefined, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -44,7 +47,7 @@ export function register(credentials: {
|
|||
email: string,
|
||||
password: string,
|
||||
}): Promise<{ token: string, } | { error: string, }> {
|
||||
return asJson(sendRequest('/auth/register', {
|
||||
return asJson(sendRequest('/auth/register', undefined, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -54,5 +57,28 @@ export function register(credentials: {
|
|||
}
|
||||
|
||||
export function accountData(credentials: Credentials): Promise<Account | { error: string }> {
|
||||
return asJson(sendRequest('/auth/account', undefined, credentials))
|
||||
return asJson(sendRequest('/auth/account', credentials))
|
||||
}
|
||||
|
||||
export function genSessionKey(credentials: Credentials): Promise<{ session_key: string } | { error: string }> {
|
||||
return asJson(sendRequest('/auth/genkey', credentials))
|
||||
}
|
||||
|
||||
export async function assetEndpoint(): Promise<string> {
|
||||
let res = await sendRequest("/asset/endpoint")
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export async function entryPage(credentials: Credentials, offset: number, limit: number): Promise<Entry[]> {
|
||||
return asJson(sendRequest('/entry/list', credentials, undefined, `?offset=${offset}&limit=${limit}`))
|
||||
}
|
||||
|
||||
export async function addEntry(credentials: Credentials, entry: IdlessEntry): Promise<void> {
|
||||
await sendRequest('/entry', credentials, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({entry}),
|
||||
})
|
||||
}
|
|
@ -1,17 +1,33 @@
|
|||
export const TITLED_ENTRIES = ["event", "environment", "memory"];
|
||||
export const FEELINGS = ["relaxed", "afraid", "angry", "bad", "bored", "confused", "excited", "fine", "happy", "hurt", "in love", "mad", "nervous", "okay", "sad", "scared", "shy", "sleepy", "active", "surprised", "tired", "upset", "worried"];
|
||||
|
||||
export type KnownFeeling = "relaxed" | "afraid" | "angry" | "bad" | "bored" | "confused" | "excited" | "fine" | "happy" | "hurt" | "in love" | "mad" | "nervous" | "okay" | "sad" | "scared" | "shy" | "sleepy" | "active" | "surprised" | "tired" | "upset" | "worried";
|
||||
|
||||
export type Entry = {
|
||||
id: string,
|
||||
export type IdlessEntry = {
|
||||
base: SongEntry | AlbumEntry | EventEntry | MemoryEntry | FeelingEntry | EnvironmentEntry | DateEntry,
|
||||
creationDate: Date,
|
||||
creationDate: string,
|
||||
feelings: (KnownFeeling | {
|
||||
identifier: string,
|
||||
description: string,
|
||||
backgroundColor: string,
|
||||
textColor: string,
|
||||
})[],
|
||||
assets: string[],
|
||||
title?: string,
|
||||
description?: string,
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
id: string,
|
||||
base: SongEntry | AlbumEntry | EventEntry | MemoryEntry | FeelingEntry | EnvironmentEntry | DateEntry,
|
||||
creationDate: string,
|
||||
feelings: (KnownFeeling | {
|
||||
identifier: string,
|
||||
description: string,
|
||||
backgroundColor: string,
|
||||
textColor: string,
|
||||
})[],
|
||||
assets: string[],
|
||||
title?: string,
|
||||
description?: string,
|
||||
};
|
||||
|
@ -59,5 +75,5 @@ export type EnvironmentEntry = {
|
|||
|
||||
export type DateEntry = {
|
||||
kind: "date",
|
||||
referencedDate: Date,
|
||||
referencedDate: string,
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { accountData, type Account, type Credentials } from "./api";
|
||||
import { accountData, assetEndpoint, genSessionKey, type Account, type Credentials } from "./api";
|
||||
|
||||
const CREDENTIALS_KEY = 'v0:credentials'
|
||||
|
||||
|
@ -11,6 +11,8 @@ credentials.subscribe((value) => {
|
|||
})
|
||||
|
||||
export const account = writable<Account | null>()
|
||||
export const session_key = writable<string | null>()
|
||||
export const asset_endpoint = writable<string | null>()
|
||||
|
||||
export async function initializeStores() {
|
||||
let rawCredentials = localStorage.getItem(CREDENTIALS_KEY)
|
||||
|
@ -31,5 +33,15 @@ export async function initializeStores() {
|
|||
} else {
|
||||
account.set(data)
|
||||
}
|
||||
|
||||
let key_result = await genSessionKey(parsedCredentials)
|
||||
if ('error' in key_result) {
|
||||
console.warn('Couldn\'t generate a session key!')
|
||||
} else {
|
||||
session_key.set(key_result.session_key)
|
||||
}
|
||||
|
||||
let asset_result = await assetEndpoint()
|
||||
asset_endpoint.set(asset_result)
|
||||
}
|
||||
}
|
|
@ -1,122 +1,52 @@
|
|||
<script lang="ts">
|
||||
import type { Entry } from "$lib/entry";
|
||||
import { account } from "$lib/stores";
|
||||
import { entryPage } from "$lib/api";
|
||||
import { account, credentials } from "$lib/stores";
|
||||
import Entries from "./Entries.svelte";
|
||||
import Overview from "./Overview.svelte"
|
||||
|
||||
const MOCK_ENTRIES: Entry[] = [
|
||||
{
|
||||
id: "0",
|
||||
creationDate: new Date("2024-04-13"),
|
||||
feelings: ["active", "happy"],
|
||||
base: {
|
||||
kind: "song",
|
||||
id: [{ provider: "spotify", id: "53mChDyESfwn9Dz8poHRf6" }],
|
||||
link: ["https://open.spotify.com/track/53mChDyESfwn9Dz8poHRf6"],
|
||||
title: "Taking What's Not Yours",
|
||||
artist: "TV Girl",
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
creationDate: new Date("2024-04-13"),
|
||||
feelings: [],
|
||||
base: {
|
||||
kind: "album",
|
||||
id: [{ provider: "spotify", id: "1d2PspdXmwrBEcOtquCvzT" }],
|
||||
link: ["https://open.spotify.com/album/1d2PspdXmwrBEcOtquCvzT"],
|
||||
title: "CHASER",
|
||||
artist: "femtanyl",
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
creationDate: new Date("2024-04-26"),
|
||||
feelings: ["excited"],
|
||||
title: "SalmorejoTech 2024",
|
||||
description: "SalmorejoTech is a great tech-event. I met some people and everything went great! :)",
|
||||
base: {
|
||||
kind: "event",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
creationDate: new Date("2024-06-26"),
|
||||
feelings: ["happy", "relaxed"],
|
||||
//title: "At the sunflower field with Ms. Violet",
|
||||
title: "Playing Minecraft with Mr. Pablo",
|
||||
//description: "Ms. Violet is my friend, she is a great friend. We spent a good time at the sunflower field. I am lucky to have a friend like her.",
|
||||
description: "Mr. Pablo is my friend, she is a great friend. We spent a good time playing Minecraft. I am lucky to have a friend like him.",
|
||||
base: {
|
||||
kind: "memory",
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
creationDate: new Date("2024-01-01"),
|
||||
feelings: ["excited", "nervous"],
|
||||
description: "New Year, New me! I'm really excited about what's going to happen this year, lots of changes. Changes may be scary, but they usually are for good!",
|
||||
base: {
|
||||
kind: "feeling",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
creationDate: new Date("2024-04-28"),
|
||||
feelings: ["happy", "relaxed"],
|
||||
title: "The park",
|
||||
description: "The park is a really chill place where I can go and relax for a bit before going to work.",
|
||||
base: {
|
||||
kind: "environment",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
creationDate: new Date("2024-04-28"),
|
||||
feelings: ["happy"],
|
||||
description: "This day has been a great day! I've talked with my friends.",
|
||||
base: {
|
||||
kind: "date",
|
||||
referencedDate: new Date("2024-04-27"),
|
||||
}
|
||||
}
|
||||
]
|
||||
credentials.subscribe((v) => v == null && (setTimeout(() => window.location.pathname = '/auth/login', 200)))
|
||||
let entries = entryPage($credentials!, 0, 20);
|
||||
let overview = Promise.allSettled([entryPage($credentials!, 0, 3), entryPage($credentials!, 20, 3)])
|
||||
</script>
|
||||
|
||||
{#if $account != null}
|
||||
{#await entries}
|
||||
<div class="justify-center flex mt-3.5">
|
||||
<div role="status" class="flex flex-col justify-center items-center gap-5">
|
||||
<span class="text-2xl">Loading entries...</span>
|
||||
<svg aria-hidden="true" class="inline w-9 h-9 text-gray-200 animate-spin fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{:then entries}
|
||||
<div class="mt-3.5 justify-center flex">
|
||||
<div class="w-[60%] flex flex-col">
|
||||
{#if entries.length === 0}
|
||||
<a href="/entry/new" class="flex h-60 flex-col items-center justify-center gap-3 rounded border border-gray-300 p-2 text-black">
|
||||
<span class="text-4xl">+</span>
|
||||
<h2 class="text-xl font-semibold">Add an entry</h2>
|
||||
</a>
|
||||
{:else}
|
||||
<h1 class="text-2xl pb-3.5">Welcome back, <span class="font-bold">{$account?.name}</span>.</h1>
|
||||
<div class="flex gap-2">
|
||||
<div class="p-6 border border-gray-200 rounded-lg shadow w-full">
|
||||
<h2 class="text-xl">Latest activity</h2>
|
||||
<div class="pt-2">
|
||||
<p>New song: <span class="font-bold">TV Girl ‐ Taking what's not yours</span></p>
|
||||
<p>New album: <span class="font-bold">femtanyl ‐ CHASER</span></p>
|
||||
<p>New memory: <a href="#memory" class="font-bold text-violet-600 hover:underline">§ At the sunflower field with Ms. Violet</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border border-gray-200 rounded-lg shadow w-full">
|
||||
<h2 class="text-xl">Memories from the past</h2>
|
||||
<div class="pt-2">
|
||||
<p><time class="pr-2.5 font-mono" datetime="2024-04-13">13/04/2024</time> <a href="#memory" class="font-bold text-violet-600 hover:underline">§ 2024 Birthday</a></p>
|
||||
<p><time class="pr-2.5 font-mono" datetime="2024-04-01">01/04/2024</time> New song: <span class="font-bold">Hayes Carll ‐ KMAG YOYO</span></p>
|
||||
<p><time class="pr-2.5 font-mono" datetime="2024-03-20">20/03/2024</time> <a href="#memory" class="font-bold text-violet-600 hover:underline">§ A new era</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{#await overview}
|
||||
<span>Loading...</span>
|
||||
{:then overview}
|
||||
<Overview latest={overview[0].value.filter(v => !["feeling"].includes(v.base.kind))} past={overview[1].value}/>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-baseline justify-between">
|
||||
<h2 class="text-2xl mt-6">Memories</h2>
|
||||
<a class="rounded-lg bg-violet-700 text-white px-3 py-2 text-center hover:bg-violet-800 focus:ring-4 focus:ring-violet-300" href="/memory/new">+ Add a memory</a>
|
||||
<a class="rounded-lg bg-violet-700 text-white px-3 py-2 text-center hover:bg-violet-800 focus:ring-4 focus:ring-violet-300" href="/entry/new">+ Add an entry</a>
|
||||
</div>
|
||||
<div class="mt-3.5 flex flex-col gap-1">
|
||||
<Entries entries={MOCK_ENTRIES}/>
|
||||
<!--
|
||||
<Entry kind="date" date={new Date("2024-04-28")}>
|
||||
<div slot="extended">
|
||||
<FeelingPill feeling="happy"/>
|
||||
<EntryDescription>This day has been a great day! I've talked with my friends.</EntryDescription>
|
||||
</div>
|
||||
</Entry>-->
|
||||
<Entries entries={entries}/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
|
@ -4,12 +4,13 @@
|
|||
import FeelingPill from "./utils/FeelingPill.svelte";
|
||||
import Entry from "./utils/Entry.svelte";
|
||||
import EntryDescription from "./utils/EntryDescription.svelte";
|
||||
import AssetPreview from "./utils/AssetPreview.svelte";
|
||||
|
||||
export let entries: EntryType[]
|
||||
</script>
|
||||
|
||||
{#each entries as entry (entry.id)}
|
||||
<Entry kind={entry.base.kind} creationDate={entry.creationDate} title={entry.base.kind === "date" ? entry.base.referencedDate.toLocaleDateString() : entry.title}>
|
||||
<Entry id={entry.id} kind={entry.base.kind} creationDate={new Date(entry.creationDate)} title={entry.base.kind === "date" ? new Date(entry.base.referencedDate).toLocaleDateString() : entry.title}>
|
||||
<div slot="contracted">
|
||||
{#if entry.base.kind === "song" || entry.base.kind === "album"}
|
||||
<ExternalLink href={entry.base.link[0]}>{entry.base.artist} ‐ {entry.base.title}</ExternalLink>
|
||||
|
@ -46,6 +47,12 @@
|
|||
{#if entry.description != null}
|
||||
<EntryDescription>{entry.description}</EntryDescription>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-1 mt-2">
|
||||
{#each entry.assets as asset}
|
||||
<AssetPreview asset_id={asset}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Entry>
|
||||
{/each}
|
26
identity-web/src/routes/dashboard/Overview.svelte
Normal file
26
identity-web/src/routes/dashboard/Overview.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { type Entry } from "$lib/entry";
|
||||
import OverviewEntry from "./utils/OverviewEntry.svelte";
|
||||
|
||||
export let latest: Entry[];
|
||||
export let past: Entry[];
|
||||
</script>
|
||||
|
||||
<div class="p-6 border border-gray-200 rounded-lg shadow w-full">
|
||||
<h2 class="text-xl">Latest activity</h2>
|
||||
<div class="pt-2">
|
||||
{#each latest as entry (entry.id)}
|
||||
<OverviewEntry entry={entry}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if past.length > 0}
|
||||
<div class="p-6 border border-gray-200 rounded-lg shadow w-full">
|
||||
<h2 class="text-xl">Memories from the past</h2>
|
||||
<div class="pt-2">
|
||||
{#each past as entry (entry.id)}
|
||||
<OverviewEntry entry={entry} showDate={true}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
32
identity-web/src/routes/dashboard/utils/AssetPreview.svelte
Normal file
32
identity-web/src/routes/dashboard/utils/AssetPreview.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { asset_endpoint, session_key } from "$lib/stores";
|
||||
import { faArrowUpRightFromSquare, faFileAudio, faFileVideo, faImage } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/svelte-fontawesome";
|
||||
import mime from "mime"
|
||||
|
||||
export let asset_id: string
|
||||
|
||||
// FIXME: This feels correct, but how is it guaranteed that session_key and asset_endpoint are not null?
|
||||
$: href = new URL(`/asset?asset_id=${encodeURIComponent(asset_id)}&session_key=${encodeURIComponent($session_key!)}`, $asset_endpoint!).href
|
||||
$: kind = mime.getType(asset_id.split(".")[1])?.split("/")[0]
|
||||
</script>
|
||||
|
||||
<a class="font-bold bg-violet-600 text-white px-2.5 py-1 rounded flex gap-2 items-center" target="_blank" href={href}>
|
||||
{#if kind == null}
|
||||
<FontAwesomeIcon icon={faArrowUpRightFromSquare}/>
|
||||
{:else if kind === "image"}
|
||||
<FontAwesomeIcon icon={faImage}/>
|
||||
{:else if kind === "audio"}
|
||||
<FontAwesomeIcon icon={faFileAudio}/>
|
||||
{:else if kind === "video"}
|
||||
<FontAwesomeIcon icon={faFileVideo}/>
|
||||
{:else}
|
||||
<FontAwesomeIcon icon={faArrowUpRightFromSquare}/>
|
||||
{/if}
|
||||
|
||||
{#if kind != null && kind !== "application"}
|
||||
<span class="capitalize">{kind}</span>
|
||||
{:else}
|
||||
<span>Asset</span>
|
||||
{/if}
|
||||
</a>
|
|
@ -2,6 +2,7 @@
|
|||
import { TITLED_ENTRIES } from "$lib/entry";
|
||||
import EntryKind from "./EntryKind.svelte";
|
||||
|
||||
export let id: string;
|
||||
export let creationDate: Date;
|
||||
export let kind: "song" | "album" | "event" | "feeling" | "environment" | "date" | "memory";
|
||||
export let title: string | undefined;
|
||||
|
@ -21,12 +22,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
console.log(cardClass)
|
||||
return cardClass
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class={cardClass()}>
|
||||
<div class={cardClass()} id={`entry__${id}`}>
|
||||
<button on:click={() => isExtended = !isExtended}>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<EntryKind kind={kind}/>
|
||||
|
|
|
@ -32,6 +32,8 @@
|
|||
export let textColor: string = (DEFAULT_COLORS[feeling] || DEFAULT_COLORS["__DEFAULT__"])[1]
|
||||
</script>
|
||||
|
||||
<div class="inline-block py-0.5 px-1.5 rounded-full text-sm font-semibold my-0.5 w-20 text-center" style={`background-color: ${bgColor}`}>
|
||||
<span style={`color: ${textColor}`}>{feeling.charAt(0).toUpperCase() + feeling.slice(1)}</span>
|
||||
<div class="inline-block py-0.5 px-1.5 rounded-full text-sm font-semibold my-0.5 w-22 text-center" style={`background-color: ${bgColor}; color: ${textColor}`}>
|
||||
<slot name="pre"/>
|
||||
|
||||
<span>{feeling.charAt(0).toUpperCase() + feeling.slice(1)}</span>
|
||||
</div>
|
22
identity-web/src/routes/dashboard/utils/OverviewEntry.svelte
Normal file
22
identity-web/src/routes/dashboard/utils/OverviewEntry.svelte
Normal file
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { TITLED_ENTRIES, type Entry } from "$lib/entry"
|
||||
|
||||
export let entry: Entry
|
||||
export let showDate = false
|
||||
</script>
|
||||
|
||||
<p>
|
||||
{#if showDate}
|
||||
<time class="pr-2.5 font-mono" datetime={entry.creationDate}>{new Date(entry.creationDate).toLocaleDateString()}</time>
|
||||
{/if}
|
||||
|
||||
{#if TITLED_ENTRIES.includes(entry.base.kind)}
|
||||
New {entry.base.kind}: <a href={`#entry__${entry.id}`} class="font-bold text-violet-600 hover:underline">§ {entry.title}</a>
|
||||
{:else if ["song", "album"].includes(entry.base.kind)}
|
||||
New {entry.base.kind}: <a href={`#entry__${entry.id}`} class="font-bold">{entry.base.artist} ‐ {entry.base.title}</a>
|
||||
{:else if entry.base.kind === "date"}
|
||||
New {entry.base.kind}: <a href={`#entry__${entry.id}`} class="font-bold">{new Date(entry.base.referencedDate).toLocaleDateString()}</a>
|
||||
{:else}
|
||||
<a href={`#entry__${entry.id}`} class="font-bold text-violet-600 hover:underline">New {entry.base.kind}</a>
|
||||
{/if}
|
||||
</p>
|
250
identity-web/src/routes/entry/new/+page.svelte
Normal file
250
identity-web/src/routes/entry/new/+page.svelte
Normal file
|
@ -0,0 +1,250 @@
|
|||
<script lang="ts">
|
||||
import { createForm } from "felte";
|
||||
import EntryKind from "../../dashboard/utils/EntryKind.svelte";
|
||||
import { FEELINGS, TITLED_ENTRIES, type AlbumEntry, type IdlessEntry, type KnownFeeling, type SongEntry } from "$lib/entry";
|
||||
import { FontAwesomeIcon } from "@fortawesome/svelte-fontawesome";
|
||||
import { faSpotify, faYoutube } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faChevronDown, faLink, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import FeelingPill from "../../dashboard/utils/FeelingPill.svelte";
|
||||
import { addEntry } from "$lib/api";
|
||||
import { credentials } from "$lib/stores";
|
||||
|
||||
credentials.subscribe((v) => v == null && (setTimeout(() => window.location.pathname = '/auth/login', 200)))
|
||||
|
||||
let feelingsDropdownShown = false
|
||||
let chosenFeelings: KnownFeeling[] = []
|
||||
|
||||
$: feelingsToChoose = FEELINGS.filter(v => !chosenFeelings.includes(v))
|
||||
|
||||
let kind: EntryKind | null
|
||||
const { form, errors } = createForm({
|
||||
onSubmit: async (values) => {
|
||||
let feelings = Object.keys(values)
|
||||
.filter(v => v.startsWith("feeling__"))
|
||||
.map(v => v.replaceAll("feeling__", "")) as KnownFeeling[];
|
||||
|
||||
let base;
|
||||
if (values.kind === "song" || values.kind === "album") {
|
||||
base = {
|
||||
kind: values.kind,
|
||||
artist: values.artist,
|
||||
title: values.musicTitle,
|
||||
link: [values.spotify, values.yt, values.otherProvider].filter(v => v != null && v.length > 0),
|
||||
// FIXME: infer univeersal ids
|
||||
id: [],
|
||||
}
|
||||
} else if (values.kind === "environment") {
|
||||
base = {
|
||||
kind: values.kind,
|
||||
location: (values.location != null && values.location.length > 0) ? values.location : undefined,
|
||||
}
|
||||
} else if (values.kind === "date") {
|
||||
base = {
|
||||
kind: values.kind,
|
||||
referencedDate: values.date,
|
||||
}
|
||||
} else {
|
||||
base = {
|
||||
kind: values.kind,
|
||||
}
|
||||
}
|
||||
|
||||
let entry: IdlessEntry = {
|
||||
base,
|
||||
creationDate: new Date().toISOString(),
|
||||
assets: [],
|
||||
feelings,
|
||||
title: TITLED_ENTRIES.includes(values.kind) ? values.title : undefined,
|
||||
description: values.description,
|
||||
}
|
||||
|
||||
await addEntry($credentials!, entry)
|
||||
window.location.pathname = '/dashboard'
|
||||
},
|
||||
validate: (values) => {
|
||||
let errors = {}
|
||||
|
||||
if (values.kind == null || values.kind.length === 0) {
|
||||
errors['kind'] = 'Must choose an entry kind'
|
||||
return errors
|
||||
}
|
||||
|
||||
if (values.kind === "song" || values.kind === "album") {
|
||||
if (values.artist == null || values.artist.length === 0) {
|
||||
errors['artist'] = "Must not be empty";
|
||||
}
|
||||
|
||||
if (values.musicTitle == null || values.musicTitle.length === 0) {
|
||||
errors["musicTitle"] = "Must not be empty";
|
||||
}
|
||||
|
||||
// FIXME: When asset support is added, another precondition is that no asset is uploaded
|
||||
if (values.spotify.length === 0 && values.yt.length === 0 && values.otherProvider.length === 0) {
|
||||
errors["links"] = "You must add at least one link or upload an audio asset";
|
||||
}
|
||||
} else if (values.kind === "date") {
|
||||
if (values.date == null || values.date.length === 0) {
|
||||
errors['date'] = "Must choose a date";
|
||||
}
|
||||
} else if (values.kind === "feeling") {
|
||||
if (Object.keys(values).filter(v => v.startsWith("feeling__")).length === 0) {
|
||||
errors['feelings'] = "Must choose at least one feeling";
|
||||
}
|
||||
} else {
|
||||
if (values.title == null || values.title.length === 0) {
|
||||
errors["title"] = "Must not be empty";
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mt-3.5 justify-center flex">
|
||||
<div class="w-[60%] flex flex-col">
|
||||
<h1 class="text-2xl pb-3.5">Add an entry</h1>
|
||||
<form use:form>
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__kind" class="block mb-2 text-sm font-medium text-gray-900">Entry kind</label>
|
||||
<select bind:value={kind} id="add-entry__kind" name="kind" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
<option value="" selected>Choose an entry kind</option>
|
||||
<option value="song">Song</option>
|
||||
<option value="album">Album</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="memory">Memory</option>
|
||||
<option value="feeling">Feeling</option>
|
||||
<option value="environment">Environment</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
{#if $errors.kind != null}
|
||||
<p class="mt-2 text-sm text-red-600"><span class="font-medium">{$errors.kind[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if TITLED_ENTRIES.includes(kind)}
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__title" class="block mb-2 text-sm font-medium text-gray-900">Title</label>
|
||||
<input id="add-entry__title" type="text" name="title" placeholder="At the sunflower field with my friends" class="bg-gray-50 border border-gray-300 text-greay-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
{#if $errors.title != null}
|
||||
<p class="mt-2 text-sm text-red-600"><span class="font-medium">{$errors.title[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ["song", "album"].includes(kind)}
|
||||
<div class="flex flex-col mb-5 gap-5 md:flex-row">
|
||||
<div class="w-full">
|
||||
<label for="add-entry__artist" class="block mb-2 text-sm font-medium text-gray-900">Artist name</label>
|
||||
<input id="add-entry__artist" type="text" name="artist" placeholder="Claude Debussy" class="bg-gray-50 border border-gray-300 text-greay-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
{#if $errors.artist != null}
|
||||
<p class="mt-2 text-sm text-red-600"><span class="font-medium">{$errors.artist[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="add-entry__music-title" class="block mb-2 text-sm font-medium text-gray-900"><span class="capitalize">{kind}</span> title</label>
|
||||
<input id="add-entry__music-title" type="text" name="musicTitle" placeholder="Clair de Lune" class="bg-gray-50 border border-gray-300 text-greay-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
{#if $errors.musicTitle != null}
|
||||
<p class="mt-2 text-sm text-red-600"><span class="font-medium">{$errors.musicTitle[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__spotify" class="block mb-2 text-sm font-medium text-gray-900">Spotify link</label>
|
||||
<div class="flex">
|
||||
<span class="inline-flex items-center px-2.5 text-sm text-gray-900 bg-gray-200 border rounded-e-0 border-gray-300 border-e-0 rounded-s-md">
|
||||
<FontAwesomeIcon size="lg" icon={faSpotify}/>
|
||||
</span>
|
||||
<input type="text" id="add-entry__spotify" name="spotify" class="rounded-none rounded-e-lg bg-gray-50 border text-gray-900 focus:ring-violet-500 focus:border-violet-500 block flex-1 min-w-0 w-full text-sm border-gray-300 p-2.5" placeholder={kind === "song" ? "https://open.spotify.com/track/..." : "https://open.spotify.com/album/..."}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__yt" class="block mb-2 text-sm font-medium text-gray-900">YouTube link</label>
|
||||
<div class="flex">
|
||||
<span class="inline-flex items-center px-2.5 text-sm text-gray-900 bg-gray-200 border rounded-e-0 border-gray-300 border-e-0 rounded-s-md">
|
||||
<FontAwesomeIcon size="lg" icon={faYoutube}/>
|
||||
</span>
|
||||
<input type="text" id="add-entry__yt" name="yt" class="rounded-none rounded-e-lg bg-gray-50 border text-gray-900 focus:ring-violet-500 focus:border-violet-500 block flex-1 min-w-0 w-full text-sm border-gray-300 p-2.5" placeholder="https://www.youtube.com/watch...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__other" class="block mb-2 text-sm font-medium text-gray-900">Link to other provider</label>
|
||||
<div class="flex">
|
||||
<span class="inline-flex items-center px-2.5 text-sm text-gray-900 bg-gray-200 border rounded-e-0 border-gray-300 border-e-0 rounded-s-md">
|
||||
<FontAwesomeIcon size="lg" icon={faLink}/>
|
||||
</span>
|
||||
<input type="text" name="otherProvider" id="add-entry__other" class="rounded-none rounded-e-lg bg-gray-50 border text-gray-900 focus:ring-violet-500 focus:border-violet-500 block flex-1 min-w-0 w-full text-sm border-gray-300 p-2.5" placeholder="https://www.music.tld/play/...">
|
||||
</div>
|
||||
</div>
|
||||
{#if $errors.links != null}
|
||||
<p class="mt-2.5 mb-3.5 text-sm text-red-600"><span class="font-medium">{$errors.links[0]}</span></p>
|
||||
{/if}
|
||||
{:else if kind === "environment"}
|
||||
<div class="w-full mb-5">
|
||||
<label for="add-entry__location" class="block mb-2 text-sm font-medium text-gray-900">Location</label>
|
||||
<input id="add-entry__location" type="text" name="location" placeholder="South of Almond Park" class="bg-gray-50 border border-gray-300 text-greay-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
</div>
|
||||
{:else if kind === "date"}
|
||||
<div class="w-full mb-5">
|
||||
<label for="add-entry__date" class="block mb-2 text-sm font-medium text-gray-900">Referenced date</label>
|
||||
<input id="add-entry__date" type="date" name="date" class="bg-gray-50 border border-gray-300 text-greay-900 text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full p-2.5">
|
||||
{#if $errors.date != null}
|
||||
<p class="mt-2 text-sm text-red-600"><span class="font-medium">{$errors.date[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if kind != null && kind.length > 0}
|
||||
<div class="mb-5">
|
||||
<label for="add-entry__description" class="block mb-2 text-sm font-medium text-gray-900">Description</label>
|
||||
<textarea name="description" id="add-entry__description" rows="7" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-violet-500 focus:border-violet-500" placeholder="Write your thoughts here..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 flex flex-col gap-3.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<button type="button" on:click={() => feelingsDropdownShown = !feelingsDropdownShown} class="text-white bg-violet-700 hover:bg-violet-800 focus:ring-4 focust:outline-none focus:ring-violet-300 font-medium rounded-lg px-3 py-1.5 text-center">
|
||||
Feelings
|
||||
<FontAwesomeIcon icon={faChevronDown}/>
|
||||
</button>
|
||||
|
||||
{#if chosenFeelings.length > 0}
|
||||
<div>
|
||||
<span class="mr-1">Chosen:</span>
|
||||
{#each chosenFeelings as feeling (feeling)}
|
||||
<div class="inline">
|
||||
<button type="button" on:click={() => chosenFeelings = chosenFeelings.filter(v => v !== feeling)}>
|
||||
<FeelingPill feeling={feeling}>
|
||||
<span class="pr-1" slot="pre"><FontAwesomeIcon icon={faXmark}/></span>
|
||||
</FeelingPill>
|
||||
</button>
|
||||
<input type="checkbox" class="hidden" name={`feeling__${feeling}`} checked>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span>No feelings chosen.</span>
|
||||
{#if kind === "feeling"}
|
||||
<span>You need to choose at least one feeling.</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class:hidden={!feelingsDropdownShown}>
|
||||
{#each feelingsToChoose as feeling (feeling)}
|
||||
<label class="capitalize p-1">
|
||||
<button type="button" on:click={() => chosenFeelings = [feeling, ...chosenFeelings]}>
|
||||
<FeelingPill feeling={feeling}>
|
||||
<span class="pr-1" slot="pre"><FontAwesomeIcon icon={faPlus}/></span>
|
||||
</FeelingPill>
|
||||
</button>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $errors.feelings != null}
|
||||
<p class="text-sm text-red-600"><span class="font-medium">{$errors.feelings[0]}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="mt-2 text-white bg-violet-700 hover:bg-violet-800 focus:ring-4 focust:outline-none focus:ring-violet-300 font-medium rounded-lg px-5 py-2.5 text-center" type="submit">Add new entry</button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -275,6 +275,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fortawesome/free-brands-svg-icons@npm:^6.5.2":
|
||||
version: 6.5.2
|
||||
resolution: "@fortawesome/free-brands-svg-icons@npm:6.5.2"
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-common-types": "npm:6.5.2"
|
||||
checksum: 10c0/4c1798930547a73bf9dfd04064288d4ce02fbf42fece056c1f5dd6596363d6a34bb86bc2e0f400b78f938f48773aceb375bc6977e55cbaec9b7bd07996ae1e8c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fortawesome/free-solid-svg-icons@npm:^6.5.2":
|
||||
version: 6.5.2
|
||||
resolution: "@fortawesome/free-solid-svg-icons@npm:6.5.2"
|
||||
|
@ -1899,6 +1908,7 @@ __metadata:
|
|||
resolution: "identity-web@workspace:."
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-svg-core": "npm:^6.5.2"
|
||||
"@fortawesome/free-brands-svg-icons": "npm:^6.5.2"
|
||||
"@fortawesome/free-solid-svg-icons": "npm:^6.5.2"
|
||||
"@fortawesome/svelte-fontawesome": "npm:^0.2.2"
|
||||
"@sveltejs/adapter-auto": "npm:^3.0.0"
|
||||
|
@ -1911,6 +1921,7 @@ __metadata:
|
|||
eslint-plugin-svelte: "npm:^2.36.0"
|
||||
felte: "npm:^1.2.14"
|
||||
globals: "npm:^15.0.0"
|
||||
mime: "npm:^4.0.3"
|
||||
postcss: "npm:^8.4.38"
|
||||
prettier: "npm:^3.1.1"
|
||||
prettier-plugin-svelte: "npm:^3.1.2"
|
||||
|
@ -2272,6 +2283,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime@npm:^4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "mime@npm:4.0.3"
|
||||
bin:
|
||||
mime: bin/cli.js
|
||||
checksum: 10c0/4be1d06813a581eb9634748919eadab9785857dcfe2af4acca8e4bc340b4b74ff7452c7d3cd76169d0f6b77d7f1ab3434bde8a72ca4291fd150b4205c756c36b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"min-indent@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "min-indent@npm:1.0.1"
|
||||
|
|
Loading…
Reference in a new issue