diff --git a/apps/penxle.com/.env b/apps/penxle.com/.env index f094e63b80..90b3211dd5 100644 --- a/apps/penxle.com/.env +++ b/apps/penxle.com/.env @@ -7,6 +7,7 @@ PRIVATE_GOOGLE_CLIENT_SECRET= PRIVATE_DATABASE_URL= PRIVATE_ELASTICSEARCH_API_KEY= PRIVATE_ELASTICSEARCH_CLOUD_ID= +PRIVATE_IFRAMELY_API_KEY= PRIVATE_NAVER_CLIENT_ID= PRIVATE_NAVER_CLIENT_SECRET= PRIVATE_OPENSEARCH_URL= diff --git a/apps/penxle.com/prisma/migrations/20240120121128_add_embed_model/migration.sql b/apps/penxle.com/prisma/migrations/20240120121128_add_embed_model/migration.sql new file mode 100644 index 0000000000..ac74b07f0a --- /dev/null +++ b/apps/penxle.com/prisma/migrations/20240120121128_add_embed_model/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'USER'; + +-- CreateTable +CREATE TABLE "embeds" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "title" TEXT, + "description" TEXT, + "image_url" TEXT, + "html" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "embeds_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "embeds" ADD CONSTRAINT "embeds_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/penxle.com/prisma/migrations/20240120123204_make_embed_url_unique/migration.sql b/apps/penxle.com/prisma/migrations/20240120123204_make_embed_url_unique/migration.sql new file mode 100644 index 0000000000..eaaaf752ed --- /dev/null +++ b/apps/penxle.com/prisma/migrations/20240120123204_make_embed_url_unique/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - A unique constraint covering the columns `[url]` on the table `embeds` will be added. If there are existing duplicate values, this will fail. + +*/ + +-- CreateIndex +CREATE UNIQUE INDEX "embeds_url_key" ON "embeds"("url"); diff --git a/apps/penxle.com/prisma/migrations/20240120155508_alter_embeds/migration.sql b/apps/penxle.com/prisma/migrations/20240120155508_alter_embeds/migration.sql new file mode 100644 index 0000000000..d1b8128758 --- /dev/null +++ b/apps/penxle.com/prisma/migrations/20240120155508_alter_embeds/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - You are about to drop the column `image_url` on the `embeds` table. All the data in the column will be lost. + - You are about to drop the column `user_id` on the `embeds` table. All the data in the column will be lost. + - The `content_filters` column on the `posts` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - Added the required column `site` to the `embeds` table without a default value. This is not possible if the table is not empty. + - Added the required column `type` to the `embeds` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "embeds" DROP CONSTRAINT "embeds_user_id_fkey"; + +-- AlterTable +ALTER TABLE "embeds" DROP COLUMN "image_url", +DROP COLUMN "user_id", +ADD COLUMN "site" TEXT NOT NULL, +ADD COLUMN "thumbnail_url" TEXT, +ADD COLUMN "type" TEXT NOT NULL; diff --git a/apps/penxle.com/prisma/migrations/20240120165507_drop_embeds_site/migration.sql b/apps/penxle.com/prisma/migrations/20240120165507_drop_embeds_site/migration.sql new file mode 100644 index 0000000000..edf2274da9 --- /dev/null +++ b/apps/penxle.com/prisma/migrations/20240120165507_drop_embeds_site/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `site` on the `embeds` table. All the data in the column will be lost. + - The `content_filters` column on the `posts` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "embeds" DROP COLUMN "site"; diff --git a/apps/penxle.com/prisma/schema.prisma b/apps/penxle.com/prisma/schema.prisma index f840db54e6..6da5f005f1 100644 --- a/apps/penxle.com/prisma/schema.prisma +++ b/apps/penxle.com/prisma/schema.prisma @@ -41,6 +41,19 @@ model BookmarkGroupPost { @@map("bookmark_posts") } +model Embed { + id String @id + type String + url String @unique + title String? + description String? + thumbnailUrl String? @map("thumbnail_url") + html String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + @@map("embeds") +} + model File { id String @id userId String @map("user_id") diff --git a/apps/penxle.com/schema.graphql b/apps/penxle.com/schema.graphql index a60e20cac4..40bb5c807a 100644 --- a/apps/penxle.com/schema.graphql +++ b/apps/penxle.com/schema.graphql @@ -109,6 +109,16 @@ input DeleteUserInput { email: String! } +type Embed { + description: String + html: String + id: ID! + thumbnailUrl: String + title: String + type: String! + url: String! +} + type File { id: ID! name: String! @@ -234,6 +244,7 @@ type Mutation { unbookmarkPost(input: UnbookmarkPostInput!): Post! unfollowSpace(input: UnfollowSpaceInput!): Space! unfollowTag(input: UnfollowTagInput!): Tag! + unfurlEmbed(input: UnfurlEmbedInput!): Embed unlikePost(input: UnlikePostInput!): Post! unlinkUserSingleSignOn(input: UnlinkUserSingleSignOnInput!): User! unlockPasswordedPost(input: UnlockPasswordedPostInput!): Post! @@ -692,6 +703,10 @@ input UnfollowTagInput { tagId: ID! } +input UnfurlEmbedInput { + url: String! +} + input UnlikePostInput { postId: ID! } diff --git a/apps/penxle.com/src/lib/server/external-api/iframely.ts b/apps/penxle.com/src/lib/server/external-api/iframely.ts new file mode 100644 index 0000000000..bd111c1a18 --- /dev/null +++ b/apps/penxle.com/src/lib/server/external-api/iframely.ts @@ -0,0 +1,26 @@ +import got from 'got'; +import { env } from '$env/dynamic/private'; + +export const unfurl = async (url: string) => { + const resp = await got({ + url: 'https://iframe.ly/api/oembed', + method: 'GET', + searchParams: { + api_key: env.PRIVATE_IFRAMELY_API_KEY, + url, + omit_script: 1, + }, + }).json>(); + + if (resp.error) { + throw new Error(resp.error); + } + + return { + type: resp.type, + title: resp.title, + description: resp.description, + thumbnailUrl: resp.thumbnail_url, + html: resp.html, + }; +}; diff --git a/apps/penxle.com/src/lib/server/external-api/index.ts b/apps/penxle.com/src/lib/server/external-api/index.ts index 1e96840dbd..3f475cb055 100644 --- a/apps/penxle.com/src/lib/server/external-api/index.ts +++ b/apps/penxle.com/src/lib/server/external-api/index.ts @@ -2,5 +2,6 @@ export * as aws from './aws'; export * as coocon from './coocon'; export * as exim from './exim'; export * as google from './google'; +export * as iframely from './iframely'; export * as naver from './naver'; export * as portone from './portone'; diff --git a/apps/penxle.com/src/lib/server/graphql/schemas/embed.ts b/apps/penxle.com/src/lib/server/graphql/schemas/embed.ts new file mode 100644 index 0000000000..e5d217f5e6 --- /dev/null +++ b/apps/penxle.com/src/lib/server/graphql/schemas/embed.ts @@ -0,0 +1,79 @@ +import { iframely } from '$lib/server/external-api'; +import { createId } from '$lib/utils'; +import { defineSchema } from '../builder'; + +export const embedSchema = defineSchema((builder) => { + /** + * * Types + */ + + builder.prismaObject('Embed', { + fields: (t) => ({ + id: t.exposeID('id'), + type: t.exposeString('type'), + url: t.exposeString('url'), + title: t.exposeString('title', { nullable: true }), + description: t.exposeString('description', { nullable: true }), + thumbnailUrl: t.exposeString('thumbnailUrl', { nullable: true }), + html: t.exposeString('html', { nullable: true }), + }), + }); + + /** + * * Inputs + */ + + const UnfurlEmbedInput = builder.inputType('UnfurlEmbedInput', { + fields: (t) => ({ + url: t.string(), + }), + }); + + /** + * * Queries + */ + + // builder.queryFields((t) => ({ + // })); + + /** + * * Mutations + */ + + builder.mutationFields((t) => ({ + unfurlEmbed: t.withAuth({ user: true }).prismaField({ + type: 'Embed', + nullable: true, + args: { input: t.arg({ type: UnfurlEmbedInput }) }, + resolve: async (query, _, { input }, { db }) => { + const embed = await db.embed.findUnique({ + ...query, + where: { url: input.url }, + }); + + if (embed) { + return embed; + } + + try { + const meta = await iframely.unfurl(input.url); + + return await db.embed.create({ + ...query, + data: { + id: createId(), + type: meta.type, + url: input.url, + title: meta.title, + description: meta.description, + thumbnailUrl: meta.thumbnailUrl, + html: meta.html, + }, + }); + } catch { + return null; + } + }, + }), + })); +}); diff --git a/apps/penxle.com/src/lib/server/graphql/schemas/index.ts b/apps/penxle.com/src/lib/server/graphql/schemas/index.ts index fec462cc20..2cf15138ed 100644 --- a/apps/penxle.com/src/lib/server/graphql/schemas/index.ts +++ b/apps/penxle.com/src/lib/server/graphql/schemas/index.ts @@ -3,6 +3,7 @@ import { addSchema, createBuilder } from '../builder'; import { bookmarkSchema } from './bookmark'; import { collectionSchema } from './collection'; import { devSchema } from './dev'; +import { embedSchema } from './embed'; import { enumsSchema } from './enums'; import { feedSchema } from './feed'; import { fileSchema } from './file'; @@ -23,6 +24,7 @@ addSchema(builder, [ bookmarkSchema, collectionSchema, devSchema, + embedSchema, enumsSchema, feedSchema, fileSchema, diff --git a/apps/penxle.com/src/lib/server/prisma/logging.ts b/apps/penxle.com/src/lib/server/prisma/logging.ts index ca0f51cf85..ae300e8ec6 100644 --- a/apps/penxle.com/src/lib/server/prisma/logging.ts +++ b/apps/penxle.com/src/lib/server/prisma/logging.ts @@ -12,9 +12,13 @@ const interpolateQuery = (query: string, params: unknown[]) => { }; export const logging = (e: Prisma.QueryEvent) => { - logger.trace({ - context: 'database', - query: interpolateQuery(e.query, JSON.parse(e.params)), - duration: e.duration, - }); + try { + logger.trace({ + context: 'database', + query: interpolateQuery(e.query, JSON.parse(e.params)), + duration: e.duration, + }); + } catch { + // pass + } }; diff --git a/apps/penxle.com/src/lib/server/utils/tiptap.ts b/apps/penxle.com/src/lib/server/utils/tiptap.ts index e9a23e9edd..2076a0d5dd 100644 --- a/apps/penxle.com/src/lib/server/utils/tiptap.ts +++ b/apps/penxle.com/src/lib/server/utils/tiptap.ts @@ -96,6 +96,35 @@ export const decorateContent = async ( }; } + if (node.type === 'embed') { + if (!node.attrs?.url) { + return node; + } + + const embed = await db.embed.findUnique({ + select: { type: true, title: true, description: true, thumbnailUrl: true, html: true }, + where: { url: node.attrs.url }, + }); + + if (!embed) { + return node; + } + + return { + ...node, + attrs: { + ...node.attrs, + __data: { + type: embed.type, + title: embed.title, + description: embed.description, + thumbnailUrl: embed.thumbnailUrl, + html: embed.html, + }, + }, + }; + } + return node; }), ); diff --git a/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte b/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte new file mode 100644 index 0000000000..7c80cdc89a --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte @@ -0,0 +1,88 @@ + + + + + + + + {#if node.attrs.__data} + {#if node.attrs.__data.html} +
+ {@html node.attrs.__data.html} +
+ {:else} + + {#if node.attrs.__data.thumbnailUrl} +
+ +
+ {/if} +
+
{node.attrs.__data.title}
+
{node.attrs.__data.description}
+
+ + {/if} + {:else} +

+ {node.attrs.url} + +

+ {/if} +
diff --git a/apps/penxle.com/src/lib/tiptap/node-views/embed/index.ts b/apps/penxle.com/src/lib/tiptap/node-views/embed/index.ts new file mode 100644 index 0000000000..9052b0fe3f --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/node-views/embed/index.ts @@ -0,0 +1,55 @@ +import { nodePasteRule } from '@tiptap/core'; +import * as linkifyjs from 'linkifyjs'; +import { createNodeView } from '../../lib'; +import Component from './Component.svelte'; + +declare module '@tiptap/core' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Commands { + embed: { + setEmbed: (url: string) => ReturnType; + }; + } +} + +export const Embed = createNodeView(Component, { + name: 'embed', + group: 'block', + draggable: true, + + addAttributes() { + return { + url: { isRequired: true }, + __data: {}, + }; + }, + + addCommands() { + return { + setEmbed: + (url) => + ({ tr }) => { + tr.replaceSelectionWith(this.type.create({ url })); + return tr.docChanged; + }, + }; + }, + + addPasteRules() { + return [ + nodePasteRule({ + find: (text) => { + // eslint-disable-next-line unicorn/no-array-callback-reference + const links = linkifyjs.find(text); + return links.map((link) => ({ + index: link.start, + text: link.value, + data: link, + })); + }, + type: this.type, + getAttributes: (match) => ({ url: match.data?.href }), + }), + ]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/node-views/index.ts b/apps/penxle.com/src/lib/tiptap/node-views/index.ts index ed81ca25d7..b436498901 100644 --- a/apps/penxle.com/src/lib/tiptap/node-views/index.ts +++ b/apps/penxle.com/src/lib/tiptap/node-views/index.ts @@ -1,3 +1,4 @@ export * from './access-barrier'; +export * from './embed'; export * from './file'; export * from './image'; diff --git a/apps/penxle.com/src/lib/tiptap/preset.ts b/apps/penxle.com/src/lib/tiptap/preset.ts index 8d5601e131..e60e8f9515 100644 --- a/apps/penxle.com/src/lib/tiptap/preset.ts +++ b/apps/penxle.com/src/lib/tiptap/preset.ts @@ -9,7 +9,7 @@ import { TextAlign, } from '$lib/tiptap/extensions'; import { Bold, Italic, Link, Strike, TextColor, Underline } from '$lib/tiptap/marks'; -import { AccessBarrier, File, Image } from '$lib/tiptap/node-views'; +import { AccessBarrier, Embed, File, Image } from '$lib/tiptap/node-views'; import { BulletList, Document, @@ -58,6 +58,7 @@ export const extensions = [ // node views AccessBarrier, + Embed, File, Image, ]; diff --git a/apps/penxle.com/src/styles/prose.css b/apps/penxle.com/src/styles/prose.css index 3488083312..e19bbe890d 100644 --- a/apps/penxle.com/src/styles/prose.css +++ b/apps/penxle.com/src/styles/prose.css @@ -1,4 +1,4 @@ -.ProseMirror { +.ProseMirror:not(:has(.tiptap-embedded)) { /* * nodes */ diff --git a/cspell.config.json b/cspell.config.json index 65b090ad6a..5808bffae6 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -29,6 +29,8 @@ "hget", "hset", "iconify", + "iframe", + "iframely", "itty", "karpenter", "lambdify", @@ -49,6 +51,7 @@ "noreferrer", "nori", "nums", + "oembed", "oidc", "oidcs", "onwarn",