diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 000000000..465ac8e27 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,34 @@ +name: checks + +on: + pull_request: + branches: + - master + - dev + push: + branches: + - master + +jobs: + linting: + name: linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + + # run eslint and prettier on the frontend and api + - run: bun install && bun lint && bun format:check + working-directory: ./frontend + - run: bun install && bun format:check + working-directory: ./api + testing: + name: testing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + + # run tests on the api + - run: bun install && bun test + working-directory: ./api diff --git a/README.md b/README.md index 7a37f61f4..95a581dee 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ Currently optimised mobile devices are: #### Running the api - go into the directory `cd api` +- copy the `.env.example` file to `.env` and fill in the values - run `bun install` to install the dependencies +- run `bun drizzle:push` to push the database schema to the database - run `bun dev` to start the api #### Running the frontend diff --git a/api/eslint.config.js b/api/eslint.config.js index e64ccc5a4..5ea76bc09 100644 --- a/api/eslint.config.js +++ b/api/eslint.config.js @@ -1,12 +1,32 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; - +import globals from 'globals' +import pluginJs from '@eslint/js' +import tseslint from 'typescript-eslint' /** @type {import('eslint').Linter.Config[]} */ export default [ - {files: ["**/*.{js,mjs,cjs,ts}"]}, - {languageOptions: { globals: globals.browser }}, + { files: ['**/*.{js,mjs,cjs,ts}'] }, + { languageOptions: { globals: globals.browser } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, -]; \ No newline at end of file + { + name: 'app/files-to-ignore', + ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'pm2.config.js'] + }, + { + rules: { + 'no-unused-vars': ['off'], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ] + } + } +] diff --git a/api/package.json b/api/package.json index 70de621a9..124430a80 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,11 @@ "test": "bun test", "drizzle:push": "drizzle-kit push", "drizzle:migrate": "drizzle-kit migrate", - "drizzle:generate": "drizzle-kit generate" + "drizzle:generate": "drizzle-kit generate", + "lint": "bunx eslint .", + "lint:fix": "bunx eslint --fix .", + "format": "bunx prettier . --write", + "format:check": "prettier --check ." }, "type": "module", "devDependencies": { diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 81bfd596c..fb62bffe2 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,40 +1,61 @@ -import { relations, eq, sql } from 'drizzle-orm' +import { relations, sql } from 'drizzle-orm' import { boolean, text, serial, pgTable as table, timestamp, integer, pgView } from 'drizzle-orm/pg-core' export const users = table('users', { id: serial().primaryKey(), name: text().notNull(), + displayName: text('display_name').notNull().unique(), webId: text('web_id').notNull().unique(), email: text('email').notNull().unique(), - providerEndpoint: text('provider_endpoint').notNull(), + providerName: text('provider_name').notNull() // This is done so there is no import here. It crashes the drizzle:push command }) -export const usersRelations = relations(users, ({one}) => ({ - posts: one(posts), +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + followers: many(users) })) +export const followers = table('followers', { + followerId: integer('follower_id') + .notNull() + .references(() => users.id), + followedId: integer('followed_id') + .notNull() + .references(() => users.id) +}) + export const posts = table('posts', { id: serial().primaryKey(), content: text().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), isPublic: boolean('is_public').notNull(), - authorId: integer('author_id').notNull().references(() => users.id), + authorId: integer('author_id').notNull() }) +export const postsRelations = relations(posts, ({ one }) => ({ + author: one(users, { + fields: [posts.authorId], + references: [users.id] + }) +})) + +// Views export const postsView = pgView('posts_view', { id: serial().primaryKey(), content: text().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), isPublic: boolean('is_public').notNull(), - authorId: integer('author_id').notNull().references(() => users.id), - authorName: text("author_name").notNull(), + authorId: integer('author_id') + .notNull() + .references(() => users.id), + authorName: text('author_name').notNull(), authorWebId: text('author_web_id').notNull().unique(), - authorProviderEndpoint: text('author_provider_endpoint').notNull(), + authorProviderEndpoint: text('author_provider_endpoint').notNull() }).as(sql`SELECT posts.*, users.name as author_name, users.web_id as author_web_id, - users.provider_endpoint as author_provider_endpoint + users.provider_name as author_provider_name FROM posts INNER JOIN users on posts.author_id = users.id WHERE posts.is_public = true`) diff --git a/api/src/decorater/User.ts b/api/src/decorater/User.ts index 2ab35e719..783db06df 100644 --- a/api/src/decorater/User.ts +++ b/api/src/decorater/User.ts @@ -1,52 +1,52 @@ -import type { SelectUsers } from "../types" +import { encodeWebId } from '@/util/user' +import { podProviderEndpoint, ViablePodProvider, type SelectUsers } from '../types' export default class User { - userId: number - userName: string - token: string - endpoint: string + userId: number = 0 + username: string = '' + token: string = '' + provider: string = '' + endpoint: string = '' + webId: string = '' + providerWebId: string = '' constructor(dbUser?: SelectUsers, token?: string) { - this.userId = 0 - this.userName = '' - this.token = '' - this.endpoint = '' - if (dbUser) { this.userId = dbUser.id - this.userName = dbUser.name - this.endpoint = dbUser.providerEndpoint + this.username = dbUser.name + this.provider = dbUser.providerName + this.computeValues() } if (token) { this.token = token } } - loadUser(oldUser: User) { - this.userId = oldUser.userId - this.userName = oldUser.userName - this.token = oldUser.token - this.endpoint = oldUser.endpoint + loadUser(oldUser: string) { + const newUser = JSON.parse(oldUser) + this.userId = newUser.userId + this.username = newUser.username + this.token = newUser.token + this.provider = newUser.provider + this.computeValues() } - getWebId() { - return `${this.endpoint}/${this.userName}` + private computeValues() { + this.endpoint = podProviderEndpoint[this.provider as ViablePodProvider] + this.webId = encodeWebId(this) + this.providerWebId = this.endpoint + '/' + this.username } - setUser(userId: number, userName: string, token: string, endpoint: string) { - this.userId = userId - this.userName = userName - this.token = token - this.endpoint = endpoint - } - - setUsername(userName: string) { - const userIdSplit = userName.split('/') - this.userName = userName - this.endpoint = userIdSplit[userIdSplit.length - 2] + getWebId() { + return encodeWebId(this) } - setToken(token: string) { - this.token = token + toString() { + return JSON.stringify({ + userId: this.userId, + username: this.username, + token: this.token, + provider: this.provider + }) } } diff --git a/api/src/index.ts b/api/src/index.ts index 2890cad7e..384872c94 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,30 +1,14 @@ import { Elysia } from 'elysia' import { drizzle } from 'drizzle-orm/node-postgres' -import { _createPost, _selectUsers } from './types' -import { postsPlugin, authPlugin, setupPlugin } from './routes' +import { postsPlugin, authPlugin, setupPlugin, usersPlugin } from './routes' export const db = drizzle({ connection: process.env.DB_URL || '', casing: 'snake_case' }) export const app = new Elysia() .use(setupPlugin) - .macro({ - isSignedIn: enabled => { - if (!enabled) return - - return { - async beforeHandle({ headers: { auth }, jwt, error, user }) { - const authValue = await jwt.verify(auth) - if (!authValue) { - return error(401, 'You must be signed in to do that') - } else { - user.loadUser(JSON.parse(authValue.user as string)) - } - } - } - } - }) .use(authPlugin) .use(postsPlugin) + .use(usersPlugin) .listen(process.env.API_PORT || 8796) console.info(`Listening on port ${process.env.API_PORT}`) diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 3732d7c30..076672a9a 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -1,14 +1,23 @@ -import { users } from "../db/schema" -import ActivityPod from "../services/ActivityPod" -import { type PodProviderSignInResponse, type SelectUsers, viablePodProviders, signinResponse, signUpBody, signinBody } from "../types" -import { eq } from "drizzle-orm" -import Elysia, { t } from "elysia" -import { db } from ".." -import setupPlugin from "./setup" -import { getTokenObject } from "../services/jwt" -import User from "../decorater/User" +import { users } from '../db/schema' +import ActivityPod from '../services/ActivityPod' +import { + type PodProviderSignInResponse, + type SelectUsers, + signinResponse, + signUpBody, + signinBody, + podProviderEndpoint +} from '../types' +import { eq } from 'drizzle-orm' +import Elysia, { t } from 'elysia' +import { db } from '..' +import setupPlugin from './setup' +import { getTokenObject } from '../services/jwt' +import User from '../decorater/User' +import { HTTPError } from 'ky' +import { encodeWebId } from '@/util/user' -const authPlugin = new Elysia({name: 'auth'}) +const authPlugin = new Elysia({ name: 'auth' }) .use(setupPlugin) .post( '/signin', @@ -17,13 +26,13 @@ const authPlugin = new Elysia({name: 'auth'}) if (auth && (await jwt.verify(auth))) { return error(204, "You're already logged in") } - const { username, password, providerEndpoint } = body + const { username, password, providerName } = body let providerResponse: PodProviderSignInResponse // try to signIn to the endpoint try { - providerResponse = await ActivityPod.signIn(providerEndpoint, username, password) + providerResponse = await ActivityPod.signIn(podProviderEndpoint[providerName], username, password) } catch (e) { console.error('Error while logging in to endpoint: ', e) return error(400, "Endpoint didn't respond with a 200 status code") @@ -36,16 +45,18 @@ const authPlugin = new Elysia({name: 'auth'}) let dbUser: SelectUsers[] = [] // the endpoint returned like expected now check if the user is already in the database try { - dbUser = await db.select().from(users).where(eq(users.webId, providerResponse.webId)) + const webId = encodeWebId(providerResponse.webId) + dbUser = await db.select().from(users).where(eq(users.webId, webId)) if (dbUser.length === 0) { // the user is not in the database yet, so we need to create a new user dbUser = await db .insert(users) .values({ name: username as string, + displayName: username as string, email: username as string, - webId: providerResponse.webId, - providerEndpoint: providerEndpoint + webId: webId, + providerName }) .returning() } @@ -74,16 +85,6 @@ const authPlugin = new Elysia({name: 'auth'}) detail: 'Logs in a with a pod provider and sets an auth cookie for the user' } ) - .get( - '/logout', - async ({ cookie: { auth } }) => { - auth.remove() - return 'You have been logged out' - }, - { - detail: 'Removes the auth cookie' - } - ) .post( '/signup', async ({ body, error, headers: { auth }, jwt }) => { @@ -91,11 +92,11 @@ const authPlugin = new Elysia({name: 'auth'}) if (auth && (await jwt.verify(auth))) { return "You're already logged in" } - const { username, password, email, providerEndpoint } = body + const { username, password, email, providerName } = body // try to sign up the user with the current provider try { - const providerResponse = await ActivityPod.signup(providerEndpoint, username, password, email) + const providerResponse = await ActivityPod.signup(podProviderEndpoint[providerName], username, password, email) let userResponse: SelectUsers[] = [] if (providerResponse.token === undefined) { @@ -103,29 +104,35 @@ const authPlugin = new Elysia({name: 'auth'}) } else { // the provider created a new user, so we need to create a new user in the database try { - const user = await db.select().from(users).where(eq(users.webId, providerResponse.webId)) + const webId = encodeWebId(providerResponse.webId) + const user = await db.select().from(users).where(eq(users.webId, webId)) if (user.length === 0) { // the user is not in the database yet, so we need to create a new user - userResponse = await db.insert(users).values({ - name: username as string, - email, - webId: providerResponse.webId, - providerEndpoint: providerEndpoint - }).returning() + userResponse = await db + .insert(users) + .values({ + name: username as string, + displayName: username as string, + email, + webId: webId, + providerName + }) + .returning() } } catch (e) { console.error('Error while checking if user is in the database: ', e) return error(500, 'Error while checking user') } - const authToken = await jwt.sign({ webId: providerResponse.webId, token: providerResponse.token }) + const tokenObject = getTokenObject(new User(userResponse[0], providerResponse.token)) + const authToken = await jwt.sign(tokenObject) return { token: authToken, user: userResponse[0] } } - } catch (e: any) { - if (e.name === 'HTTPError') { + } catch (e: unknown) { + if (e instanceof HTTPError) { const errorJson = await e.response.json() console.error('Error while signing up the user', errorJson) return error(errorJson.code, errorJson.message) @@ -140,7 +147,7 @@ const authPlugin = new Elysia({name: 'auth'}) response: { 200: signinResponse, 400: t.String(), - 500: t.String(), + 500: t.String() } } ) diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 519d9ff84..e5f19c227 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -1,3 +1,4 @@ export { default as authPlugin } from './auth' export { default as postsPlugin } from './posts' export { default as setupPlugin } from './setup' +export { default as usersPlugin } from './users' diff --git a/api/src/routes/posts.ts b/api/src/routes/posts.ts index 6681a8bbb..5b471b320 100644 --- a/api/src/routes/posts.ts +++ b/api/src/routes/posts.ts @@ -1,27 +1,27 @@ import Elysia, { t } from 'elysia' import { posts, postsView } from '../db/schema' import ActivityPod from '../services/ActivityPod' -import { _createPost, selectQueryObject, type SelectPost } from '../types' +import { _createPost, PodRequestTypes, selectQueryObject, type NoteCreateRequest, type SelectPost } from '../types' import { db } from '..' import setupPlugin from './setup' -const postsRoutes = new Elysia({ name: 'posts' }) +const postsRoutes = new Elysia({ name: 'posts', prefix: '/posts' }) .use(setupPlugin) .guard({ isSignedIn: true }) .post( - '/posts', + '/', async ({ error, body, user }) => { const { content, isPublic } = body - const addressats = [`${user.endpoint}/${user.userName}/followers`] + const addressats = [`${user.endpoint}/${user.username}/followers`] if (isPublic) addressats.push('https://www.w3.org/ns/activitystreams#Public') - const post = { + const post: NoteCreateRequest = { '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Note', - attributedTo: `${user.endpoint}/${user.userName}`, + type: PodRequestTypes.Note, + attributedTo: `${user.endpoint}/${user.username}`, content: content, to: addressats } @@ -47,7 +47,7 @@ const postsRoutes = new Elysia({ name: 'posts' }) createdAt: newPosts[0].createdAt?.toString() || '', author: { id: user.userId, - name: user.userName, + name: user.username, webId: user.getWebId() } } @@ -65,7 +65,7 @@ const postsRoutes = new Elysia({ name: 'posts' }) } ) .get( - '/posts', + '/', async ({ query: { limit, offset } }) => { const postsQuery = await db .select({ diff --git a/api/src/routes/setup.ts b/api/src/routes/setup.ts index 9fc3225ee..e6377bc32 100644 --- a/api/src/routes/setup.ts +++ b/api/src/routes/setup.ts @@ -12,5 +12,21 @@ const setupPlugin = new Elysia({ name: 'setup' }) ) .use(cors()) .decorate('user', new User()) + .macro({ + isSignedIn: enabled => { + if (!enabled) return + + return { + async beforeHandle({ headers: { auth }, jwt, error, user }) { + const authValue = await jwt.verify(auth) + if (!authValue) { + return error(401, 'You must be signed in to do that') + } else { + user.loadUser(authValue.user as string) + } + } + } + } + }) export default setupPlugin diff --git a/api/src/routes/users.ts b/api/src/routes/users.ts new file mode 100644 index 000000000..8b2184321 --- /dev/null +++ b/api/src/routes/users.ts @@ -0,0 +1,193 @@ +import { Elysia, t } from 'elysia' +import setupPlugin from './setup' +import { decodeWebId, encodeWebId } from '@/util/user' +import { + FollowErrors, + followersFollowedResponse, + PodRequestTypes, + selectQueryObject, + type FollowersFollowedResponse +} from '@/types' +import { db } from '..' +import { eq, and } from 'drizzle-orm' +import { users, followers } from '@/db/schema' +import ActivityPod from '@/services/ActivityPod' + +export default new Elysia({ name: 'user', prefix: '/user' }) + .use(setupPlugin) + .guard({ + isSignedIn: true + }) + .get( + '/following', + async ({ user, query: { limit, offset } }) => { + const followingResponse = await db + .select({ + id: users.id, + name: users.name, + webId: users.webId, + displayName: users.displayName, + providerName: users.providerName + }) + .from(followers) + .leftJoin(users, eq(followers.followedId, users.id)) + .where(eq(followers.followerId, user.userId)) + .limit(limit) + .offset(offset) + return followingResponse as FollowersFollowedResponse[] + }, + { + detail: 'Returns the users the user is following', + response: { + 200: t.Array(followersFollowedResponse) + }, + query: selectQueryObject + } + ) + .get( + '/followers', + async ({ user, query: { limit, offset } }) => { + const followersResponse = await db + .select({ + id: users.id, + name: users.name, + webId: users.webId, + displayName: users.displayName, + providerName: users.providerName + }) + .from(followers) + .leftJoin(users, eq(followers.followerId, users.id)) + .where(eq(followers.followedId, user.userId)) + .limit(limit) + .offset(offset) + return followersResponse as FollowersFollowedResponse[] + }, + { + detail: 'Returns the followers of the user', + query: selectQueryObject, + response: { + 200: t.Array(followersFollowedResponse) + } + } + ) + .post( + '/:followerWebId/follow', + async ({ user, params: { followerWebId }, error }) => { + try { + // check if user to follow is on memory + const webId = encodeWebId(user) + if (webId === followerWebId) return error(400, FollowErrors.IsSelf) + + // check if user to follow is on memory + const toFollow = await db.select().from(users).where(eq(users.webId, followerWebId)).limit(1) + if (toFollow.length === 0) return error(400, FollowErrors.NotOnMemory) + const userToFollow = toFollow[0] + + // check if user is already following the user + const following = await db + .select() + .from(followers) + .where(and(eq(followers.followerId, user.userId), eq(followers.followedId, userToFollow.id))) + .limit(1) + if (following.length > 0) return error(400, FollowErrors.AlreadyFollowing) + + // Follow the user + const decodedFollowedWebId = decodeWebId(followerWebId) + const followedPodProviderWebId = decodedFollowedWebId.endpoint + '/' + decodedFollowedWebId.username + await ActivityPod.follow(user, { + '@context': 'https://www.w3.org/ns/activitystreams', + type: PodRequestTypes.Follow, + actor: user.providerWebId, + object: followedPodProviderWebId, + to: followedPodProviderWebId + }) + // add the relationship to the database + await db.insert(followers).values({ + followerId: user.userId, + followedId: userToFollow.id + }) + + const returnObject: FollowersFollowedResponse = { + id: userToFollow.id, + name: userToFollow.name, + webId: userToFollow.webId, + displayName: userToFollow.displayName, + providerName: userToFollow.providerName + } + return returnObject + } catch (_) { + console.log(_) + return error(400, FollowErrors.NotValidProvider) + } + }, + { + detail: 'Follows a user', + params: t.Object({ + followerWebId: t.String() + }), + response: { + 200: followersFollowedResponse, + 400: t.Enum(FollowErrors) + } + } + ) + .delete( + '/:followerWebId/unfollow', + async ({ params: { followerWebId }, user, error }) => { + try { + // get the id of the user to unfollow + const toUnfollow = await db.select().from(users).where(eq(users.webId, followerWebId)).limit(1) + if (toUnfollow.length === 0) return error(400, FollowErrors.NotOnMemory) + const userToUnfollow = toUnfollow[0] + const webIdToUnfollow = decodeWebId(followerWebId) + // check if user is following the user + const isFollowing = await db + .select() + .from(followers) + .where(and(eq(followers.followerId, user.userId), eq(followers.followedId, userToUnfollow.id))) + .limit(1) + if (isFollowing.length === 0) { + return error(400, FollowErrors.NotFollowing) + } + + // send unfollow request to the pod + await ActivityPod.unfollow(user, { + '@context': 'https://www.w3.org/ns/activitystreams', + type: PodRequestTypes.Undo, + actor: user.providerWebId, + object: { + actor: user.providerWebId, + type: PodRequestTypes.Follow, + object: webIdToUnfollow.endpointWebId + }, + to: userToUnfollow.webId + }) + // remove the follow from the database + await db + .delete(followers) + .where(and(eq(followers.followerId, user.userId), eq(followers.followedId, userToUnfollow.id))) + + const returnObject: FollowersFollowedResponse = { + id: userToUnfollow.id, + name: userToUnfollow.name, + webId: userToUnfollow.webId, + displayName: userToUnfollow.displayName, + providerName: userToUnfollow.providerName + } + return returnObject + } catch (_) { + // this only happens when the decodeWebId throws an error + return error(400, FollowErrors.NotValidProvider) + } + }, + { + summary: 'Unfollow a user', + params: t.Object({ + followerWebId: t.String() + }), + response: { + 200: followersFollowedResponse, + 400: t.Enum(FollowErrors) + } + } + ) diff --git a/api/src/services/ActivityPod.ts b/api/src/services/ActivityPod.ts index a78cf2c39..bd35d23a5 100644 --- a/api/src/services/ActivityPod.ts +++ b/api/src/services/ActivityPod.ts @@ -1,8 +1,15 @@ import ky from 'ky' -import type { NoteCreateRequest, PodProviderSignInResponse } from '../types' +import type { FollowRequest, NoteCreateRequest, PodProviderSignInResponse, UnfollowRequest } from '../types' import type User from '../decorater/User' export default abstract class ActivityPod { + /** + * + * @param endpoint - the url of the pod provider + * @param username - the username of the user + * @param password - the password of the user + * @returns + */ static async signIn(endpoint: string, username: string, password: string) { const response: PodProviderSignInResponse = await ky .post(`${endpoint}/auth/login`, { @@ -15,6 +22,14 @@ export default abstract class ActivityPod { return response } + /** + * Singup a user with the provided credentials on the provided pod provider + * @param endpoint - the url of the pod provider + * @param username - the username of the user + * @param password - the password of the user + * @param email - the email of the user + * @returns the response of the signup request when ther is an error it will throw an error + */ static async signup(endpoint: string, username: string, password: string, email: string) { const response: PodProviderSignInResponse = await ky .post(`${endpoint}/auth/signup`, { @@ -28,9 +43,15 @@ export default abstract class ActivityPod { return response } + /** + * Create a Post (Note) + * @param user - the user who is creating the post + * @param post - the post to create + * @returns the response of the post request when ther is an error it will throw an error + */ static async createPost(user: User, post: NoteCreateRequest) { const response = await ky - .post(`${user.endpoint}/${user.userName}/outbox`, { + .post(`${user.providerWebId}/outbox`, { headers: { Authorization: `Bearer ${user.token}` }, @@ -39,4 +60,40 @@ export default abstract class ActivityPod { .json() return response } + + /** + * Follows a user + * @param {User} user - the user who is following + * @param {FollowRequest} body - the body of the follow request + * @returns returns the response of the follow request when ther is an error it will throw an error + */ + static async follow(user: User, body: FollowRequest) { + const response = await ky + .post(`${user.providerWebId}/outbox`, { + headers: { + Authorization: `Bearer ${user.token}` + }, + json: body + }) + .json() + return response + } + + /** + * Unfollow a User + * @param {User} user - the user who is unfollowing + * @param {UnfollowRequest} body - the body of the unfollow request + * @returns returns the response of the unfollow request when ther is an error it will throw an error + */ + static async unfollow(user: User, body: UnfollowRequest) { + const response = await ky + .post(`${user.providerWebId}/outbox`, { + headers: { + Authorization: `Bearer ${user.token}` + }, + json: body + }) + .json() + return response + } } diff --git a/api/src/services/jwt.ts b/api/src/services/jwt.ts index a3196661c..90f26d225 100644 --- a/api/src/services/jwt.ts +++ b/api/src/services/jwt.ts @@ -1,12 +1,12 @@ -import type User from "../decorater/User" -import type { JWTPayloadSpec } from "@elysiajs/jwt" +import type User from '../decorater/User' +import type { JWTPayloadSpec } from '@elysiajs/jwt' export interface TokenObject { user: string } export function getTokenObject(user: User): Record & JWTPayloadSpec { - return { - user: JSON.stringify(user) - } + return { + user: user.toString() } +} diff --git a/api/src/test/user.test.ts b/api/src/test/user.test.ts new file mode 100644 index 000000000..3c79c4d3b --- /dev/null +++ b/api/src/test/user.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import { decodeWebId, encodeWebId } from '@/util/user' +import User from '@/decorater/User' + +describe('User Util', () => { + describe('decodeWebId', () => { + test('decodeWebId', () => { + const webId = '@test@memory.' + const encoded = decodeWebId(webId) + + expect(encoded.username).toBe('test') + expect(encoded.provider).toBe('memory.') + expect(encoded.endpoint).toBe('http://localhost:3000') + expect(encoded.endpointWebId).toBe('http://localhost:3000/test') + }) + + test('decodeWebId - with a not viably pod provider', () => { + const webId = '@test@test.' + + expect(() => { + decodeWebId(webId) + }).toThrow('The provider is not a viable pod provider') + }) + }) + describe('encodeWebId', () => { + test('encodeWebId', () => { + const webId = '@test@memory.' + const encoded = encodeWebId('http://localhost:3000/test') + + expect(encoded).toBe(webId) + }) + + test('encodeWebId - with a memory webId', () => { + const webId = '@test@memory.' + const encoded = encodeWebId(webId) + + expect(encoded).toBe(webId) + }) + + test('encodeWebId - with a memory webId that has a invalid provider', () => { + const invalidMemoryWebId = '@test@test.' + expect(() => { + encodeWebId(invalidMemoryWebId) + }).toThrow('The provider is not a viable pod provider') + }) + + test('encodeWebId - with a corrupted memory webId', () => { + const corruptedWebId = 'test@memory' + expect(() => { + encodeWebId(corruptedWebId) + }).toThrow('The provider is not a viable pod provider') + }) + + test('encodeWebId - with User object', () => { + const webId = '@test@memory.' + + const testUser = new User() + testUser.loadUser(JSON.stringify({ userId: 1, username: 'test', token: 'test', provider: 'memory.' })) + const encoded = encodeWebId(testUser) + expect(encoded).toBe(webId) + }) + + test('encodeWebId - with a not viably pod provider webId', () => { + expect(() => { + encodeWebId('notValidProvider/test') + }).toThrow('The provider is not a viable pod provider') + }) + + test('encodeWebId - with a not viably user object', () => { + expect(() => { + const invalidUser = new User() + invalidUser.loadUser( + JSON.stringify({ userId: 1, username: 'test', token: 'test', provider: 'notAPodProvider.' }) + ) + encodeWebId(invalidUser) + }).toThrow('The provider is not a viable pod provider') + }) + }) +}) diff --git a/api/src/types/activityPod.ts b/api/src/types/activityPod.ts new file mode 100644 index 000000000..b98fbcd73 --- /dev/null +++ b/api/src/types/activityPod.ts @@ -0,0 +1,29 @@ +export enum PodRequestTypes { + Follow = 'Follow', + Undo = 'Undo', + Note = 'Note' +} + +interface BasePodRequest { + '@context': 'https://www.w3.org/ns/activitystreams' + type: PodRequestTypes + to: T +} + +interface PodRequest extends BasePodRequest { + actor: string + object: O +} + +export type FollowRequest = PodRequest +export type UnfollowRequest = PodRequest> +export interface NoteCreateRequest extends BasePodRequest { + attributedTo: string + content: string +} + +export interface PodProviderSignInResponse { + token: string + webId: string + newUser: boolean +} diff --git a/api/src/types/db.ts b/api/src/types/db.ts index 8b71a0c38..003d38d13 100644 --- a/api/src/types/db.ts +++ b/api/src/types/db.ts @@ -20,4 +20,4 @@ export type CreatePost = Static export const _createUser = createInsertSchema(users) export type CreateUser = Static export const _selectUsers = createSelectSchema(users) -export interface SelectUsers extends Static {} +export type SelectUsers = Static diff --git a/api/src/types/enums.ts b/api/src/types/enums.ts index 0e6fc1bb2..11e5fc9d1 100644 --- a/api/src/types/enums.ts +++ b/api/src/types/enums.ts @@ -1,5 +1,11 @@ import { t } from 'elysia' -export const viablePodProviders = t.Enum({ - 'http://localhost:3000': 'http://localhost:3000' -}) +export const vibaleProviderNames = ['memory.'] + +export enum ViablePodProvider { + 'memory.' = 'memory.' +} +export const podProviderEndpoint: { [key in ViablePodProvider]: string } = { + 'memory.': 'http://localhost:3000' +} +export const viablePodProviders = t.Enum(ViablePodProvider) diff --git a/api/src/types/errors.ts b/api/src/types/errors.ts new file mode 100644 index 000000000..47ab290e7 --- /dev/null +++ b/api/src/types/errors.ts @@ -0,0 +1,20 @@ +export enum FollowErrors { + NotOnMemory = 'The user to follow is not on the memory.', + NotFollowing = 'User is not following the user.', + NotValidProvider = 'The provider of the user to follow is not a viable pod provider.', + AlreadyFollowing = 'User already follows the user.', + IsSelf = 'Cannot follow yourself.' +} + +// Errors that are thrown by the pod provider +export enum ProviderSignUpErrors { + providerSignUpDefault = 'Error while signing up the user', + 'username.invalid' = 'Username is invalid', + 'username.already.exists' = 'Username is already taken', + 'email.invalid' = 'Email is invalid', + 'email.already.exists' = 'Email is already taken' +} + +export enum ProviderSignInErrors { + "Endpoint didn't respond with a 200 status code" = 'Wrong credentials' +} diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 03c168145..a775607e4 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -1,18 +1,6 @@ -export interface PodProviderSignInResponse { - token: string - webId: string - newUser: boolean -} - -export interface NoteCreateRequest { - '@context': string - type: string - attributedTo: string - content: string - to: string[] -} - // Export all Types from diffrent files export * from './responses' export * from './db' export * from './enums' +export * from './errors' +export * from './activityPod' diff --git a/api/src/types/responses.ts b/api/src/types/responses.ts index a8a0b5f5d..458e9344d 100644 --- a/api/src/types/responses.ts +++ b/api/src/types/responses.ts @@ -2,16 +2,16 @@ import { t, type Static } from 'elysia' import { _selectUsers } from './db' import { viablePodProviders } from './enums' import { createSchemaFactory } from 'drizzle-typebox' -import { posts, postsView, users } from '../db/schema' +import { postsView, users } from '../db/schema' -const {createSelectSchema} = createSchemaFactory({typeboxInstance: t}) +const { createSelectSchema } = createSchemaFactory({ typeboxInstance: t }) // Auth // SignIn export const signinBody = t.Object({ username: t.String(), password: t.String(), - providerEndpoint: viablePodProviders + providerName: viablePodProviders }) export type SignInBody = Static @@ -25,24 +25,27 @@ export const signUpBody = t.Object({ username: t.String(), password: t.String(), email: t.String(), - providerEndpoint: viablePodProviders + providerName: viablePodProviders }) export type SignUpBody = Static // Users export const selectUser = createSelectSchema(users) +export const followersFollowedResponse = t.Omit(selectUser, ['email']) +export type FollowersFollowedResponse = Static + // Posts export const selectPost = createSelectSchema(postsView) export type SelectPost = { - id: number; - content: string; - isPublic: boolean; - createdAt: string; - authorId: number; + id: number + content: string + isPublic: boolean + createdAt: string + authorId: number author: { - id: number; - name: string; - webId: string; - }; + id: number + name: string + webId: string + } } diff --git a/api/src/util/user.ts b/api/src/util/user.ts new file mode 100644 index 000000000..3d50e21b4 --- /dev/null +++ b/api/src/util/user.ts @@ -0,0 +1,61 @@ +import type User from '@/decorater/User' +import { podProviderEndpoint, ViablePodProvider } from '@/types' + +/** + * Encodes a webId to a partial User object + * @param {string} webId - The webId to encode (e.g. @test@memory.) + * @returns + */ +export function decodeWebId(webId: string): { + username: string + provider: string + endpoint: string + endpointWebId: string +} { + const [username, provider] = webId.split('@').slice(1) + + if (!Object.keys(podProviderEndpoint).includes(provider)) throw new Error('The provider is not a viable pod provider') + + return { + username: username, + provider: provider, + endpoint: podProviderEndpoint[provider as ViablePodProvider], + endpointWebId: `${podProviderEndpoint[provider as ViablePodProvider]}/${username}` + } +} + +/** + * Decodes a User object to a webId + * @param {User} string - The username of the user + * @returns {string} - The webId of the user + */ +export function encodeWebId(user: User): string +export function encodeWebId(providerWebId: string): string +export function encodeWebId(user_or_providerWebId: User | string): string { + if (typeof user_or_providerWebId === 'string') { + const username = user_or_providerWebId.split('/').pop() + const endpoint = user_or_providerWebId.split('/').slice(0, -1).join('/') + let name = '' + Object.entries(podProviderEndpoint).forEach(([providerName, providerEndpoint]) => { + if (providerEndpoint === endpoint) { + name = providerName + } + }) + if (name === '') { + //check if provided webId is already a memory webId + const split = user_or_providerWebId.split('@') + if (split.length === 3) { + // check if provider is valid + if (!Object.keys(podProviderEndpoint).includes(split[2])) + throw new Error('The provider is not a viable pod provider') + return user_or_providerWebId + } + throw new Error('The provider is not a viable pod provider') + } + return `@${username}@${name}` + } else { + if (!Object.keys(podProviderEndpoint).includes(user_or_providerWebId.provider)) + throw new Error('The provider is not a viable pod provider') + return `@${user_or_providerWebId.username}@${user_or_providerWebId.provider}` + } +} diff --git a/docker/api.dockerfile b/docker/api.dockerfile index 6715cc9fb..16e62d85f 100644 --- a/docker/api.dockerfile +++ b/docker/api.dockerfile @@ -6,6 +6,7 @@ COPY /api/package.json /api/bun.lock /api/.env /app/api RUN bun install --global pm2 RUN bun install +RUN bun drizzle:push ADD api /app/api RUN bun build \ diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 000000000..5cff45b98 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,2 @@ +**/ios/** +**/android/** \ No newline at end of file diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.js similarity index 81% rename from frontend/eslint.config.ts rename to frontend/eslint.config.js index 84b2cefa0..05b93c3bc 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.js @@ -11,20 +11,20 @@ import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' export default defineConfigWithVueTs( { name: 'app/files-to-lint', - files: ['**/*.{ts,mts,tsx,vue}'], + files: ['**/*.{ts,mts,tsx,vue}'] }, { name: 'app/files-to-ignore', - ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], + ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/ios/**', '**/android/**'] }, pluginVue.configs['flat/essential'], vueTsConfigs.recommended, - + { ...pluginVitest.configs.recommended, - files: ['src/**/__tests__/*'], + files: ['src/**/__tests__/*'] }, - skipFormatting, + skipFormatting ) diff --git a/frontend/index.html b/frontend/index.html index 9e5fc8f06..9d30802cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,9 @@ - + - - - + + + Vite App diff --git a/frontend/package.json b/frontend/package.json index 72a60d4fe..84e2d7d15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "test:unit": "vitest", "build-only": "vite build", "type-check": "vue-tsc --build", - "lint": "eslint . --fix", - "format": "prettier --write src/", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", "sync": "cap sync", "open:android": "cap open android", "open:ios": "cap open ios" diff --git a/frontend/src/assets/fonts/Butler/index.css b/frontend/src/assets/fonts/Butler/index.css index 28340bc18..18e3618cb 100644 --- a/frontend/src/assets/fonts/Butler/index.css +++ b/frontend/src/assets/fonts/Butler/index.css @@ -1,56 +1,62 @@ @font-face { - font-family: 'Butler'; - src: url('Butler-UltraLight.woff2') format('woff2'), - url('Butler-UltraLight.woff') format('woff'); - font-weight: 200; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-UltraLight.woff2') format('woff2'), + url('Butler-UltraLight.woff') format('woff'); + font-weight: 200; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler-Medium.woff2') format('woff2'), - url('Butler-Medium.woff') format('woff'); - font-weight: 500; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-Medium.woff2') format('woff2'), + url('Butler-Medium.woff') format('woff'); + font-weight: 500; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler-Light.woff2') format('woff2'), - url('Butler-Light.woff') format('woff'); - font-weight: 300; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-Light.woff2') format('woff2'), + url('Butler-Light.woff') format('woff'); + font-weight: 300; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler-Black.woff2') format('woff2'), - url('Butler-Black.woff') format('woff'); - font-weight: 900; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-Black.woff2') format('woff2'), + url('Butler-Black.woff') format('woff'); + font-weight: 900; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler-Bold.woff2') format('woff2'), - url('Butler-Bold.woff') format('woff'); - font-weight: bold; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-Bold.woff2') format('woff2'), + url('Butler-Bold.woff') format('woff'); + font-weight: bold; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler-ExtraBold.woff2') format('woff2'), - url('Butler-ExtraBold.woff') format('woff'); - font-weight: 800; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler-ExtraBold.woff2') format('woff2'), + url('Butler-ExtraBold.woff') format('woff'); + font-weight: 800; + font-style: normal; } @font-face { - font-family: 'Butler'; - src: url('Butler.woff2') format('woff2'), - url('Butler.woff') format('woff'); - font-weight: normal; - font-style: normal; + font-family: 'Butler'; + src: + url('Butler.woff2') format('woff2'), + url('Butler.woff') format('woff'); + font-weight: normal; + font-style: normal; } - diff --git a/frontend/src/components/BottomBar.vue b/frontend/src/components/BottomBar.vue index ff594e903..994a0ed1d 100644 --- a/frontend/src/components/BottomBar.vue +++ b/frontend/src/components/BottomBar.vue @@ -1,8 +1,11 @@ diff --git a/frontend/src/components/PostList.vue b/frontend/src/components/PostList.vue index 8e7a60736..829434ee6 100644 --- a/frontend/src/components/PostList.vue +++ b/frontend/src/components/PostList.vue @@ -2,8 +2,10 @@ import { usePostsStore } from '@/stores/postsStore' import { DateTime } from 'luxon' import MemoryButton from './MemoryButton.vue' +import { useUserStore } from '@/stores/userStore' const postsStore = usePostsStore() +const userStore = useUserStore()