diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml deleted file mode 100644 index 2a4766d..0000000 --- a/.github/workflows/npm-publish.yml +++ /dev/null @@ -1,33 +0,0 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages - -name: Node.js Package - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npm test - - publish-npm: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index b6a32e3..73a495f 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -348,6 +348,23 @@ export class Meta { length: 128, nullable: true, }) + public enableGithubIntegration: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public githubClientId: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public githubClientSecret: string | null; + + @Column("boolean", { + default: false, + }) public deeplAuthKey: string | null; @Column("boolean", { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 674262c..6eaca6c 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -178,6 +178,11 @@ export const meta = { optional: false, nullable: false, }, + enableGithubIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, enableServiceWorker: { type: "boolean", optional: false, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 210b5d2..01f1fe2 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -126,6 +126,9 @@ export const paramDef = { deeplIsPro: { type: "boolean" }, libreTranslateApiUrl: { type: "string", nullable: true }, libreTranslateApiKey: { type: "string", nullable: true }, + enableGithubIntegration: { type: "boolean" }, + githubClientId: { type: "string", nullable: true }, + githubClientSecret: { type: "string", nullable: true }, enableEmail: { type: "boolean" }, email: { type: "string", nullable: true }, smtpSecure: { type: "boolean" }, diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index efce27f..c05b00f 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -287,6 +287,11 @@ export const meta = { optional: false, nullable: false, }, + enableGithubIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, enableServiceWorker: { type: "boolean", optional: false, diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts new file mode 100644 index 0000000..d829997 --- /dev/null +++ b/packages/backend/src/server/api/service/github.ts @@ -0,0 +1,295 @@ +import type Koa from "koa"; +import Router from "@koa/router"; +import { OAuth2 } from "oauth"; +import { v4 as uuid } from "uuid"; +import { IsNull } from "typeorm"; +import { getJson } from "@/misc/fetch.js"; +import config from "@/config/index.js"; +import { publishMainStream } from "@/services/stream.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { redisClient } from "../../../db/redis.js"; +import signin from "../common/signin.js"; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers["cookie"] || "").match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? (url.endsWith("/") ? url.slice(0, url.length - 1) : url) : ""; + } + + const referer = ctx.headers["referer"]; + + return normalizeUrl(referer) === normalizeUrl(config.url); +} + +// Init router +const router = new Router(); + +router.get("/disconnect/github", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + profile.integrations.github = undefined; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = "El enlace de GitHub ha sido cancelado. :v:"; + + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); +}); + +async function getOath2() { + const meta = await fetchMeta(true); + + if ( + meta.enableGithubIntegration && + meta.githubClientId && + meta.githubClientSecret + ) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + "https://github.com/", + "login/oauth/authorize", + "login/oauth/access_token", + ); + } else { + return null; + } +} + +router.get("/connect/github", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ["read:user"], + state: uuid(), + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/signin/github", async (ctx) => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ["read:user"], + state: uuid(), + }; + + ctx.cookies.set("signin_with_github_sid", sessid, { + path: "/", + secure: config.url.startsWith("https"), + httpOnly: true, + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/gh/cb", async (ctx) => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get("signin_with_github_sid"); + + if (!sessid) { + ctx.throw(400, "invalid session"); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { + redirect_uri, + }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + }, + ), + ); + + const { login, id } = (await getJson( + "https://api.github.com/user", + "application/vnd.github.v3+json", + 10 * 1000, + { + Authorization: `bearer ${accessToken}`, + }, + )) as Record; + if (typeof login !== "string" || typeof id !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const link = await UserProfiles.createQueryBuilder() + .where("\"integrations\"->'github'->>'id' = :id", { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw( + 404, + `@${login}No habĂ­a ninguna cuenta de Fedired vinculada con...`, + ); + return; + } + + signin( + ctx, + (await Users.findOneBy({ id: link.userId })) as ILocalUser, + true, + ); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + }, + ), + ); + + const { login, id } = (await getJson( + "https://api.github.com/user", + "application/vnd.github.v3+json", + 10 * 1000, + { + Authorization: `bearer ${accessToken}`, + }, + )) as Record; + + if (typeof login !== "string" || typeof id !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + }, + }, + }); + + ctx.body = `GitHub: @${login} conectado a Fedired: @${user.username}! `; + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); + } +}); + +export default router; diff --git a/packages/client/src/components/MkSignin.vue b/packages/client/src/components/MkSignin.vue index 0f4ec6e..9ba5883 100644 --- a/packages/client/src/components/MkSignin.vue +++ b/packages/client/src/components/MkSignin.vue @@ -130,6 +130,18 @@ + diff --git a/packages/client/src/components/note/MkNoteHeader.vue b/packages/client/src/components/note/MkNoteHeader.vue index 7d4fbe9..56c1e4d 100644 --- a/packages/client/src/components/note/MkNoteHeader.vue +++ b/packages/client/src/components/note/MkNoteHeader.vue @@ -92,14 +92,13 @@ function openServerInfo() { justify-self: flex-end; border-radius: 100px; font-size: 0.8em; - text-shadow: 0 2px 2px var(--shadow); > .avatar { - inline-size: 3.7em; - block-size: 3.7em; - margin-inline-end: 1em; + width: 3.7em; + height: 3.7em; + margin-right: 1em; } > .user-info { - inline-size: 0; + width: 0; flex-grow: 1; line-height: 1.5; display: flex; @@ -107,13 +106,13 @@ function openServerInfo() { > div { &:first-child { flex-grow: 1; - inline-size: 0; + width: 0; overflow: hidden; text-overflow: ellipsis; gap: 0.1em 0; } &:last-child { - max-inline-size: 50%; + max-width: 50%; gap: 0.3em 0.5em; } .article > .main & { @@ -124,17 +123,14 @@ function openServerInfo() { align-items: flex-end; } > * { - max-inline-size: 100%; + max-width: 100%; } } } .name { // flex: 1 1 0px; display: inline; - margin-block-start: 0; - margin-inline-end: 0.5em; - margin-block-end: 0; - margin-inline-start: 0; + margin: 0 0.5em 0 0; padding: 0; overflow: hidden; font-weight: bold; @@ -144,12 +140,8 @@ function openServerInfo() { .mkusername > .is-bot { flex-shrink: 0; align-self: center; - margin-block-start: 0; - margin-inline-end: 0.5em; - margin-block-end: 0; - margin-inline-start: 0; - padding-block: 1px; - padding-inline: 6px; + margin: 0 0.5em 0 0; + padding: 1px 6px; font-size: 80%; border: solid 0.5px var(--divider); border-radius: 3px; @@ -162,10 +154,7 @@ function openServerInfo() { .username { display: inline; - margin-block-start: 0; - margin-inline-end: 0.5em; - margin-block-end: 0; - margin-inline-start: 0; + margin: 0 0.5em 0 0; overflow: hidden; text-overflow: ellipsis; align-self: flex-start; @@ -175,10 +164,10 @@ function openServerInfo() { .info { display: inline-flex; flex-shrink: 0; - margin-inline-start: 0.5em; + margin-left: 0.5em; font-size: 0.9em; .created-at { - max-inline-size: 100%; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; } @@ -186,7 +175,7 @@ function openServerInfo() { .ticker { display: inline-flex; - margin-inline-start: 0.5em; + margin-left: 0.5em; vertical-align: middle; > .name { display: none; diff --git a/packages/client/src/pages/admin/integrations.github.vue b/packages/client/src/pages/admin/integrations.github.vue new file mode 100644 index 0000000..67c57a7 --- /dev/null +++ b/packages/client/src/pages/admin/integrations.github.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue new file mode 100644 index 0000000..2b9b541 --- /dev/null +++ b/packages/client/src/pages/settings/integration.vue @@ -0,0 +1,67 @@ + + + \ No newline at end of file diff --git a/packages/fedired-js/src/entities.ts b/packages/fedired-js/src/entities.ts index 83ebc25..71a4a47 100644 --- a/packages/fedired-js/src/entities.ts +++ b/packages/fedired-js/src/entities.ts @@ -370,6 +370,7 @@ export type LiteInstanceMetadata = { swPublickey: string | null; maxNoteTextLength: number; enableEmail: boolean; + enableGithubIntegration: boolean; enableServiceWorker: boolean; markLocalFilesNsfwByDefault: boolean; emojis: CustomEmoji[];