diff --git a/conf/artalk.example.simple.yml b/conf/artalk.example.simple.yml index 56bac2864..0231b64be 100644 --- a/conf/artalk.example.simple.yml +++ b/conf/artalk.example.simple.yml @@ -141,8 +141,84 @@ admin_notify: channel_secret: "" channel_access_token: "" receivers: - - USER_ID_1 - - GROUP_ID_1 + - "USER_ID_1" + - "GROUP_ID_1" +auth: + enabled: false + anonymous: true + callback: "http://localhost:23366/api/v2/auth/{provider}/callback" + email: + enabled: true + verify_subject: "Your Code - {{code}}" + verify_tpl: default + github: + enabled: false + client_id: "" + client_secret: "" + gitlab: + enabled: false + client_id: "" + client_secret: "" + gitea: + enabled: false + client_id: "" + client_secret: "" + google: + enabled: false + client_id: "" + client_secret: "" + mastodon: + enabled: false + client_id: "" + client_secret: "" + twitter: + enabled: false + client_id: "" + client_secret: "" + facebook: + enabled: false + client_id: "" + client_secret: "" + discord: + enabled: false + client_id: "" + client_secret: "" + steam: + enabled: false + api_key: "" + apple: + enabled: false + client_id: "" + client_secret: "" + microsoft: + enabled: false + client_id: "" + client_secret: "" + wechat: + enabled: false + client_id: "" + client_secret: "" + tiktok: + enabled: false + client_id: "" + client_secret: "" + slack: + enabled: false + client_id: "" + client_secret: "" + line: + enabled: false + client_id: "" + client_secret: "" + patreon: + enabled: false + client_id: "" + client_secret: "" + auth0: + enabled: false + client_id: "" + client_secret: "" + domain: "" frontend: placeholder: "" noComment: "" diff --git a/conf/artalk.example.yml b/conf/artalk.example.yml index 86cdfb023..5c6f836ca 100644 --- a/conf/artalk.example.yml +++ b/conf/artalk.example.yml @@ -282,6 +282,108 @@ admin_notify: - USER_ID_1 - GROUP_ID_1 +# Social Login +auth: + # Enable Social Login + enabled: false + # Allow anonymous commenting (Allow skipping verification, only fill in an anonymous nickname and email) + anonymous: true + # Callback URL (https://example.com/api/v2/auth/{provider}/callback) + callback: "http://localhost:23366/api/v2/auth/{provider}/callback" + # Email + email: + # Enable email password login + enabled: true + # Verification email subject + verify_subject: "Your Code - {{code}}" + # Verification email template (set to file path to use custom template) + verify_tpl: default + # GitHub + github: + enabled: false + client_id: "" + client_secret: "" + # GitLab + gitlab: + enabled: false + client_id: "" + client_secret: "" + # Gitea + gitea: + enabled: false + client_id: "" + client_secret: "" + # Google + google: + enabled: false + client_id: "" + client_secret: "" + # Mastodon + mastodon: + enabled: false + client_id: "" + client_secret: "" + # Twitter + twitter: + enabled: false + client_id: "" + client_secret: "" + # Facebook + facebook: + enabled: false + client_id: "" + client_secret: "" + # Discord + discord: + enabled: false + client_id: "" + client_secret: "" + # Steam + steam: + enabled: false + api_key: "" + # Apple + apple: + enabled: false + client_id: "" + client_secret: "" + # Microsoft + microsoft: + enabled: false + client_id: "" + client_secret: "" + # WeChat + wechat: + enabled: false + client_id: "" + client_secret: "" + # Tiktok + tiktok: + enabled: false + client_id: "" + client_secret: "" + # Slack + slack: + enabled: false + client_id: "" + client_secret: "" + # Line + line: + enabled: false + client_id: "" + client_secret: "" + # Patreon + patreon: + enabled: false + client_id: "" + client_secret: "" + # Auth0 + auth0: + enabled: false + client_id: "" + client_secret: "" + domain: "" + # UI Settings frontend: # Comment box placeholder diff --git a/conf/artalk.example.zh-CN.yml b/conf/artalk.example.zh-CN.yml index 5ebf44a0e..5911e4400 100644 --- a/conf/artalk.example.zh-CN.yml +++ b/conf/artalk.example.zh-CN.yml @@ -287,6 +287,108 @@ admin_notify: - USER_ID_1 - GROUP_ID_1 +# 社交登录 +auth: + # 启用社交登录 + enabled: false + # 允许匿名评论 (允许跳过验证,仅填写匿名的昵称和邮箱) + anonymous: true + # 回调地址 (https://example.com/api/v2/auth/{provider}/callback) + callback: "http://localhost:23366/api/v2/auth/{provider}/callback" + # Email + email: + # 启用邮箱密码登录 + enabled: true + # 邮箱验证邮件标题 + verify_subject: "您的验证码是 - {{code}}" + # 邮箱验证邮件模板 (填入文件路径使用自定义模板) + verify_tpl: default + # GitHub + github: + enabled: false + client_id: "" + client_secret: "" + # GitLab + gitlab: + enabled: false + client_id: "" + client_secret: "" + # Gitea + gitea: + enabled: false + client_id: "" + client_secret: "" + # Google + google: + enabled: false + client_id: "" + client_secret: "" + # Mastodon + mastodon: + enabled: false + client_id: "" + client_secret: "" + # Twitter + twitter: + enabled: false + client_id: "" + client_secret: "" + # Facebook + facebook: + enabled: false + client_id: "" + client_secret: "" + # Discord + discord: + enabled: false + client_id: "" + client_secret: "" + # Steam + steam: + enabled: false + api_key: "" + # Apple + apple: + enabled: false + client_id: "" + client_secret: "" + # Microsoft + microsoft: + enabled: false + client_id: "" + client_secret: "" + # 微信 + wechat: + enabled: false + client_id: "" + client_secret: "" + # Tiktok + tiktok: + enabled: false + client_id: "" + client_secret: "" + # Slack + slack: + enabled: false + client_id: "" + client_secret: "" + # Line + line: + enabled: false + client_id: "" + client_secret: "" + # Patreon + patreon: + enabled: false + client_id: "" + client_secret: "" + # Auth0 + auth0: + enabled: false + client_id: "" + client_secret: "" + domain: "" + # 界面配置 frontend: # 评论框占位文字 diff --git a/docs/docs/.vitepress/config.ts b/docs/docs/.vitepress/config.ts index d283eba07..1dbb068a8 100644 --- a/docs/docs/.vitepress/config.ts +++ b/docs/docs/.vitepress/config.ts @@ -100,6 +100,7 @@ export default defineConfig({ { text: '侧边栏', link: '/guide/frontend/sidebar.md' }, { text: '邮件通知', link: '/guide/backend/email.md' }, { text: '多元推送', link: '/guide/backend/admin_notify.md' }, + { text: '社交登录', link: '/guide/frontend/auth.md' }, { text: '评论审核', link: '/guide/backend/moderator.md' }, { text: '验证码', link: '/guide/backend/captcha.md' }, { text: '图片上传', link: '/guide/backend/img-upload.md' }, diff --git a/docs/docs/guide/backend/img-upload.md b/docs/docs/guide/backend/img-upload.md index fc9fe1f7a..49d68124b 100644 --- a/docs/docs/guide/backend/img-upload.md +++ b/docs/docs/guide/backend/img-upload.md @@ -6,7 +6,7 @@ Artalk 提供图片上传功能,支持限制图片大小、上传频率等, ## 配置文件 -完整的 `img-upload` 配置如下: +完整的 `img_upload` 配置如下: ```yaml # 图片上传 diff --git a/docs/docs/guide/deploy.md b/docs/docs/guide/deploy.md index 2906be6d5..43b52d43e 100644 --- a/docs/docs/guide/deploy.md +++ b/docs/docs/guide/deploy.md @@ -39,13 +39,13 @@ docker exec -it artalk artalk admin
``` diff --git a/docs/docs/guide/frontend/auth.md b/docs/docs/guide/frontend/auth.md new file mode 100644 index 000000000..30f95a910 --- /dev/null +++ b/docs/docs/guide/frontend/auth.md @@ -0,0 +1,75 @@ +# 社交登录 + +Artalk 默认只需填写昵称和邮箱即可发表评论,无需验证邮箱。 + +但有时候,我们希望用户能够使用社交账号登录,以减少用户填写信息的时间,或者提高用户信息的真实性,可以通过启用社交登录来实现这一目的。 + +社交登录目前支持以下多种方式: + +| 登录方式 | 接入文档 | 登录方式 | 接入文档 | 登录方式 | 接入文档 | +| --- | --- | --- | --- | --- | --- | +| Google | [查看](https://developers.google.com/identity/protocols/oauth2) | Microsoft | [查看](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) | Apple | [查看](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple) | +| Facebook | [查看](https://developers.facebook.com/docs/facebook-login/) | Twitter | [查看](https://developer.twitter.com/en/docs/basics/authentication/overview) | Discord | [查看](https://discord.com/developers/docs/topics/oauth2) | +| Slack | [查看](https://api.slack.com/authentication/oauth-v2) | Github | [查看](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/) | Tiktok | [查看](https://developers.tiktok.com/doc/login) | +| Steam | [查看](https://partner.steamgames.com/doc/webapi_overview/auth) | WeChat | [查看](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html) | Line | [查看](https://developers.line.biz/en/docs/line-login/integrate-line-login/) | +| GitLab | [查看](https://docs.gitlab.com/ee/api/oauth2.html) | Gitea | [查看](https://docs.gitea.io/en-us/oauth2-provider/) | Mastodon | [查看](https://docs.joinmastodon.org/api/authentication/) | +| Patreon | [查看](https://docs.patreon.com/#oauth) | Auth0 | [查看](https://auth0.com/docs/connections/social/) | 邮箱密码 | [查看](#邮箱密码登录) | + +开启社交登录功能仅需在 Artalk 控制中心的设置页面找到「社交登录」选项,首先启用该功能,然后填写对应的配置信息即可。 + +## 邮箱密码登录 + +![邮箱登录](../../images/auth/email_login.png) + +启用邮箱密码登录后,评论框顶部的昵称邮箱输入框将被隐藏,发送按钮将显示为登录按钮。用户点击登录按钮后,将会弹出一个登录框,用户可以输入邮箱和密码登录,登录成功后即可发表评论。并且,用户发表的评论将展示「邮箱已验证」的标识。 + +邮箱已验证标识 + +用户可以通过邮箱注册账号,Artalk 将向用户邮箱发送一封带有验证码的邮件。验证码有效期为 10 分钟,验证码发送频率限制为 1 分钟一次。 + +![邮箱注册](../../images/auth/email_register.png) + +支持自定义验证码邮件模板和邮件标题,可在 Artalk 控制中心的设置页面的社交登录找到「邮箱验证邮件标题」、「邮箱验证邮件模板」选项进行设置。在配置文件中,可以通过 `auth.email.verify_subject` 和 `auth.email.verify_tpl` 进行设置: + +```yaml +auth: + enabled: true + email: + enabled: true + verify_subject: "您的验证码是 - {{code}}" + verify_tpl: default +``` + +默认模版如下: + +```html +您的验证码是:{{code}}。请使用它来验证您的电子邮件并登录到 Artalk。如果您没有请求此操作,请忽略此消息。 +``` + +![跳过登录](../../images/auth/login_skip.png) + +启用邮箱密码登录功能后,仍然可跳过邮箱验证:登录弹窗底部显示 “跳过,不验证” 按钮,点击后评论框顶部恢复为显示原有的昵称、邮箱、网址三个输入框。在设置中勾选「允许匿名评论」可以更改。 + +## 账号合并工具 + +登录后如果检测到相同的邮箱下有多个不同用户名的账号,将会弹出账号合并工具,用户可以选择保留其中一个用户名,该邮箱下的所有评论等数据合并到保留的账号下。原有的账号将被删除,评论显示的用户名将会变更为保留的用户名。 + +![账号合并工具](../../images/auth/merge_accounts.png) + +## 多种登录方式 + +Artalk 支持同时启用多种登录方式,用户可以选择任意一种方式登录。 + +![多种登录方式](../../images/auth/multi_login.png) + +如果只启用了唯一一种登录,例如 GitHub 登录,将直接弹出 GitHub 的授权登录页面。 + +![GitHub 授权弹窗](../../images/auth/github_login.png) + +接入 GitHub 登录可参考文档:[关于创建 GitHub 应用](https://docs.github.com/zh/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps),得到 Client ID 和 Client Secret 后,填写到 Artalk 控制中心的设置页面的社交登录中的「GitHub」选项中即可。 + +## 插件开发 + +Artalk 的社交登录功能是通过独立的插件实现并采用 Solid.js 开发,代码可在 [@ArtalkJS/Artalk:ui/plugin-auth](https://github.com/ArtalkJS/Artalk/tree/master/ui/plugin-auth) 找到。 + +在控制中心启用社交登录功能后,将自动在前端加载该插件。 diff --git a/docs/docs/guide/intro.md b/docs/docs/guide/intro.md index e61c81792..0f04aedea 100644 --- a/docs/docs/guide/intro.md +++ b/docs/docs/guide/intro.md @@ -18,8 +18,9 @@ - Markdown 语法 + 代码高亮 - [通知中心](./frontend/sidebar.md) - 站内:侧边栏 + 红点标记 - - [多形式推送](./backend/admin_notify.md) - 站外:邮件、TG、钉钉、飞书 + 异步执行 + - [多元推送](./backend/admin_notify.md) - 站外:邮件、TG、钉钉、飞书 + 异步执行 - [评论审核](./backend/moderator.md):折叠 / 反垃圾 / 频率限制 / 滑动验证 + - [社交登录](./frontend/auth.md):邮箱密码、GitHub、Google 等多种登录方式 - [多站点](./backend/multi-site.md):共用同一个后端程序,多站点集中化管理 - [表情包](./frontend/emoticons.md):支持 OwO 格式 + 动态加载 - [Artrans](./transfer.md):评论数据快速迁移 (导入 / 导出) 工具 diff --git a/docs/docs/images/auth/email_login.png b/docs/docs/images/auth/email_login.png new file mode 100644 index 000000000..79092f7bb Binary files /dev/null and b/docs/docs/images/auth/email_login.png differ diff --git a/docs/docs/images/auth/email_register.png b/docs/docs/images/auth/email_register.png new file mode 100644 index 000000000..1bbd15e1a Binary files /dev/null and b/docs/docs/images/auth/email_register.png differ diff --git a/docs/docs/images/auth/email_verified.png b/docs/docs/images/auth/email_verified.png new file mode 100644 index 000000000..feb645918 Binary files /dev/null and b/docs/docs/images/auth/email_verified.png differ diff --git a/docs/docs/images/auth/github_login.png b/docs/docs/images/auth/github_login.png new file mode 100644 index 000000000..32f61b072 Binary files /dev/null and b/docs/docs/images/auth/github_login.png differ diff --git a/docs/docs/images/auth/login_skip.png b/docs/docs/images/auth/login_skip.png new file mode 100644 index 000000000..213cb7547 Binary files /dev/null and b/docs/docs/images/auth/login_skip.png differ diff --git a/docs/docs/images/auth/merge_accounts.png b/docs/docs/images/auth/merge_accounts.png new file mode 100644 index 000000000..8bcf99237 Binary files /dev/null and b/docs/docs/images/auth/merge_accounts.png differ diff --git a/docs/docs/images/auth/multi_login.png b/docs/docs/images/auth/multi_login.png new file mode 100644 index 000000000..d98e88dd1 Binary files /dev/null and b/docs/docs/images/auth/multi_login.png differ diff --git a/docs/landing/src/components/Features/FullFeatureList.tsx b/docs/landing/src/components/Features/FullFeatureList.tsx index fa8e5436e..92322c479 100644 --- a/docs/landing/src/components/Features/FullFeatureList.tsx +++ b/docs/landing/src/components/Features/FullFeatureList.tsx @@ -1,9 +1,10 @@ import React from 'react' -import { TbLayoutSidebarRightExpandFilled, TbMailFilled, TbEyeFilled, TbTransformFilled, TbLocationFilled, TbCardsFilled, TbPhotoSearch, TbMath, TbPlug, TbLanguage, TbTerminal, TbApi } from 'react-icons/tb' +import { TbLayoutSidebarRightExpandFilled, TbMailFilled, TbEyeFilled, TbTransformFilled, TbLocationFilled, TbCardsFilled, TbPhotoSearch, TbMath, TbPlug, TbLanguage, TbTerminal, TbApi, TbSocial } from 'react-icons/tb' import { BiSolidNotification, BiSolidBadgeCheck } from 'react-icons/bi' -import { RiRobot2Fill, RiUpload2Fill } from 'react-icons/ri' +import { RiLoader4Fill, RiRobot2Fill, RiUpload2Fill } from 'react-icons/ri' import { BsFillShieldLockFill } from 'react-icons/bs' import { PiSmileyWinkBold } from 'react-icons/pi' +import { GrUpgrade } from 'react-icons/gr' interface FuncItem { icon: React.ReactNode @@ -43,6 +44,12 @@ const FuncList: FuncItem[] = [ desc: '内容检测、垃圾拦截', link: 'https://artalk.js.org/guide/backend/moderator.html' }, + { + icon: , + name: '社交登录', + desc: 'GitHub 等多种登录方式', + link: 'https://artalk.js.org/guide/frontend/auth.html' + }, { icon: , name: '图片上传', @@ -80,10 +87,10 @@ const FuncList: FuncItem[] = [ link: 'https://artalk.js.org/guide/transfer.html' }, { - icon: , - name: 'IP 属地', - desc: '用户 IP 属地展示', - link: 'https://artalk.js.org/guide/frontend/ip-region.html' + icon: , + name: '图片懒加载', + desc: '延迟加载图片,优化体验', + link: 'https://artalk.js.org/guide/frontend/img-lazy-load.html' }, { icon: , @@ -91,6 +98,12 @@ const FuncList: FuncItem[] = [ desc: '快速集成图片灯箱', link: 'https://artalk.js.org/guide/frontend/lightbox.html' }, + { + icon: , + name: 'IP 属地', + desc: '用户 IP 属地展示', + link: 'https://artalk.js.org/guide/frontend/ip-region.html' + }, { icon: , name: 'Latex', @@ -121,6 +134,12 @@ const FuncList: FuncItem[] = [ desc: '提供 OpenAPI 格式文档', link: 'https://artalk.js.org/develop/' }, + { + icon: , + name: '程序升级', + desc: '版本检测,一键升级', + link: 'https://artalk.js.org/guide/backend/update.html' + } ] export const FullFeatureList: React.FC = () => { diff --git a/docs/landing/src/components/Features/FuncFeature.tsx b/docs/landing/src/components/Features/FuncFeature.tsx index 9032a56e8..3950c49d7 100644 --- a/docs/landing/src/components/Features/FuncFeature.tsx +++ b/docs/landing/src/components/Features/FuncFeature.tsx @@ -7,6 +7,7 @@ import { Reveal } from '../Reveal' import { FaArrowRight } from 'react-icons/fa' const FuncGrps: {name: string, items: string[], link?: string}[] = [ + { name: '社交登录', items: ['Github', 'GitLab', 'Twitter', 'Facebook', 'Mastodon', 'Google', 'Microsoft', 'Apple', 'Discord', 'Slack', 'Tiktok', 'Steam'], link: 'https://artalk.js.org/guide/frontend/auth.html' }, { name: '邮箱发送', items: ['SMTP', '阿里云邮件', 'sendmail'], link: 'https://artalk.js.org/guide/backend/email.html' }, { name: '验证码', items: ['Turnstile', 'reCAPTCHA', 'hCaptcha', '极验'], link: 'https://artalk.js.org/guide/backend/captcha.html' }, { name: '消息推送', items: ['Telegram', '飞书', '钉钉', 'Bark', 'WebHook', 'Slack', 'LINE'], link: 'https://artalk.js.org/guide/backend/admin_notify.html' }, diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 2335dafd4..8cb2cc023 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -23,6 +23,367 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/email/login": { + "post": { + "description": "Login by email with verify code (Need send email verify code first) or password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login by email", + "operationId": "LoginByEmail", + "parameters": [ + { + "description": "The data to login", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailLogin" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserLogin" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/email/register": { + "post": { + "description": "Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register by email", + "operationId": "RegisterByEmail", + "parameters": [ + { + "description": "The data to register", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailRegister" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserLogin" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/email/send": { + "post": { + "description": "Send email including verify code to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Send verify email", + "operationId": "SendVerifyEmail", + "parameters": [ + { + "description": "The data", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/merge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all users with same email, if there are more than one user with same email, need merge", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Check data merge", + "operationId": "CheckDataMerge", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseAuthDataMergeCheck" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This function is to solve the problem of multiple users with the same email address, should be called after user login and then check, and perform data merge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Apply data merge", + "operationId": "ApplyDataMerge", + "parameters": [ + { + "description": "The data", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthDataMergeApply" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseAuthDataMergeApply" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/cache/flush": { "post": { "security": [ @@ -787,6 +1148,45 @@ const docTemplate = `{ } } }, + "/conf/auth/providers": { + "get": { + "description": "Get social login providers", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get Social Login Providers", + "operationId": "GetSocialLoginProviders", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseConfAuthProviders" + } + }, + "404": { + "description": "Not Found", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/conf/domain": { "get": { "description": "Get Domain Info", @@ -3150,6 +3550,28 @@ const docTemplate = `{ } }, "definitions": { + "auth.AuthProviderInfo": { + "type": "object", + "required": [ + "icon", + "label", + "name" + ], + "properties": { + "icon": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "common.ApiVersionData": { "type": "object", "required": [ @@ -3927,6 +4349,70 @@ const docTemplate = `{ } } }, + "handler.RequestAuthDataMergeApply": { + "type": "object", + "required": [ + "user_name" + ], + "properties": { + "user_name": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailLogin": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailRegister": { + "type": "object", + "required": [ + "code", + "email", + "password" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailSend": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "handler.ResponseAdminUserList": { "type": "object", "required": [ @@ -3945,6 +4431,52 @@ const docTemplate = `{ } } }, + "handler.ResponseAuthDataMergeApply": { + "type": "object", + "required": [ + "deleted_user_count", + "update_comments_count", + "update_notifies_count", + "update_votes_count", + "user_token" + ], + "properties": { + "deleted_user_count": { + "type": "integer" + }, + "update_comments_count": { + "type": "integer" + }, + "update_notifies_count": { + "type": "integer" + }, + "update_votes_count": { + "type": "integer" + }, + "user_token": { + "description": "Empty if login user is target user no need to re-login", + "type": "string" + } + } + }, + "handler.ResponseAuthDataMergeCheck": { + "type": "object", + "required": [ + "need_merge", + "user_names" + ], + "properties": { + "need_merge": { + "type": "boolean" + }, + "user_names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handler.ResponseCaptchaGet": { "type": "object", "required": [ @@ -4224,6 +4756,24 @@ const docTemplate = `{ } } }, + "handler.ResponseConfAuthProviders": { + "type": "object", + "required": [ + "anonymous", + "providers" + ], + "properties": { + "anonymous": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.AuthProviderInfo" + } + } + } + }, "handler.ResponseConfDomain": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 41da5458e..4a0618201 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -16,6 +16,367 @@ }, "basePath": "/api/v2", "paths": { + "/auth/email/login": { + "post": { + "description": "Login by email with verify code (Need send email verify code first) or password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login by email", + "operationId": "LoginByEmail", + "parameters": [ + { + "description": "The data to login", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailLogin" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserLogin" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/email/register": { + "post": { + "description": "Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register by email", + "operationId": "RegisterByEmail", + "parameters": [ + { + "description": "The data to register", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailRegister" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserLogin" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/email/send": { + "post": { + "description": "Send email including verify code to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Send verify email", + "operationId": "SendVerifyEmail", + "parameters": [ + { + "description": "The data", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthEmailSend" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/auth/merge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all users with same email, if there are more than one user with same email, need merge", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Check data merge", + "operationId": "CheckDataMerge", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseAuthDataMergeCheck" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This function is to solve the problem of multiple users with the same email address, should be called after user login and then check, and perform data merge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Apply data merge", + "operationId": "ApplyDataMerge", + "parameters": [ + { + "description": "The data", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestAuthDataMergeApply" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseAuthDataMergeApply" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/cache/flush": { "post": { "security": [ @@ -780,6 +1141,45 @@ } } }, + "/conf/auth/providers": { + "get": { + "description": "Get social login providers", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "Get Social Login Providers", + "operationId": "GetSocialLoginProviders", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseConfAuthProviders" + } + }, + "404": { + "description": "Not Found", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/conf/domain": { "get": { "description": "Get Domain Info", @@ -3143,6 +3543,28 @@ } }, "definitions": { + "auth.AuthProviderInfo": { + "type": "object", + "required": [ + "icon", + "label", + "name" + ], + "properties": { + "icon": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "common.ApiVersionData": { "type": "object", "required": [ @@ -3920,6 +4342,70 @@ } } }, + "handler.RequestAuthDataMergeApply": { + "type": "object", + "required": [ + "user_name" + ], + "properties": { + "user_name": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailLogin": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailRegister": { + "type": "object", + "required": [ + "code", + "email", + "password" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.RequestAuthEmailSend": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "handler.ResponseAdminUserList": { "type": "object", "required": [ @@ -3938,6 +4424,52 @@ } } }, + "handler.ResponseAuthDataMergeApply": { + "type": "object", + "required": [ + "deleted_user_count", + "update_comments_count", + "update_notifies_count", + "update_votes_count", + "user_token" + ], + "properties": { + "deleted_user_count": { + "type": "integer" + }, + "update_comments_count": { + "type": "integer" + }, + "update_notifies_count": { + "type": "integer" + }, + "update_votes_count": { + "type": "integer" + }, + "user_token": { + "description": "Empty if login user is target user no need to re-login", + "type": "string" + } + } + }, + "handler.ResponseAuthDataMergeCheck": { + "type": "object", + "required": [ + "need_merge", + "user_names" + ], + "properties": { + "need_merge": { + "type": "boolean" + }, + "user_names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handler.ResponseCaptchaGet": { "type": "object", "required": [ @@ -4217,6 +4749,24 @@ } } }, + "handler.ResponseConfAuthProviders": { + "type": "object", + "required": [ + "anonymous", + "providers" + ], + "properties": { + "anonymous": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.AuthProviderInfo" + } + } + } + }, "handler.ResponseConfDomain": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 1898d1bdb..2c9d2a4f0 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -1,5 +1,20 @@ basePath: /api/v2 definitions: + auth.AuthProviderInfo: + properties: + icon: + type: string + label: + type: string + name: + type: string + path: + type: string + required: + - icon + - label + - name + type: object common.ApiVersionData: properties: app: @@ -562,6 +577,48 @@ definitions: description: The username type: string type: object + handler.RequestAuthDataMergeApply: + properties: + user_name: + type: string + required: + - user_name + type: object + handler.RequestAuthEmailLogin: + properties: + code: + type: string + email: + type: string + password: + type: string + required: + - email + type: object + handler.RequestAuthEmailRegister: + properties: + code: + type: string + email: + type: string + link: + type: string + name: + type: string + password: + type: string + required: + - code + - email + - password + type: object + handler.RequestAuthEmailSend: + properties: + email: + type: string + required: + - email + type: object handler.ResponseAdminUserList: properties: count: @@ -574,6 +631,38 @@ definitions: - count - users type: object + handler.ResponseAuthDataMergeApply: + properties: + deleted_user_count: + type: integer + update_comments_count: + type: integer + update_notifies_count: + type: integer + update_votes_count: + type: integer + user_token: + description: Empty if login user is target user no need to re-login + type: string + required: + - deleted_user_count + - update_comments_count + - update_notifies_count + - update_votes_count + - user_token + type: object + handler.ResponseAuthDataMergeCheck: + properties: + need_merge: + type: boolean + user_names: + items: + type: string + type: array + required: + - need_merge + - user_names + type: object handler.ResponseCaptchaGet: properties: img_data: @@ -772,6 +861,18 @@ definitions: - vote_down - vote_up type: object + handler.ResponseConfAuthProviders: + properties: + anonymous: + type: boolean + providers: + items: + $ref: '#/definitions/auth.AuthProviderInfo' + type: array + required: + - anonymous + - providers + type: object handler.ResponseConfDomain: properties: is_trusted: @@ -1134,6 +1235,215 @@ info: title: Artalk API version: "2.0" paths: + /auth/email/login: + post: + consumes: + - application/json + description: Login by email with verify code (Need send email verify code first) + or password + operationId: LoginByEmail + parameters: + - description: The data to login + in: body + name: data + required: true + schema: + $ref: '#/definitions/handler.RequestAuthEmailLogin' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseUserLogin' + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + summary: Login by email + tags: + - Auth + /auth/email/register: + post: + consumes: + - application/json + description: Register by email and verify code (if user exists, will update + user, like forget password. Need send email verify code first) + operationId: RegisterByEmail + parameters: + - description: The data to register + in: body + name: data + required: true + schema: + $ref: '#/definitions/handler.RequestAuthEmailRegister' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseUserLogin' + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + summary: Register by email + tags: + - Auth + /auth/email/send: + post: + consumes: + - application/json + description: Send email including verify code to user + operationId: SendVerifyEmail + parameters: + - description: The data + in: body + name: data + required: true + schema: + $ref: '#/definitions/handler.RequestAuthEmailSend' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + summary: Send verify email + tags: + - Auth + /auth/merge: + get: + description: Get all users with same email, if there are more than one user + with same email, need merge + operationId: CheckDataMerge + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseAuthDataMergeCheck' + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: Check data merge + tags: + - Auth + post: + consumes: + - application/json + description: This function is to solve the problem of multiple users with the + same email address, should be called after user login and then check, and + perform data merge. + operationId: ApplyDataMerge + parameters: + - description: The data + in: body + name: data + required: true + schema: + $ref: '#/definitions/handler.RequestAuthDataMergeApply' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseAuthDataMergeApply' + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: Apply data merge + tags: + - Auth /cache/flush: post: description: Flush all cache on the server @@ -1580,6 +1890,29 @@ paths: summary: Get System Configs tags: - System + /conf/auth/providers: + get: + description: Get social login providers + operationId: GetSocialLoginProviders + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseConfAuthProviders' + "404": + description: Not Found + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + summary: Get Social Login Providers + tags: + - System /conf/domain: get: description: Get Domain Info diff --git a/go.mod b/go.mod index b2d6ee565..1de4be40f 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/jeremywohl/flatten v1.0.1 github.com/knadh/koanf v1.5.0 github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20240419130813-d2b12ef0c81c + github.com/markbates/goth v1.79.0 github.com/mattn/go-colorable v0.1.13 github.com/microcosm-cc/bluemonday v1.0.26 github.com/nikoksr/notify v0.41.0 @@ -53,6 +54,7 @@ require ( ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.23.1 // indirect @@ -64,6 +66,7 @@ require ( github.com/blinkbean/dingtalk v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -74,6 +77,8 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect @@ -94,6 +99,12 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.17.8 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx v1.2.28 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -103,6 +114,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c // indirect github.com/onsi/gomega v1.33.0 // indirect github.com/paulmach/orb v0.11.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect diff --git a/go.sum b/go.sum index 6cb814cda..68141a5b6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= @@ -95,6 +97,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -155,6 +160,8 @@ github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-testfixtures/testfixtures/v3 v3.10.0 h1:BrBwN7AuC+74g5qtk9D59TLGOaEa8Bw1WmIsf+SyzWc= github.com/go-testfixtures/testfixtures/v3 v3.10.0/go.mod h1:z8RoleoNtibi6Ar8ziCW7e6PQ+jWiqbUWvuv8AMe4lo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= @@ -165,6 +172,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= @@ -220,8 +228,14 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -292,6 +306,8 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9j1KzlHaXL09LyMVM9rupS39lncbXk= +github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= @@ -341,6 +357,19 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.28 h1:uadI6o0WpOVrBSf498tRXZIwPpEtLnR9CvqPFXeI5sA= +github.com/lestrrat-go/jwx v1.2.28/go.mod h1:nF+91HEMh/MYFVwKPl5HHsBGMPscqbQb+8IDQdIazP8= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ= @@ -349,6 +378,8 @@ github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20240419130813-d2b12ef0c github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20240419130813-d2b12ef0c81c/go.mod h1:C5LA5UO2ZXJrLaPLYtE1wUJMiyd/nwWaCO5cw/2pSHs= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/goth v1.79.0 h1:fUYi9R6VubVEK2bpmXvIUp7xRcxA68i8ovfUQx/i5Qc= +github.com/markbates/goth v1.79.0/go.mod h1:RBD+tcFnXul2NnYuODhnIweOcuVPkBohLfEvutPekcU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -398,6 +429,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c h1:3wkDRdxK92dF+c1ke2dtj7ZzemFWBHB9plnJOtlwdFA= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nikoksr/notify v0.41.0 h1:4LGE41GpWdHX5M3Xo6DlWRwS2WLDbOq1Rk7IzY4vjmQ= @@ -465,8 +498,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -581,6 +614,7 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -692,6 +726,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -701,6 +736,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/i18n/en.yml b/i18n/en.yml index 0a190259a..a713389e7 100644 --- a/i18n/en.yml +++ b/i18n/en.yml @@ -63,6 +63,8 @@ "Username": "" "Verification failed": "" "Wrong captcha": "" +"Your Code - {{code}}": "" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "" "{{count}} items imported": "" "{{done}} of {{total}} done": "" "{{name}} already exists": "" diff --git a/i18n/fr.yml b/i18n/fr.yml index 5b9f9946a..17c1c69fd 100644 --- a/i18n/fr.yml +++ b/i18n/fr.yml @@ -63,6 +63,8 @@ "Username": "Nom d'utilisateur" "Verification failed": "Échec de la vérification" "Wrong captcha": "Mauvais captcha" +"Your Code - {{code}}": "Votre code - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "Votre code est : {{code}}. Utilisez-le pour vérifier votre e-mail et vous connecter à Artalk. Si vous n'avez pas demandé cela, ignorez simplement ce message." "{{count}} items imported": "{{count}} articles importés" "{{done}} of {{total}} done": "{{done}} de {{total}} fait" "{{name}} already exists": "{{name}} existe déjà" diff --git a/i18n/jp.yml b/i18n/jp.yml index ba7b6efc3..e2b0a9470 100644 --- a/i18n/jp.yml +++ b/i18n/jp.yml @@ -63,6 +63,8 @@ "Username": "ユーザー名" "Verification failed": "検証失敗" "Wrong captcha": "間違ったキャプチャ" +"Your Code - {{code}}": "あなたのコード - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "あなたのコードは: {{code}} です。これを使用してメールを確認し、Artalk にサインインしてください。これをリクエストしていない場合は、このメッセージを単に無視してください。" "{{count}} items imported": "{{count}}アイテムがインポートされました" "{{done}} of {{total}} done": "{{done}} / {{total}} 完了" "{{name}} already exists": "{{name}}はすでに存在します" diff --git a/i18n/ko.yml b/i18n/ko.yml index 49af3a2b3..f2a698096 100644 --- a/i18n/ko.yml +++ b/i18n/ko.yml @@ -63,6 +63,8 @@ "Username": "사용자 이름" "Verification failed": "검증 실패" "Wrong captcha": "잘못된 Captcha" +"Your Code - {{code}}": "당신의 코드 - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "당신의 코드는 다음과 같습니다: {{code}}. 이를 사용하여 이메일을 확인하고 Artalk에 로그인하세요. 요청하지 않은 경우 이 메시지를 무시하십시오." "{{count}} items imported": "{{count}}개 항목 가져옴" "{{done}} of {{total}} done": "{{total}} 중 {{done}} 완료" "{{name}} already exists": "{{name}}이(가) 이미 존재합니다" diff --git a/i18n/ru.yml b/i18n/ru.yml index 1d1ffc7c7..3d55dcfcb 100644 --- a/i18n/ru.yml +++ b/i18n/ru.yml @@ -63,6 +63,8 @@ "Username": "Имя пользователя" "Verification failed": "Ошибка верификации" "Wrong captcha": "Неверная капча" +"Your Code - {{code}}": "Ваш код - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "Ваш код: {{code}}. Используйте его для подтверждения своего адреса электронной почты и входа в Artalk. Если вы не запрашивали это, просто проигнорируйте это сообщение." "{{count}} items imported": "{{count}} элементов импортировано" "{{done}} of {{total}} done": "{{done}} из {{total}} выполнено" "{{name}} already exists": "{{name}} уже существует" diff --git a/i18n/zh-CN.yml b/i18n/zh-CN.yml index 6341c0a54..9038d39bd 100644 --- a/i18n/zh-CN.yml +++ b/i18n/zh-CN.yml @@ -63,6 +63,8 @@ "Username": "用户名" "Verification failed": "验证失败" "Wrong captcha": "验证码错误" +"Your Code - {{code}}": "您的验证码 - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "您的验证码是:{{code}}。请使用它来验证您的电子邮件并登录到 Artalk。如果您没有请求此操作,请忽略此消息。" "{{count}} items imported": "已导入 {{count}} 个项目" "{{done}} of {{total}} done": "已完成 {{done}} 共 {{total}} 个" "{{name}} already exists": "{{name}}已存在" diff --git a/i18n/zh-TW.yml b/i18n/zh-TW.yml index 12dfa5638..ce6b47459 100644 --- a/i18n/zh-TW.yml +++ b/i18n/zh-TW.yml @@ -63,6 +63,8 @@ "Username": "用戶名" "Verification failed": "驗證失敗" "Wrong captcha": "驗證碼錯誤" +"Your Code - {{code}}": "您的代碼 - {{code}}" +"Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.": "您的代碼是:{{code}}。請使用它來驗證您的電子郵件並登錄到Artalk。如果您沒有請求此操作,請忽略此消息。" "{{count}} items imported": "已導入 {{count}} 個項目" "{{done}} of {{total}} done": "已完成 {{done}} 共 {{total}} 個" "{{name}} already exists": "{{name}}已存在" diff --git a/internal/auth/callback.go b/internal/auth/callback.go new file mode 100644 index 000000000..040f7df5e --- /dev/null +++ b/internal/auth/callback.go @@ -0,0 +1,30 @@ +package auth + +import ( + "embed" + "html/template" + + "github.com/gofiber/fiber/v2" +) + +//go:embed callback.html +var callbackPageHTML embed.FS + +func ResponseCallbackPage(c *fiber.Ctx, token string) error { + h, err := callbackPageHTML.ReadFile("callback.html") + if err != nil { + return err + } + t, err := template.New("callback").Parse(string(h)) + if err != nil { + return err + } + + c.Set(fiber.HeaderCacheControl, "no-cache, no-store, must-revalidate") + c.Set(fiber.HeaderPragma, "no-cache") + c.Set(fiber.HeaderExpires, "0") + c.Set(fiber.HeaderContentType, "text/html; charset=utf-8") + return t.Execute(c.Response().BodyWriter(), fiber.Map{ + "token": token, + }) +} diff --git a/internal/auth/callback.html b/internal/auth/callback.html new file mode 100644 index 000000000..dbdd90049 --- /dev/null +++ b/internal/auth/callback.html @@ -0,0 +1,134 @@ + + + + + + Artalk Auth + + + + + +
+
+ + + +
+
+ + diff --git a/internal/auth/gothic_fiber/gothic_fiber.go b/internal/auth/gothic_fiber/gothic_fiber.go new file mode 100644 index 000000000..af97422ba --- /dev/null +++ b/internal/auth/gothic_fiber/gothic_fiber.go @@ -0,0 +1,358 @@ +// This package is adapted from the `github.com/markbates/goth/gothic` +// to work with the Fiber web framework. +package gothic_fiber + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "github.com/markbates/goth" +) + +const SessionName = "_gothic_session" + +// Session can/should be set by applications using gothic. The default is a cookie store. +var ( + SessionStore *session.Store + ErrSessionNil = errors.New("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store") +) + +func init() { + // optional config + config := session.Config{ + KeyLookup: fmt.Sprintf("cookie:%s", SessionName), + Expiration: 10 * time.Minute, // as short as possible for security + CookieSameSite: "Lax", + CookieHTTPOnly: true, + // CookieSecure: true, // TODO: HTTPS only, dev mode should be false + } + + SessionStore = session.New(config) +} + +// BeginAuthHandler is a convenience handler for starting the authentication process. +// It expects to be able to get the name of the provider from the query parameters +// as either "provider" or ":provider". + +// BeginAuthHandler will redirect the user to the appropriate authentication end-point +// for the requested provider. + +// See https://github.com/markbates/goth/examples/main.go to see this in action. +func BeginAuthHandler(ctx *fiber.Ctx) error { + url, err := GetAuthURL(ctx) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + return ctx.Redirect(url, fiber.StatusTemporaryRedirect) +} + +// SetState sets the state string associated with the given request. +// If no state string is associated with the request, one will be generated. +// This state is sent to the provider and can be retrieved during the +// callback. +func SetState(ctx *fiber.Ctx) string { + state := ctx.Query("state") + if len(state) > 0 { + return state + } + + // If a state query param is not passed in, generate a random + // base64-encoded nonce so that the state on the auth URL + // is unguessable, preventing CSRF attacks, as described in + // + // https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading + nonceBytes := make([]byte, 64) + _, err := io.ReadFull(rand.Reader, nonceBytes) + if err != nil { + panic("gothic: source of randomness unavailable: " + err.Error()) + } + return base64.URLEncoding.EncodeToString(nonceBytes) +} + +// GetState gets the state returned by the provider during the callback. +// This is used to prevent CSRF attacks, see +// http://tools.ietf.org/html/rfc6749#section-10.12 +func GetState(ctx *fiber.Ctx) string { + return ctx.Query("state") +} + +// GetAuthURL starts the authentication process with the requested provided. +// It will return a URL that should be used to send users to. + +// It expects to be able to get the name of the provider from the query parameters +// as either "provider" or ":provider". + +// I would recommend using the BeginAuthHandler instead of doing all of these steps +// yourself, but that's entirely up to you. +func GetAuthURL(ctx *fiber.Ctx) (string, error) { + if SessionStore == nil { + return "", ErrSessionNil + } + + providerName, err := GetProviderName(ctx) + if err != nil { + return "", err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return "", err + } + + sess, err := provider.BeginAuth(SetState(ctx)) + if err != nil { + return "", err + } + + url, err := sess.GetAuthURL() + if err != nil { + return "", err + } + + err = StoreInSession(providerName, sess.Marshal(), ctx) + if err != nil { + return "", err + } + + return url, err +} + +// Options that affect how CompleteUserAuth works. +type CompleteUserAuthOptions struct { + // True if CompleteUserAuth should automatically end the user's session. + // + // Defaults to True. + ShouldLogout bool +} + +// CompleteUserAuth does what it says on the tin. It completes the authentication +// process and fetches all of the basic information about the user from the provider. + +// It expects to be able to get the name of the provider from the query parameters +// as either "provider" or ":provider". + +// This method automatically ends the session. You can prevent this behavior by +// passing in options. Please note that any options provided in addition to the +// first will be ignored. + +// See https://github.com/markbates/goth/examples/main.go to see this in action. +func CompleteUserAuth(ctx *fiber.Ctx, options ...CompleteUserAuthOptions) (goth.User, error) { + if SessionStore == nil { + return goth.User{}, ErrSessionNil + } + + providerName, err := GetProviderName(ctx) + if err != nil { + return goth.User{}, err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return goth.User{}, err + } + + value, err := GetFromSession(providerName, ctx) + if err != nil { + return goth.User{}, err + } + + shouldLogout := true + if len(options) > 0 && !options[0].ShouldLogout { + shouldLogout = false + } + + if shouldLogout { + defer Logout(ctx) + } + + sess, err := provider.UnmarshalSession(value) + if err != nil { + return goth.User{}, err + } + + err = validateState(ctx, sess) + if err != nil { + return goth.User{}, err + } + + user, err := provider.FetchUser(sess) + if err == nil { + // user can be found with existing session data + return user, err + } + + reqURL, err := url.Parse(ctx.Request().URI().String()) + if err != nil { + return goth.User{}, err + } + + // get new token and retry fetch + _, err = sess.Authorize(provider, reqURL.Query()) + if err != nil { + return goth.User{}, err + } + + err = StoreInSession(providerName, sess.Marshal(), ctx) + + if err != nil { + return goth.User{}, err + } + + gu, err := provider.FetchUser(sess) + return gu, err +} + +// validateState ensures that the state token param from the original +// AuthURL matches the one included in the current (callback) request. +func validateState(ctx *fiber.Ctx, sess goth.Session) error { + rawAuthURL, err := sess.GetAuthURL() + if err != nil { + return err + } + + authURL, err := url.Parse(rawAuthURL) + if err != nil { + return err + } + + originalState := authURL.Query().Get("state") + if originalState != "" && (originalState != ctx.Query("state")) { + return errors.New("state token mismatch") + } + return nil +} + +// Logout invalidates a user session. +func Logout(ctx *fiber.Ctx) error { + session, err := SessionStore.Get(ctx) + if err != nil { + return err + } + + if err := session.Destroy(); err != nil { + return err + } + + return nil +} + +// GetProviderName is a function used to get the name of a provider +// for a given request. By default, this provider is fetched from +// the URL query string. If you provide it in a different way, +// assign your own function to this variable that returns the provider +// name for your request. +func GetProviderName(ctx *fiber.Ctx) (string, error) { + // try to get it from the url param "provider" + if p := ctx.Query("provider"); p != "" { + return p, nil + } + + // try to get it from the url param ":provider" + if p := ctx.Params("provider"); p != "" { + return p, nil + } + + // try to get it from the Fasthttp context's value of "provider" key + if p := ctx.Get("provider", ""); p != "" { + return p, nil + } + + // As a fallback, loop over the used providers, if we already have a valid session for any provider (ie. user has already begun authentication with a provider), then return that provider name + providers := goth.GetProviders() + session, err := SessionStore.Get(ctx) + if err != nil { + return "", err + // or panic? + } + + for _, provider := range providers { + p := provider.Name() + value := session.Get(p) + if _, ok := value.(string); ok { + return p, nil + } + } + + // if not found then return an empty string with the corresponding error + return "", errors.New("you must select a provider") +} + +// StoreInSession stores a specified key/value pair in the session. +func StoreInSession(key string, value string, ctx *fiber.Ctx) error { + session, err := SessionStore.Get(ctx) + if err != nil { + return err + } + + if err := updateSessionValue(session, key, value); err != nil { + return err + } + + // saved here + session.Save() + return nil +} + +// GetFromSession retrieves a previously-stored value from the session. +// If no value has previously been stored at the specified key, it will return an error. +func GetFromSession(key string, ctx *fiber.Ctx) (string, error) { + session, err := SessionStore.Get(ctx) + if err != nil { + return "", err + } + + value, err := getSessionValue(session, key) + if err != nil { + return "", errors.New("could not find a matching session for this request") + } + + return value, nil +} + +func getSessionValue(store *session.Session, key string) (string, error) { + value := store.Get(key) + if value == nil { + return "", errors.New("could not find a matching session for this request") + } + + rdata := strings.NewReader(value.(string)) + r, err := gzip.NewReader(rdata) + if err != nil { + return "", err + } + s, err := io.ReadAll(r) + if err != nil { + return "", err + } + + return string(s), nil +} + +func updateSessionValue(session *session.Session, key, value string) error { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write([]byte(value)); err != nil { + return err + } + if err := gz.Flush(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + + session.Set(key, b.String()) + + return nil +} diff --git a/internal/auth/icons.go b/internal/auth/icons.go new file mode 100644 index 000000000..23fe281cc --- /dev/null +++ b/internal/auth/icons.go @@ -0,0 +1,17 @@ +package auth + +import ( + "embed" + "encoding/base64" +) + +//go:embed icons/* +var iconsFS embed.FS + +func GetProviderIconBase64(provider string) string { + buf, err := iconsFS.ReadFile("icons/" + provider + ".svg") + if err != nil { + return "" + } + return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(buf) +} diff --git a/internal/auth/icons/apple.svg b/internal/auth/icons/apple.svg new file mode 100644 index 000000000..d6ae15973 --- /dev/null +++ b/internal/auth/icons/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/auth0.svg b/internal/auth/icons/auth0.svg new file mode 100644 index 000000000..4536d2f76 --- /dev/null +++ b/internal/auth/icons/auth0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/discord.svg b/internal/auth/icons/discord.svg new file mode 100644 index 000000000..71b9fdac9 --- /dev/null +++ b/internal/auth/icons/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/email.svg b/internal/auth/icons/email.svg new file mode 100644 index 000000000..ca2e2b89f --- /dev/null +++ b/internal/auth/icons/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/facebook.svg b/internal/auth/icons/facebook.svg new file mode 100644 index 000000000..b4949ee4e --- /dev/null +++ b/internal/auth/icons/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/gitea.svg b/internal/auth/icons/gitea.svg new file mode 100644 index 000000000..cb32909ca --- /dev/null +++ b/internal/auth/icons/gitea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/github.svg b/internal/auth/icons/github.svg new file mode 100644 index 000000000..ff7b608f5 --- /dev/null +++ b/internal/auth/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/gitlab.svg b/internal/auth/icons/gitlab.svg new file mode 100644 index 000000000..77c859696 --- /dev/null +++ b/internal/auth/icons/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/google.svg b/internal/auth/icons/google.svg new file mode 100644 index 000000000..95222f423 --- /dev/null +++ b/internal/auth/icons/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/line.svg b/internal/auth/icons/line.svg new file mode 100644 index 000000000..7167991f0 --- /dev/null +++ b/internal/auth/icons/line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/mastodon.svg b/internal/auth/icons/mastodon.svg new file mode 100644 index 000000000..754ee908d --- /dev/null +++ b/internal/auth/icons/mastodon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/microsoft.svg b/internal/auth/icons/microsoft.svg new file mode 100644 index 000000000..ba5449e66 --- /dev/null +++ b/internal/auth/icons/microsoft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/patreon.svg b/internal/auth/icons/patreon.svg new file mode 100644 index 000000000..55b1b68a4 --- /dev/null +++ b/internal/auth/icons/patreon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/slack.svg b/internal/auth/icons/slack.svg new file mode 100644 index 000000000..ecc77a354 --- /dev/null +++ b/internal/auth/icons/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/steam.svg b/internal/auth/icons/steam.svg new file mode 100644 index 000000000..93b1045f9 --- /dev/null +++ b/internal/auth/icons/steam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/telegram.svg b/internal/auth/icons/telegram.svg new file mode 100644 index 000000000..63b3d2664 --- /dev/null +++ b/internal/auth/icons/telegram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/tiktok.svg b/internal/auth/icons/tiktok.svg new file mode 100644 index 000000000..5f61b37ad --- /dev/null +++ b/internal/auth/icons/tiktok.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/twitter.svg b/internal/auth/icons/twitter.svg new file mode 100644 index 000000000..547be8155 --- /dev/null +++ b/internal/auth/icons/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/icons/wechat.svg b/internal/auth/icons/wechat.svg new file mode 100644 index 000000000..cb5eb9a3e --- /dev/null +++ b/internal/auth/icons/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/auth/info.go b/internal/auth/info.go new file mode 100644 index 000000000..d86541da1 --- /dev/null +++ b/internal/auth/info.go @@ -0,0 +1,42 @@ +package auth + +import ( + "strings" + + "github.com/ArtalkJS/Artalk/internal/config" + "github.com/markbates/goth" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type AuthProviderInfo struct { + Name string `json:"name" validate:"required"` + Label string `json:"label" validate:"required"` + Path string `json:"path" validate:"optional"` + Icon string `json:"icon" validate:"required"` +} + +func GetProviderInfo(conf *config.Config, providers []goth.Provider) []AuthProviderInfo { + var info []AuthProviderInfo + + // Email + if conf.Auth.Email.Enabled { + info = append(info, AuthProviderInfo{ + Name: "email", + Label: "Email", + Icon: GetProviderIconBase64("email"), + }) + } + + for _, provider := range providers { + name := strings.ToLower(provider.Name()) + info = append(info, AuthProviderInfo{ + Name: name, + Label: cases.Title(language.Und, cases.NoLower).String(name), + Path: "/api/v2/auth/" + name, + Icon: GetProviderIconBase64(name), + }) + } + + return info +} diff --git a/internal/auth/providers.go b/internal/auth/providers.go new file mode 100644 index 000000000..6dbed2582 --- /dev/null +++ b/internal/auth/providers.go @@ -0,0 +1,114 @@ +package auth + +import ( + "fmt" + "net/url" + "strings" + + "github.com/ArtalkJS/Artalk/internal/config" + "github.com/ArtalkJS/Artalk/internal/log" + "github.com/markbates/goth" + "github.com/markbates/goth/providers/apple" + "github.com/markbates/goth/providers/auth0" + "github.com/markbates/goth/providers/discord" + "github.com/markbates/goth/providers/facebook" + "github.com/markbates/goth/providers/gitea" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/gitlab" + "github.com/markbates/goth/providers/google" + "github.com/markbates/goth/providers/line" + "github.com/markbates/goth/providers/mastodon" + "github.com/markbates/goth/providers/patreon" + "github.com/markbates/goth/providers/slack" + "github.com/markbates/goth/providers/steam" + "github.com/markbates/goth/providers/tiktok" + "github.com/markbates/goth/providers/twitter" + "github.com/markbates/goth/providers/wechat" + "github.com/samber/lo" +) + +func GetProviders(conf *config.Config) []goth.Provider { + providers := []goth.Provider{} + + u, _ := url.Parse(conf.Auth.Callback) + origin := u.Scheme + "://" + u.Host + + callbackURL := func(provider string) string { + log.Debug("[SocialLogin] Callback URL: ", fmt.Sprintf("%s/api/v2/auth/%s/callback", origin, provider)) + return fmt.Sprintf("%s/api/v2/auth/%s/callback", origin, provider) + } + + // @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps + if githubConf := conf.Auth.Github; githubConf.Enabled { + providers = append(providers, github.New(githubConf.ClientID, githubConf.ClientSecret, callbackURL("github"), + "read:user", "user:email")) + } + // @see https://docs.gitlab.com/ee/integration/oauth_provider.html + if gitlabConf := conf.Auth.Gitlab; gitlabConf.Enabled { + providers = append(providers, gitlab.New(gitlabConf.ClientID, gitlabConf.ClientSecret, callbackURL("gitlab"))) + } + // @see https://docs.gitea.io/en-us/oauth2-provider/ + if giteaConf := conf.Auth.Gitea; giteaConf.Enabled { + providers = append(providers, gitea.New(giteaConf.ClientID, giteaConf.ClientSecret, callbackURL("gitea"))) + } + // @see https://developers.google.com/identity/protocols/oauth2 + if googleConf := conf.Auth.Google; googleConf.Enabled { + providers = append(providers, google.New(googleConf.ClientID, googleConf.ClientSecret, callbackURL("google"))) + } + // @see https://docs.joinmastodon.org/spec/oauth/ + if mastodonConf := conf.Auth.Mastodon; mastodonConf.Enabled { + providers = append(providers, mastodon.New(mastodonConf.ClientID, mastodonConf.ClientSecret, callbackURL("mastodon"))) + } + // @see https://developer.twitter.com/en/docs/authentication/oauth-2-0 + if twitterConf := conf.Auth.Twitter; twitterConf.Enabled { + providers = append(providers, twitter.New(twitterConf.ClientID, twitterConf.ClientSecret, callbackURL("twitter"))) + } + // @see https://developers.facebook.com/docs/facebook-login + if facebookConf := conf.Auth.Facebook; facebookConf.Enabled { + providers = append(providers, facebook.New(facebookConf.ClientID, facebookConf.ClientSecret, callbackURL("facebook"))) + } + // @see https://discord.com/developers/docs/topics/oauth2 + if discordConf := conf.Auth.Discord; discordConf.Enabled { + providers = append(providers, discord.New(discordConf.ClientID, discordConf.ClientSecret, callbackURL("discord"), + discord.ScopeIdentify, discord.ScopeEmail)) + } + // @see https://developer.valvesoftware.com/wiki/Steam_Web_API + if steamConf := conf.Auth.Steam; steamConf.Enabled { + providers = append(providers, steam.New(steamConf.ApiKey, callbackURL("steam"))) + } + // @see https://developer.apple.com/documentation/sign_in_with_apple + if appleConf := conf.Auth.Apple; appleConf.Enabled { + providers = append(providers, apple.New(appleConf.ClientID, appleConf.ClientSecret, callbackURL("apple"), nil, + apple.ScopeEmail, apple.ScopeName)) + } + // @see https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html + if wechatConf := conf.Auth.Wechat; wechatConf.Enabled { + providers = append(providers, wechat.New(wechatConf.ClientID, wechatConf.ClientSecret, callbackURL("wechat"), + lo.If(strings.HasPrefix(conf.Locale, "zh"), wechat.WECHAT_LANG_CN).Else(wechat.WECHAT_LANG_EN), + )) + } + // @see https://developers.tiktok.com/ + if tiktokConf := conf.Auth.Tiktok; tiktokConf.Enabled { + providers = append(providers, tiktok.New(tiktokConf.ClientID, tiktokConf.ClientSecret, callbackURL("tiktok"))) + } + // @see https://api.slack.com/authentication/oauth-v2 + if slackConf := conf.Auth.Slack; slackConf.Enabled { + providers = append(providers, slack.New(slackConf.ClientID, slackConf.ClientSecret, callbackURL("slack"))) + } + // @see https://developers.line.biz/en/docs/line-login/integrate-line-login/ + if lineConf := conf.Auth.Line; lineConf.Enabled { + providers = append(providers, line.New(lineConf.ClientID, lineConf.ClientSecret, callbackURL("line"))) + } + // @see https://www.patreon.com/portal/registration + if patreonConf := conf.Auth.Patreon; patreonConf.Enabled { + providers = append(providers, patreon.New(patreonConf.ClientID, patreonConf.ClientSecret, callbackURL("patreon"), + patreon.ScopeIdentityEmail)) + } + // @see https://auth0.com/docs/api/authentication + if auth0Conf := conf.Auth.Auth0; auth0Conf.Enabled { + providers = append(providers, auth0.New(auth0Conf.ClientID, auth0Conf.ClientSecret, callbackURL("auth0"), + auth0Conf.Domain)) + } + + return providers +} diff --git a/internal/auth/social_register.go b/internal/auth/social_register.go new file mode 100644 index 000000000..5dcc63468 --- /dev/null +++ b/internal/auth/social_register.go @@ -0,0 +1,44 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/ArtalkJS/Artalk/internal/dao" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/utils" +) + +func RegisterSocialUser(dao *dao.Dao, u SocialUser) (entity.AuthIdentity, error) { + if u.Name == "" { + return entity.AuthIdentity{}, fmt.Errorf("name is required") + } + if u.Email == "" { + return entity.AuthIdentity{}, fmt.Errorf("email is required") + } + if !utils.ValidateEmail(u.Email) { + return entity.AuthIdentity{}, fmt.Errorf("email is invalid") + } + + // Create user if not exists + user, err := dao.FindCreateUser(u.Name, u.Email, u.Link) + if err != nil { + return entity.AuthIdentity{}, err + } + + // Store user auth identity in db + now := time.Now() + authIdentity := entity.AuthIdentity{ + Provider: u.Provider, + RemoteUID: u.RemoteUID, + UserID: user.ID, + Token: u.AccessToken, + ConfirmedAt: &now, + ExpiresAt: &u.ExpiresAt, + } + if err := dao.CreateAuthIdentity(&authIdentity); err != nil { + return entity.AuthIdentity{}, err + } + + return authIdentity, nil +} diff --git a/internal/auth/social_user.go b/internal/auth/social_user.go new file mode 100644 index 000000000..232fb4bdf --- /dev/null +++ b/internal/auth/social_user.go @@ -0,0 +1,35 @@ +package auth + +import "github.com/markbates/goth" + +type SocialUser struct { + goth.User + RemoteUID string + Link string +} + +func GetSocialUser(u goth.User) SocialUser { + var link string + if u.Provider == "github" { + if l, ok := u.RawData["blog"].(string); ok && l != "" { + link = l + } else if l, ok := u.RawData["html_url"].(string); ok && l != "" { + link = l + } + } + + // Email patch + if u.Provider == "steam" { + // @see https://stackoverflow.com/questions/31571267/steam-get-users-email-address + u.Email = u.UserID + "@steam.com" + } + if u.Email == "" { + u.Email = u.UserID + "@" + u.Provider + ".com" + } + + return SocialUser{ + User: u, + RemoteUID: u.UserID, + Link: link, + } +} diff --git a/internal/config/base.go b/internal/config/base.go index 6d99a7072..9754a4419 100644 --- a/internal/config/base.go +++ b/internal/config/base.go @@ -141,6 +141,13 @@ func (conf *Config) normalPatch() { } else { *conf.HTTP.ProxyHeader = strings.TrimSpace(*conf.HTTP.ProxyHeader) } + + // 社交登录配置 + if conf.Auth.Enabled && strings.TrimSpace(conf.Auth.Callback) == "" { + callbackURL := "http://localhost:23366/api/v2/auth/:provider/callback" + log.Warn("[SocialLogin] config `auth.callback` is not set, now it is: ", strconv.Quote(callbackURL)) + conf.Auth.Callback = callbackURL + } } // 多语言配置修补 diff --git a/internal/config/config.go b/internal/config/config.go index 8f838ec68..75a329702 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { IPRegion IPRegionConf `koanf:"ip_region" json:"ip_region"` // IP 属地展示 ImgUpload ImgUploadConf `koanf:"img_upload" json:"img_upload"` // 图片上传 AdminNotify AdminNotifyConf `koanf:"admin_notify" json:"admin_notify"` // 其他通知方式 + Auth AuthConf `koanf:"auth" json:"auth"` // Social Login Frontend map[string]interface{} `koanf:"frontend" json:"frontend"` // deprecated options @@ -355,3 +356,101 @@ type NotifyWebHookConf struct { Enabled bool `koanf:"enabled" json:"enabled"` URL string `koanf:"url" json:"url"` } + +type AuthEmailConf struct { + Enabled bool `koanf:"enabled" json:"enabled"` + VerifySubject string `koanf:"verify_subject" json:"verify_subject"` + VerifyTpl string `koanf:"verify_tpl" json:"verify_tpl"` +} + +type AuthConf struct { + Enabled bool `koanf:"enabled" json:"enabled"` + Anonymous bool `koanf:"anonymous" json:"anonymous"` + Callback string `koanf:"callback" json:"callback"` + Email AuthEmailConf `koanf:"email" json:"email"` + Github struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"github" json:"github"` + Gitlab struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"gitlab" json:"gitlab"` + Gitea struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"gitea" json:"gitea"` + Google struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"google" json:"google"` + Mastodon struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"mastodon" json:"mastodon"` + Twitter struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"twitter" json:"twitter"` + Facebook struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"facebook" json:"facebook"` + Discord struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"discord" json:"discord"` + Steam struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ApiKey string `koanf:"api_key" json:"api_key"` + } `koanf:"steam" json:"steam"` + Apple struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"apple" json:"apple"` + Microsoft struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"microsoft" json:"microsoft"` + Wechat struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"wechat" json:"wechat"` + Tiktok struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"tiktok" json:"tiktok"` + Slack struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"slack" json:"slack"` + Line struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"line" json:"line"` + Patreon struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + } `koanf:"patreon" json:"patreon"` + Auth0 struct { + Enabled bool `koanf:"enabled" json:"enabled"` + ClientID string `koanf:"client_id" json:"client_id"` + ClientSecret string `koanf:"client_secret" json:"client_secret"` + Domain string `koanf:"domain" json:"domain"` + } `koanf:"auth0" json:"auth0"` +} diff --git a/internal/dao/migrate.go b/internal/dao/migrate.go index f58f23125..5d5fb2543 100644 --- a/internal/dao/migrate.go +++ b/internal/dao/migrate.go @@ -17,6 +17,7 @@ func (dao *Dao) MigrateModels() { // Migrate the schema dao.DB().AutoMigrate(&entity.Site{}, &entity.Page{}, &entity.User{}, + &entity.AuthIdentity{}, &entity.UserEmailVerify{}, &entity.Comment{}, &entity.Notify{}, &entity.Vote{}) // Delete all foreign key constraints diff --git a/internal/dao/query_del.go b/internal/dao/query_del.go index 33f0eed82..626305905 100644 --- a/internal/dao/query_del.go +++ b/internal/dao/query_del.go @@ -119,3 +119,17 @@ func (dao *Dao) DelUser(user *entity.User) error { return nil } + +func (dao *Dao) DelAuthIdentity(authIdentity *entity.AuthIdentity) error { + err := dao.DB().Unscoped().Delete(&authIdentity).Error + if err != nil { + return err + } + + // TODO 删除缓存 + // dao.CacheAction(func(cache *DaoCache) { + // cache.AuthIdentityCacheDel(authIdentity) + // }) + + return nil +} diff --git a/internal/dao/query_find.go b/internal/dao/query_find.go index 4471a29ab..004265667 100644 --- a/internal/dao/query_find.go +++ b/internal/dao/query_find.go @@ -277,3 +277,21 @@ func (dao *Dao) IsAdminUserByNameEmail(name string, email string) bool { } //#endregion + +func (dao *Dao) FindAuthIdentityByToken(provider string, token string) entity.AuthIdentity { + var identity entity.AuthIdentity + dao.DB().Where("provider = ? AND token = ?", provider, token).First(&identity) + return identity +} + +func (dao *Dao) FindAuthIdentityByRemoteUID(provider string, remoteUID string) entity.AuthIdentity { + var identity entity.AuthIdentity + dao.DB().Where("provider = ? AND remote_uid = ?", provider, remoteUID).First(&identity) + return identity +} + +func (dao *Dao) FindAuthIdentityByUserID(provider string, userID uint) entity.AuthIdentity { + var identity entity.AuthIdentity + dao.DB().Where("provider = ? AND user_id = ?", provider, userID).First(&identity) + return identity +} diff --git a/internal/dao/query_new.go b/internal/dao/query_new.go index bc8674b93..e1b72b624 100644 --- a/internal/dao/query_new.go +++ b/internal/dao/query_new.go @@ -139,3 +139,17 @@ func (dao *Dao) NewVote(targetID uint, voteType entity.VoteType, userID uint, ua return vote, err } + +func (dao *Dao) CreateAuthIdentity(identity *entity.AuthIdentity) error { + err := dao.DB().Create(&identity).Error + if err != nil { + return err + } + + // TODO + // dao.CacheAction(func(cache *DaoCache) { + // cache.AuthIdentityCacheSave(identity) + // }) + + return nil +} diff --git a/internal/dao/query_update.go b/internal/dao/query_update.go index 6c44ce5e2..38720839b 100644 --- a/internal/dao/query_update.go +++ b/internal/dao/query_update.go @@ -68,3 +68,15 @@ func (dao *Dao) UserNotifyMarkAllAsRead(userID uint) error { return nil } + +func (dao *Dao) UpdateAuthIdentity(authIdentity *entity.AuthIdentity) error { + err := dao.DB().Save(authIdentity).Error + if err != nil { + log.Error("Update AuthIdentity error: ", err) + } + // TODO: 更新缓存 + // dao.CacheAction(func(cache *DaoCache) { + // cache.AuthIdentityCacheSave(authIdentity) + // }) + return err +} diff --git a/internal/entity/auth_identity.go b/internal/entity/auth_identity.go new file mode 100644 index 000000000..b44b2a015 --- /dev/null +++ b/internal/entity/auth_identity.go @@ -0,0 +1,21 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +type AuthIdentity struct { + gorm.Model + Provider string // local, email, oauth, github + RemoteUID string `gorm:"column:remote_uid;index;size:255"` + UserID uint `gorm:"index"` + Token string + ConfirmedAt *time.Time + ExpiresAt *time.Time +} + +func (n AuthIdentity) IsEmpty() bool { + return n.ID == 0 +} diff --git a/internal/entity/user_email_verify.go b/internal/entity/user_email_verify.go new file mode 100644 index 000000000..005ab0f9d --- /dev/null +++ b/internal/entity/user_email_verify.go @@ -0,0 +1,16 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +type UserEmailVerify struct { + gorm.Model + Email string `gorm:"index;size:255"` + Code string + ExpiresAt time.Time + IP string + UA string +} diff --git a/package.json b/package.json index 6bb3dbf89..5b80ac8c5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:sidebar": "pnpm -F @artalk/artalk-sidebar dev", "build": "pnpm -F artalk build", "build:sidebar": "pnpm -F @artalk/artalk-sidebar build", + "build:auth": "pnpm -F @artalk/plugin-auth build", "build:all": "pnpm build && pnpm build:sidebar", "build:docs": "pnpm build && pnpm -F=docs-landing build && pnpm -F=docs-swagger swagger:build && pnpm -F=docs build:docs && pnpm patch:docs", "patch:docs": "cp -rf docs/landing/dist/* docs/swagger/dist/* docs/docs/.vitepress/dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0231c3ded..f3758b7fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,22 @@ importers: specifier: ^2.0.11 version: 2.0.11(typescript@5.4.4) + ui/plugin-auth: + dependencies: + artalk: + specifier: workspace:^ + version: link:../artalk + solid-js: + specifier: ^1.8.14 + version: 1.8.17 + devDependencies: + vite-plugin-css-injected-by-js: + specifier: ^3.4.0 + version: 3.5.1(vite@5.2.8) + vite-plugin-solid: + specifier: ^2.9.1 + version: 2.10.2(solid-js@1.8.17)(vite@5.2.8) + ui/plugin-katex: dependencies: artalk: @@ -501,6 +517,122 @@ packages: picocolors: 1.0.0 dev: true + /@babel/compat-data@7.24.4: + resolution: {integrity: sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.24.5: + resolution: {integrity: sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.5 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-module-transforms': 7.24.5(@babel/core@7.24.5) + '@babel/helpers': 7.24.5 + '@babel/parser': 7.24.5 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.5 + '@babel/types': 7.24.5 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.24.5: + resolution: {integrity: sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.5 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: true + + /@babel/helper-compilation-targets@7.23.6: + resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/helper-validator-option': 7.23.5 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-environment-visitor@7.22.20: + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-function-name@7.23.0: + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.5 + dev: true + + /@babel/helper-hoist-variables@7.22.5: + resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.5 + dev: true + + /@babel/helper-module-imports@7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + + /@babel/helper-module-transforms@7.24.5(@babel/core@7.24.5): + resolution: {integrity: sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.24.5 + '@babel/helper-split-export-declaration': 7.24.5 + '@babel/helper-validator-identifier': 7.24.5 + dev: true + + /@babel/helper-plugin-utils@7.24.5: + resolution: {integrity: sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-simple-access@7.24.5: + resolution: {integrity: sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.5 + dev: true + + /@babel/helper-split-export-declaration@7.24.5: + resolution: {integrity: sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.5 + dev: true + /@babel/helper-string-parser@7.24.1: resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} engines: {node: '>=6.9.0'} @@ -509,6 +641,27 @@ packages: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} + /@babel/helper-validator-identifier@7.24.5: + resolution: {integrity: sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.23.5: + resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.24.5: + resolution: {integrity: sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.5 + '@babel/types': 7.24.5 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/highlight@7.24.2: resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} engines: {node: '>=6.9.0'} @@ -526,6 +679,24 @@ packages: dependencies: '@babel/types': 7.24.0 + /@babel/parser@7.24.5: + resolution: {integrity: sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.5 + dev: true + + /@babel/plugin-syntax-jsx@7.24.1(@babel/core@7.24.5): + resolution: {integrity: sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.5 + '@babel/helper-plugin-utils': 7.24.5 + dev: true + /@babel/runtime@7.24.4: resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==} engines: {node: '>=6.9.0'} @@ -533,6 +704,33 @@ packages: regenerator-runtime: 0.14.1 dev: true + /@babel/template@7.24.0: + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.5 + '@babel/types': 7.24.5 + dev: true + + /@babel/traverse@7.24.5: + resolution: {integrity: sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.24.5 + '@babel/parser': 7.24.5 + '@babel/types': 7.24.5 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types@7.24.0: resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} engines: {node: '>=6.9.0'} @@ -541,6 +739,15 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@babel/types@7.24.5: + resolution: {integrity: sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.24.5 + to-fast-properties: 2.0.0 + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -1679,6 +1886,35 @@ packages: resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} dev: true + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.5 + dev: true + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.24.0 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + dev: true + + /@types/babel__traverse@7.20.5: + resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true @@ -2560,6 +2796,28 @@ packages: possible-typed-array-names: 1.0.0 dev: true + /babel-plugin-jsx-dom-expressions@0.37.16(@babel/core@7.24.5): + resolution: {integrity: sha512-ItMD16axbk+FqVb9vIbc7AOpNowy46VaSUHaMYPn+erPGpMCxsahQ1Iv+qhPMthjxtn5ROVMZ5AJtQvzjxjiNA==} + peerDependencies: + '@babel/core': ^7.20.12 + dependencies: + '@babel/core': 7.24.5 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.5) + '@babel/types': 7.24.0 + html-entities: 2.3.3 + validate-html-nesting: 1.2.2 + dev: true + + /babel-preset-solid@1.8.12(@babel/core@7.24.5): + resolution: {integrity: sha512-Fx1dYokeRwouWqjLkdobA6qvTAPxFSEU2c5PlkfJjlNyONlSMJQPaX0Bae5pc+5/LNteb9BseOp4UHwQu6VC9Q==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.5 + babel-plugin-jsx-dom-expressions: 0.37.16(@babel/core@7.24.5) + dev: true + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -3729,6 +3987,11 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3814,6 +4077,11 @@ packages: which: 1.3.1 dev: true + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + /globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -3946,6 +4214,10 @@ packages: whatwg-encoding: 3.1.1 dev: true + /html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -4206,6 +4478,11 @@ packages: call-bind: 1.0.7 dev: true + /is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + dev: true + /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -4306,6 +4583,12 @@ packages: - utf-8-validate dev: true + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: true @@ -4462,6 +4745,12 @@ packages: get-func-name: 2.0.2 dev: true + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -4539,6 +4828,13 @@ packages: engines: {node: '>=18'} dev: true + /merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.16 + dev: true + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true @@ -5490,6 +5786,18 @@ packages: lru-cache: 6.0.0 dev: true + /seroval-plugins@1.0.5(seroval@1.0.5): + resolution: {integrity: sha512-8+pDC1vOedPXjKG7oz8o+iiHrtF2WswaMQJ7CKFpccvSYfrzmvKY9zOJWCg+881722wIHfwkdnRmiiDm9ym+zQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + dependencies: + seroval: 1.0.5 + + /seroval@1.0.5: + resolution: {integrity: sha512-TM+Z11tHHvQVQKeNlOUonOWnsNM+2IBwZ4vwoi4j3zKzIpc5IDw8WPwCfcc8F17wy6cBcJGbZbFOR0UCuTZHQA==} + engines: {node: '>=10'} + /set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5628,6 +5936,24 @@ packages: engines: {node: '>=8.0.0'} dev: true + /solid-js@1.8.17: + resolution: {integrity: sha512-E0FkUgv9sG/gEBWkHr/2XkBluHb1fkrHywUgA6o6XolPDCJ4g1HaLmQufcBBhiF36ee40q+HpG/vCZu7fLpI3Q==} + dependencies: + csstype: 3.1.3 + seroval: 1.0.5 + seroval-plugins: 1.0.5(seroval@1.0.5) + + /solid-refresh@0.6.3(solid-js@1.8.17): + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + dependencies: + '@babel/generator': 7.24.5 + '@babel/helper-module-imports': 7.24.3 + '@babel/types': 7.24.0 + solid-js: 1.8.17 + dev: true + /source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -6308,6 +6634,10 @@ packages: convert-source-map: 2.0.0 dev: true + /validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + dev: true + /validator@13.11.0: resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} engines: {node: '>= 0.10'} @@ -6386,6 +6716,14 @@ packages: vscode-uri: 3.0.8 dev: true + /vite-plugin-css-injected-by-js@3.5.1(vite@5.2.8): + resolution: {integrity: sha512-9ioqwDuEBxW55gNoWFEDhfLTrVKXEEZgl5adhWmmqa88EQGKfTmexy4v1Rh0pAS6RhKQs2bUYQArprB32JpUZQ==} + peerDependencies: + vite: '>2.0.0-0' + dependencies: + vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)(terser@5.30.3) + dev: true + /vite-plugin-dts@3.8.1(@types/node@20.12.5)(rollup@4.14.1)(typescript@5.4.4)(vite@5.2.8): resolution: {integrity: sha512-zEYyQxH7lKto1VTKZHF3ZZeOPkkJgnMrePY4VxDHfDSvDjmYMMfWjZxYmNwW8QxbaItWJQhhXY+geAbyNphI7g==} engines: {node: ^14.18.0 || >=16.0.0} @@ -6411,6 +6749,28 @@ packages: - supports-color dev: true + /vite-plugin-solid@2.10.2(solid-js@1.8.17)(vite@5.2.8): + resolution: {integrity: sha512-AOEtwMe2baBSXMXdo+BUwECC8IFHcKS6WQV/1NEd+Q7vHPap5fmIhLcAzr+DUJ04/KHx/1UBU0l1/GWP+rMAPQ==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + dependencies: + '@babel/core': 7.24.5 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.8.12(@babel/core@7.24.5) + merge-anything: 5.1.7 + solid-js: 1.8.17 + solid-refresh: 0.6.3(solid-js@1.8.17) + vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)(terser@5.30.3) + vitefu: 0.2.5(vite@5.2.8) + transitivePeerDependencies: + - supports-color + dev: true + /vite-tsconfig-paths@4.3.2(typescript@5.4.4)(vite@5.2.8): resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} peerDependencies: @@ -6466,6 +6826,17 @@ packages: fsevents: 2.3.3 dev: true + /vitefu@0.2.5(vite@5.2.8): + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)(terser@5.30.3) + dev: true + /vitepress@1.0.2(@algolia/client-search@4.23.2)(@types/node@20.12.5)(postcss@8.4.38)(sass@1.74.1)(search-insights@2.13.0)(terser@5.30.3)(typescript@5.4.4): resolution: {integrity: sha512-bEj9yTEdWyewJFOhEREZF+mXuAgOq27etuJZT6DZSp+J3XpQstXMJc5piSVwhZBtuj8OfA0iXy+jdP1c71KMYQ==} hasBin: true @@ -6884,6 +7255,10 @@ packages: engines: {node: '>=10'} dev: true + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true + /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh index 08602b4df..89c52eed4 100755 --- a/scripts/build-frontend.sh +++ b/scripts/build-frontend.sh @@ -23,9 +23,19 @@ DIST_DIR="./public/dist" rm -rf ${DIST_DIR} && mkdir -p ${DIST_DIR} cp -r ./ui/artalk/dist/{Artalk.css,Artalk.js} ${DIST_DIR} cp -r ./ui/artalk/dist/{ArtalkLite.css,ArtalkLite.js} ${DIST_DIR} -cp -r ./ui/artalk/dist/i18n ${DIST_DIR} + +I18N_DIR="${DIST_DIR}/i18n" +mkdir -p ${I18N_DIR} +cp -r ./ui/artalk/dist/i18n/*.js ${I18N_DIR} ## sidebar SIDEBAR_DIR="./public/sidebar" rm -rf ${SIDEBAR_DIR} && mkdir -p ${SIDEBAR_DIR} cp -r ./ui/artalk-sidebar/dist/* ${SIDEBAR_DIR} + +## plugins +PLUGIN_DIR="${DIST_DIR}/plugins" +mkdir -p ${PLUGIN_DIR} + +pnpm build:auth +cp -r ./ui/plugin-auth/dist/artalk-plugin-auth.js ${PLUGIN_DIR} diff --git a/server/common/auth.go b/server/common/auth.go index 3d39d5c3d..9f2a90ed1 100644 --- a/server/common/auth.go +++ b/server/common/auth.go @@ -93,7 +93,7 @@ func GetUserByReq(app *core.App, c *fiber.Ctx) (entity.User, error) { } // check tokenValidFrom - if user.TokenValidFrom.Valid && user.TokenValidFrom.Time.After(time.Unix(claims.IssuedAt, 0)) { + if user.TokenValidFrom.Valid && claims.IssuedAt < user.TokenValidFrom.Time.Unix() { return entity.User{}, ErrTokenInvalidFromDate } diff --git a/server/common/check.go b/server/common/check.go index 69f109939..449b35873 100644 --- a/server/common/check.go +++ b/server/common/check.go @@ -1,7 +1,10 @@ package common import ( + "errors" + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" "github.com/ArtalkJS/Artalk/internal/i18n" "github.com/gofiber/fiber/v2" ) @@ -16,6 +19,20 @@ func AdminGuard(app *core.App, handler fiber.Handler) fiber.Handler { } } +func LoginGuard(app *core.App, handler func(*fiber.Ctx, entity.User) error) fiber.Handler { + return func(c *fiber.Ctx) error { + user, err := GetUserByReq(app, c) + if err != nil { + msg := i18n.T("Login required") + if errors.Is(err, ErrTokenInvalidFromDate) { + msg = i18n.T("Your authentication token has expired. Please try signing in again.") + } + return RespError(c, 401, msg, Map{"need_auth_login": true}) + } + return handler(c, user) + } +} + func CheckIsAdminReq(app *core.App, c *fiber.Ctx) bool { user, err := GetUserByReq(app, c) if err != nil { diff --git a/server/common/conf.go b/server/common/conf.go index 514b14981..de8e957fd 100644 --- a/server/common/conf.go +++ b/server/common/conf.go @@ -51,13 +51,21 @@ func GetApiPublicConfDataMap(app *core.App, c *fiber.Ctx) ConfData { frontendConf["locale"] = app.Conf().Locale } - if pluginURLs, ok := frontendConf["pluginURLs"].([]any); ok { - frontendConf["pluginURLs"] = handlePluginURLs(app, - lo.Map[any, string](pluginURLs, func(u any, _ int) string { - return strings.TrimSpace(fmt.Sprintf("%v", u)) - })) + if _, ok := frontendConf["pluginURLs"].([]any); !ok { + frontendConf["pluginURLs"] = []string{} } + pluginURLs := frontendConf["pluginURLs"].([]any) + + if app.Conf().Auth.Enabled { + pluginURLs = append(pluginURLs, "dist/plugins/artalk-plugin-auth.js") + } + + frontendConf["pluginURLs"] = handlePluginURLs(app, + lo.Map(pluginURLs, func(u any, _ int) string { + return strings.TrimSpace(fmt.Sprintf("%v", u)) + })) + return ConfData{ FrontendConf: frontendConf, Version: GetApiVersionDataMap(), @@ -65,7 +73,7 @@ func GetApiPublicConfDataMap(app *core.App, c *fiber.Ctx) ConfData { } func handlePluginURLs(app *core.App, urls []string) []string { - return lo.Filter[string](urls, func(u string, _ int) bool { + return utils.RemoveDuplicates(lo.Filter(urls, func(u string, _ int) bool { if strings.TrimSpace(u) == "" { return false } @@ -76,5 +84,5 @@ func handlePluginURLs(app *core.App, urls []string) []string { return true } return false - }) + })) } diff --git a/server/handler/auth_email_login.go b/server/handler/auth_email_login.go new file mode 100644 index 000000000..369b8628b --- /dev/null +++ b/server/handler/auth_email_login.go @@ -0,0 +1,83 @@ +package handler + +import ( + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/i18n" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" +) + +type RequestAuthEmailLogin struct { + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"optional"` + Code string `json:"code" validate:"optional"` +} + +// @Id LoginByEmail +// @Summary Login by email +// @Description Login by email with verify code (Need send email verify code first) or password +// @Tags Auth +// @Param data body RequestAuthEmailLogin true "The data to login" +// @Success 200 {object} ResponseUserLogin +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Accept json +// @Produce json +// @Router /auth/email/login [post] +func AuthEmailLogin(app *core.App, router fiber.Router) { + router.Post("/auth/email/login", common.LimiterGuard(app, func(c *fiber.Ctx) error { + if !app.Conf().Auth.Email.Enabled { + return common.RespError(c, 400, "Email auth is not enabled") + } + + var p RequestAuthEmailLogin + if ok, resp := common.ParamsDecode(c, &p); !ok { + return resp + } + + findUser := func(email string) (user entity.User) { + users := app.Dao().FindUsersByEmail(email) + if len(users) == 0 { + return entity.User{} + } + // Select first user, if there are multiple users with same email + return users[0] + } + + var user entity.User + if p.Code != "" { + // Login by verify code + if ok, resp := CheckEmailVerifyCode(app, c, p.Email, p.Code); !ok { + return resp + } + + user = findUser(p.Email) + } else if p.Password != "" { + // Login by password + user = findUser(p.Email) + + // Check password + if !user.CheckPassword(p.Password) { + return common.RespError(c, 401, i18n.T("Login failed")) + } + } else { + return common.RespError(c, 400, "Password or code is required") + } + + if user.IsEmpty() { + return common.RespError(c, 401, "User not found") + } + + // Get user token + jwtToken, err := common.LoginGetUserToken(user, app.Conf().AppKey, app.Conf().LoginTimeout) + if err != nil { + return common.RespError(c, 500, err.Error()) + } + + return common.RespData(c, ResponseUserLogin{ + Token: jwtToken, + User: app.Dao().CookUser(&user), + }) + })) +} diff --git a/server/handler/auth_email_register.go b/server/handler/auth_email_register.go new file mode 100644 index 000000000..5634db205 --- /dev/null +++ b/server/handler/auth_email_register.go @@ -0,0 +1,106 @@ +package handler + +import ( + "strings" + + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/utils" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" +) + +type RequestAuthEmailRegister struct { + Code string `json:"code" validate:"required"` + Email string `json:"email" validate:"required"` + Name string `json:"name" validate:"optional"` + Link string `json:"link" validate:"optional"` + Password string `json:"password" validate:"required"` +} + +// @Id RegisterByEmail +// @Summary Register by email +// @Description Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first) +// @Tags Auth +// @Param data body RequestAuthEmailRegister true "The data to register" +// @Success 200 {object} ResponseUserLogin +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Accept json +// @Produce json +// @Router /auth/email/register [post] +func AuthEmailRegister(app *core.App, router fiber.Router) { + router.Post("/auth/email/register", common.LimiterGuard(app, func(c *fiber.Ctx) error { + if !app.Conf().Auth.Email.Enabled { + return common.RespError(c, 400, "Email auth is not enabled") + } + + var p RequestAuthEmailRegister + if ok, resp := common.ParamsDecode(c, &p); !ok { + return resp + } + + // Trim form + p.Name = strings.TrimSpace(p.Name) + p.Email = strings.TrimSpace(p.Email) + p.Link = strings.TrimSpace(p.Link) + p.Password = strings.TrimSpace(p.Password) + + // Check email + if !utils.ValidateEmail(p.Email) { + return common.RespError(c, 400, "Invalid email") + } + + // Check link + if p.Link != "" && !utils.ValidateURL(p.Link) { + return common.RespError(c, 400, "Invalid link") + } + + // Check password + if len(p.Password) < 6 { + return common.RespError(c, 400, "Password must be at least 6 characters") + } + + // Select first user, if there are multiple users with same email + // If name is empty, it is the mode of updating user (like forget password) + if p.Name == "" { + users := app.Dao().FindUsersByEmail(p.Email) + if len(users) == 0 { + return common.RespError(c, 400, "User not found") + } + p.Name = users[0].Name + } + + // Check email verify code + if ok, resp := CheckEmailVerifyCode(app, c, p.Email, p.Code); !ok { + return resp + } + + // Create user + user, err := app.Dao().FindCreateUser(p.Name, p.Email, p.Link) + if err != nil { + return common.RespError(c, 500, "Failed to create user") + } + + // Update user + if err := user.SetPasswordEncrypt(p.Password); err != nil { + return common.RespError(c, 500, "Failed to encrypt password") + } + if p.Link != "" { + user.Link = p.Link + } + if err := app.Dao().UpdateUser(&user); err != nil { + return common.RespError(c, 500, "Failed to update user") + } + + // Login + jwtToken, err := common.LoginGetUserToken(user, app.Conf().AppKey, app.Conf().LoginTimeout) + if err != nil { + return common.RespError(c, 500, err.Error()) + } + + return common.RespData(c, ResponseUserLogin{ + Token: jwtToken, + User: app.Dao().CookUser(&user), + }) + })) +} diff --git a/server/handler/auth_email_send.go b/server/handler/auth_email_send.go new file mode 100644 index 000000000..d8a9fb3fc --- /dev/null +++ b/server/handler/auth_email_send.go @@ -0,0 +1,153 @@ +package handler + +import ( + "cmp" + "os" + "strings" + "time" + + "github.com/ArtalkJS/Artalk/internal/config" + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/i18n" + "github.com/ArtalkJS/Artalk/internal/log" + "github.com/ArtalkJS/Artalk/internal/sync" + "github.com/ArtalkJS/Artalk/internal/utils" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" +) + +type RequestAuthEmailSend struct { + Email string `json:"email" validate:"required"` +} + +// @Id SendVerifyEmail +// @Summary Send verify email +// @Description Send email including verify code to user +// @Tags Auth +// @Param data body RequestAuthEmailSend true "The data" +// @Success 200 {object} Map{msg=string} +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Accept json +// @Produce json +// @Router /auth/email/send [post] +func AuthEmailSend(app *core.App, router fiber.Router) { + mutexMap := sync.NewKeyMutex[string]() + + router.Post("/auth/email/send", common.LimiterGuard(app, func(c *fiber.Ctx) error { + if !app.Conf().Auth.Email.Enabled { + return common.RespError(c, 400, "Email auth is not enabled") + } + + var p RequestAuthEmailSend + if isOK, resp := common.ParamsDecode(c, &p); !isOK { + return resp + } + + // Check email + p.Email = strings.TrimSpace(p.Email) + if !utils.ValidateEmail(p.Email) { + return common.RespError(c, 400, "Invalid email") + } + + // Mutex for each email to avoid frequency check fail concurrently + mutexMap.Lock(p.Email) + defer mutexMap.Unlock(p.Email) + + // check if had send email verify code in 1 minutes + if err := app.Dao().DB().Model(&entity.UserEmailVerify{}).Where("email = ? OR ip = ?", p.Email, c.IP()).Where("updated_at > ?", time.Now().Add(-time.Minute*1)).First(&entity.UserEmailVerify{}).Error; err == nil { + return common.RespError(c, 400, "Send email verify code too frequently") + } + + // Save email verify code + code := utils.RandomStringWithAlphabet(6, "0123456789") + + var emailVerify entity.UserEmailVerify + err := app.Dao().DB().Where(entity.UserEmailVerify{ + Email: p.Email, + }).Assign(entity.UserEmailVerify{ + Code: code, + ExpiresAt: time.Now().Add(time.Minute * 5), + IP: c.IP(), + UA: string(c.Request().Header.UserAgent()), + }).FirstOrCreate(&emailVerify).Error + if err != nil { + return common.RespError(c, 500, "Failed to save email verify") + } + + // Send email + emailService, err := core.AppService[*core.EmailService](app) + if err != nil { + return common.RespError(c, 500, "Failed to get email service") + } + + // Email template + emailSubject := cmp.Or(app.Conf().Auth.Email.VerifySubject, i18n.T("Your Code - {{code}}", map[string]any{"code": code})) + emailBody := "" + + if tpl := loadAuthEmailVerifyTemplate(&app.Conf().Auth.Email); tpl != "" { + emailBody = utils.RenderMustaches(tpl, map[string]any{ + "code": code, + }) + } else { + emailBody = i18n.T("Your code is: {{code}}. Use it to verify your email and sign in Artalk. If you didn't request this, simply ignore this message.", map[string]any{"code": code}) + } + + log.Debug("Send email to: ", p.Email, " subject: ", emailSubject, " body: ", emailBody) + emailService.AsyncSendTo(emailSubject, emailBody, p.Email) + + return common.RespSuccess(c) + })) +} + +func CheckEmailVerifyCode(app *core.App, c *fiber.Ctx, email string, code string) (ok bool, resp error) { + email = strings.TrimSpace(email) + code = strings.TrimSpace(code) + + if email == "" || code == "" || !utils.ValidateEmail(email) { + return false, common.RespError(c, 400, "Invalid email or code") + } + + // Check email verify code + var emailVerify entity.UserEmailVerify + app.Dao().DB().Where(entity.UserEmailVerify{ + Email: email, + Code: code, + }).First(&emailVerify) + + if emailVerify.ID == 0 { + return false, common.RespError(c, 400, "Invalid email verify code") + } + if emailVerify.ExpiresAt.Before(time.Now()) { + return false, common.RespError(c, 400, "Email verify code expired") + } + + // Revoke email verify code + if err := app.Dao().DB().Unscoped().Delete(&emailVerify).Error; err != nil { + return false, common.RespError(c, 500, "Failed to revoke email verify code") + } + + return true, nil +} + +func loadAuthEmailVerifyTemplate(conf *config.AuthEmailConf) string { + if conf.VerifyTpl == "" || conf.VerifyTpl == "default" { + return "" + } + + // check if tpl file exists + if !utils.CheckFileExist(conf.VerifyTpl) { + log.Error("Email template file not exists: ", conf.VerifyTpl) + return "" + } + + // read tpl file + fs, err := os.ReadFile(conf.VerifyTpl) + if err != nil { + log.Error("Failed to read email template file: ", conf.VerifyTpl, " err: ", err.Error()) + return "" + } + + return string(fs) +} diff --git a/server/handler/auth_merge_apply.go b/server/handler/auth_merge_apply.go new file mode 100644 index 000000000..fec174115 --- /dev/null +++ b/server/handler/auth_merge_apply.go @@ -0,0 +1,159 @@ +package handler + +import ( + "fmt" + "strconv" + "strings" + + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/log" + "github.com/ArtalkJS/Artalk/internal/sync" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" + "github.com/samber/lo" + "gorm.io/gorm" +) + +type RequestAuthDataMergeApply struct { + UserName string `json:"user_name" validate:"required"` +} + +type ResponseAuthDataMergeApply struct { + UpdatedComment int64 `json:"update_comments_count"` + UpdatedNotify int64 `json:"update_notifies_count"` + UpdatedVote int64 `json:"update_votes_count"` + DeletedUser int64 `json:"deleted_user_count"` + UserToken string `json:"user_token"` // Empty if login user is target user no need to re-login +} + +// @Id ApplyDataMerge +// @Summary Apply data merge +// @Description This function is to solve the problem of multiple users with the same email address, should be called after user login and then check, and perform data merge. +// @Tags Auth +// @Security ApiKeyAuth +// @Param data body RequestAuthDataMergeApply true "The data" +// @Success 200 {object} ResponseAuthDataMergeApply +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Accept json +// @Produce json +// @Router /auth/merge [post] +func AuthMergeApply(app *core.App, router fiber.Router) { + mutexMap := sync.NewKeyMutex[uint]() + + router.Post("/auth/merge", common.LoginGuard(app, func(c *fiber.Ctx, user entity.User) error { + // Mutex for each user to avoid concurrent merge operation + mutexMap.Lock(user.ID) + defer mutexMap.Unlock(user.ID) + + if user.Email == "" { + return common.RespError(c, 500, "User email is empty") + } + + var p RequestAuthDataMergeApply + if isOK, resp := common.ParamsDecode(c, &p); !isOK { + return resp + } + + // Get all users with same email + sameEmailUsers := app.Dao().FindUsersByEmail(user.Email) + if len(sameEmailUsers) == 0 { + return common.RespError(c, 500, "No user with same email") + } + + targetUser, err := app.Dao().FindCreateUser(p.UserName, user.Email, user.Link) + if err != nil { + return common.RespError(c, 500, "Failed to create user") + } + + // Check target if admin and recover + isAdmin := false + for _, u := range sameEmailUsers { + if u.IsAdmin { + isAdmin = true + break + } + } + if targetUser.IsAdmin != isAdmin { + targetUser.IsAdmin = isAdmin + app.Dao().UpdateUser(&targetUser) + } + + resp := ResponseAuthDataMergeApply{} + otherUsers := lo.Filter(sameEmailUsers, func(u entity.User, _ int) bool { + return u.ID != targetUser.ID + }) + + // Functions for log + getMergeLogSummary := func() string { + getUserInfo := func(u entity.User) string { + return fmt.Sprintf("[%d, %s, %s]", u.ID, strconv.Quote(u.Name), strconv.Quote(u.Email)) + } + getUsersInfo := func(otherUsers []entity.User) string { + return strings.Join(lo.Map(otherUsers, func(u entity.User, _ int) string { return getUserInfo(u) }), ", ") + } + return " | " + getUsersInfo(otherUsers) + " -> " + getUserInfo(targetUser) + } + + // Begin a transaction to Merge all user data to target user + if err := app.Dao().DB().Transaction(func(tx *gorm.DB) error { + // Merge all user data to target user + for _, u := range otherUsers { + // comments + if tx := app.Dao().DB().Model(&entity.Comment{}). + Where("user_id = ?", u.ID).Update("user_id", targetUser.ID); tx.Error != nil { + return tx.Error // if error the whole transaction will be rollback + } else { + resp.UpdatedComment += tx.RowsAffected + } + + // notifies + if tx := app.Dao().DB().Model(&entity.Notify{}). + Where("user_id = ?", u.ID).Update("user_id", targetUser.ID); tx.Error != nil { + return tx.Error + } else { + resp.UpdatedNotify += tx.RowsAffected + } + + // votes + if tx := app.Dao().DB().Model(&entity.Vote{}). + Where("user_id = ?", u.ID).Update("user_id", targetUser.ID); tx.Error != nil { + return tx.Error + } else { + resp.UpdatedVote += tx.RowsAffected + } + } + + return nil + }); err != nil { + log.Error("Failed to merge user data: ", err.Error(), getMergeLogSummary()) + return common.RespError(c, 500, "Failed to merge data") + } + + // Delete other users except target user + for _, u := range otherUsers { + if err := app.Dao().DelUser(&u); err != nil { + log.Error("Failed to delete other user [id=", u.ID, "]: ", err.Error(), getMergeLogSummary()) + } else { + resp.DeletedUser++ + } + } + + // Re-login + jwtToken, err := common.LoginGetUserToken(targetUser, app.Conf().AppKey, app.Conf().LoginTimeout) + if err != nil { + return common.RespError(c, 500, "Failed to re-login") + } + resp.UserToken = jwtToken + + // Log + log.Info("User data merged successfully", getMergeLogSummary(), " | ", + "Updated Comments: ", resp.UpdatedComment, " | ", + "Updated Notifies: ", resp.UpdatedNotify, " | ", + "Updated Votes: ", resp.UpdatedVote, " | ", + "Deleted Users: ", resp.DeletedUser) + + return common.RespData(c, resp) + })) +} diff --git a/server/handler/auth_merge_check.go b/server/handler/auth_merge_check.go new file mode 100644 index 000000000..09c08bcb6 --- /dev/null +++ b/server/handler/auth_merge_check.go @@ -0,0 +1,58 @@ +package handler + +import ( + "slices" + + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" +) + +type ResponseAuthDataMergeCheck struct { + NeedMerge bool `json:"need_merge"` + UserNames []string `json:"user_names"` +} + +// @Id CheckDataMerge +// @Summary Check data merge +// @Description Get all users with same email, if there are more than one user with same email, need merge +// @Tags Auth +// @Security ApiKeyAuth +// @Success 200 {object} ResponseAuthDataMergeCheck +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Produce json +// @Router /auth/merge [get] +func AuthMergeCheck(app *core.App, router fiber.Router) { + router.Get("/auth/merge", common.LoginGuard(app, func(c *fiber.Ctx, user entity.User) error { + if user.Email == "" { + return common.RespError(c, 500, "User email is empty") + } + + var ( + needMerge = false + userNames = []string{} + ) + + // Get all users with same email + sameEmailUsers := app.Dao().FindUsersByEmail(user.Email) + + // If there are more than one user with same email, need merge + if len(sameEmailUsers) > 1 { + needMerge = true + + // Get unique user names for user to choose + for _, u := range sameEmailUsers { + if !slices.Contains(userNames, u.Name) { + userNames = append(userNames, u.Name) + } + } + } + + return common.RespData(c, ResponseAuthDataMergeCheck{ + NeedMerge: needMerge, + UserNames: userNames, + }) + })) +} diff --git a/server/handler/auth_social_login.go b/server/handler/auth_social_login.go new file mode 100644 index 000000000..0b8df872f --- /dev/null +++ b/server/handler/auth_social_login.go @@ -0,0 +1,112 @@ +package handler + +import ( + "github.com/ArtalkJS/Artalk/internal/auth" + "github.com/ArtalkJS/Artalk/internal/auth/gothic_fiber" + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/log" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" + "github.com/markbates/goth" +) + +func SocialLoginGuard(app *core.App, handler fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + if !app.Conf().Auth.Enabled { + return common.RespError(c, 404, "Auth Api disabled") + } + + return handler(c) + } +} + +type ResponseConfAuthProviders struct { + Providers []auth.AuthProviderInfo `json:"providers" validator:"required"` + Anonymous bool `json:"anonymous" validator:"required"` +} + +// @Id GetSocialLoginProviders +// @Summary Get Social Login Providers +// @Description Get social login providers +// @Tags System +// @Produce json +// @Success 200 {object} ResponseConfAuthProviders +// @Success 404 {object} Map{msg=string} +// @Router /conf/auth/providers [get] +func AuthSocialLogin(app *core.App, router fiber.Router) { + // Load providers + var providers []goth.Provider + + loadProviders := func() { + providers = auth.GetProviders(app.Conf()) + goth.ClearProviders() + goth.UseProviders(providers...) + } + + loadProviders() + app.OnConfUpdated().Add(func(e *core.ConfUpdatedEvent) error { + loadProviders() + return nil + }) + + // Endpoints + router.Get("/conf/auth/providers", SocialLoginGuard(app, func(c *fiber.Ctx) error { + return common.RespData(c, ResponseConfAuthProviders{ + Providers: auth.GetProviderInfo(app.Conf(), providers), + Anonymous: app.Conf().Auth.Anonymous, + }) + })) + + router.Get("/auth/:provider", SocialLoginGuard(app, func(c *fiber.Ctx) error { + return gothic_fiber.BeginAuthHandler(c) + })) + + router.Get("/auth/:provider/callback", SocialLoginGuard(app, func(c *fiber.Ctx) error { + provider, err := gothic_fiber.GetProviderName(c) + if err != nil { + log.Error("[SocialLogin] ", err) + return common.RespError(c, 500, "Field to get provider name") + } + + // Fetch user + gothUser, err := gothic_fiber.CompleteUserAuth(c) + if err != nil { + log.Error("[SocialLogin] ", err) + return common.RespError(c, 500, "Field to complete user auth") + } + + // Convert to social user + socialUser := auth.GetSocialUser(gothUser) + log.Debug("[SocialLogin] ", socialUser) + + // Find auth identity + authIdentity := app.Dao().FindAuthIdentityByRemoteUID(provider, socialUser.RemoteUID) + + // No auth identity record, register user + if authIdentity.IsEmpty() { + authIdentity, err = auth.RegisterSocialUser(app.Dao(), socialUser) + if err != nil { + log.Error("[SocialLogin] ", err) + return common.RespError(c, 500, "Failed to register user") + } + } + if authIdentity.UserID == 0 { + return common.RespError(c, 500, "Auth Identity user_id invalid") + } + + // Find user perform login + user := app.Dao().FindUserByID(authIdentity.UserID) + if user.IsEmpty() { + return common.RespError(c, 500, "Failed to find user") + } + + // Get user token + jwtToken, err := common.LoginGetUserToken(user, app.Conf().AppKey, app.Conf().LoginTimeout) + if err != nil { + return common.RespError(c, 500, err.Error()) + } + + // Render response + return auth.ResponseCallbackPage(c, jwtToken) + })) +} diff --git a/server/handler/comment_create.go b/server/handler/comment_create.go index 4485894bd..5b2b145c9 100644 --- a/server/handler/comment_create.go +++ b/server/handler/comment_create.go @@ -108,20 +108,30 @@ func CommentCreate(app *core.App, router fiber.Router) { } // find user - user, err := app.Dao().FindCreateUser(p.Name, p.Email, p.Link) - if err != nil || page.Key == "" { - log.Error("Cannot get user or page") + isVerified := true + user, err := common.GetUserByReq(app, c) + if errors.Is(err, common.ErrTokenNotProvided) { + // Anonymous user + isVerified = false + user, err = app.Dao().FindCreateUser(p.Name, p.Email, p.Link) + if err != nil { + log.Error("[CommentCreate] Create user error: ", err) + return common.RespError(c, 500, i18n.T("Comment failed")) + } + + // Update user + user.Link = p.Link + user.LastIP = ip + user.LastUA = ua + user.Name = p.Name // for 若用户修改用户名大小写 + user.Email = p.Email + app.Dao().UpdateUser(&user) + } else if err != nil { + // Login user error + log.Error("[CommentCreate] Get user error: ", err) return common.RespError(c, 500, i18n.T("Comment failed")) } - // update user - user.Link = p.Link - user.LastIP = ip - user.LastUA = ua - user.Name = p.Name // for 若用户修改用户名大小写 - user.Email = p.Email - app.Dao().UpdateUser(&user) - comment := entity.Comment{ Content: p.Content, PageKey: page.Key, @@ -137,6 +147,7 @@ func CommentCreate(app *core.App, router fiber.Router) { IsPending: false, IsCollapsed: false, IsPinned: false, + IsVerified: isVerified, } // default comment type diff --git a/server/server.go b/server/server.go index 1b6219c60..b86c61c4f 100644 --- a/server/server.go +++ b/server/server.go @@ -86,6 +86,15 @@ func Serve(app *core.App) (*fiber.App, error) { // captcha h.Captcha(app, api) + // auth + h.AuthEmailLogin(app, api) + h.AuthEmailRegister(app, api) + h.AuthEmailSend(app, api) + h.AuthMergeApply(app, api) + h.AuthMergeCheck(app, api) + + h.AuthSocialLogin(app, api) + // user h.UserInfo(app, api) h.UserLogin(app, api) diff --git a/ui/artalk-sidebar/vite.config.ts b/ui/artalk-sidebar/vite.config.ts index f93f24147..7cbcf0451 100644 --- a/ui/artalk-sidebar/vite.config.ts +++ b/ui/artalk-sidebar/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ port: 23367, proxy: { '/api': 'http://127.0.0.1:23366', + '/dist': 'http://127.0.0.1:23366', }, }, css: { diff --git a/ui/artalk/src/api/v2.ts b/ui/artalk/src/api/v2.ts index 736d1a1f8..68a1ba7f8 100644 --- a/ui/artalk/src/api/v2.ts +++ b/ui/artalk/src/api/v2.ts @@ -9,6 +9,13 @@ * --------------------------------------------------------------- */ +export interface AuthAuthProviderInfo { + icon: string + label: string + name: string + path?: string +} + export interface CommonApiVersionData { app: string commit_hash: string @@ -295,11 +302,47 @@ export interface HandlerParamsVote { name?: string } +export interface HandlerRequestAuthDataMergeApply { + user_name: string +} + +export interface HandlerRequestAuthEmailLogin { + code?: string + email: string + password?: string +} + +export interface HandlerRequestAuthEmailRegister { + code: string + email: string + link?: string + name?: string + password: string +} + +export interface HandlerRequestAuthEmailSend { + email: string +} + export interface HandlerResponseAdminUserList { count: number users: EntityCookedUserForAdmin[] } +export interface HandlerResponseAuthDataMergeApply { + deleted_user_count: number + update_comments_count: number + update_notifies_count: number + update_votes_count: number + /** Empty if login user is target user no need to re-login */ + user_token: string +} + +export interface HandlerResponseAuthDataMergeCheck { + need_merge: boolean + user_names: string[] +} + export interface HandlerResponseCaptchaGet { img_data: string } @@ -376,6 +419,11 @@ export interface HandlerResponseCommentUpdate { vote_up: number } +export interface HandlerResponseConfAuthProviders { + anonymous: boolean + providers: AuthAuthProviderInfo[] +} + export interface HandlerResponseConfDomain { /** Is the domain trusted */ is_trusted: boolean @@ -556,7 +604,9 @@ export type RequestParams = Omit { baseUrl?: string baseApiParams?: Omit - securityWorker?: (securityData: SecurityDataType | null) => Promise | RequestParams | void + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void customFetch?: typeof fetch } @@ -614,7 +664,11 @@ export class HttpClient { const query = rawQuery || {} const keys = Object.keys(query).filter((key) => 'undefined' !== typeof query[key]) return keys - .map((key) => (Array.isArray(query[key]) ? this.addArrayQueryParam(query, key) : this.addQueryParam(query, key))) + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) .join('&') } @@ -625,8 +679,11 @@ export class HttpClient { private contentFormatters: Record any> = { [ContentType.Json]: (input: any) => - input !== null && (typeof input === 'object' || typeof input === 'string') ? JSON.stringify(input) : input, - [ContentType.Text]: (input: any) => (input !== null && typeof input !== 'string' ? JSON.stringify(input) : input), + input !== null && (typeof input === 'object' || typeof input === 'string') + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== 'string' ? JSON.stringify(input) : input, [ContentType.FormData]: (input: any) => Object.keys(input || {}).reduce((formData, key) => { const property = input[key] @@ -700,15 +757,18 @@ export class HttpClient { const payloadFormatter = this.contentFormatters[type || ContentType.Json] const responseFormat = format || requestParams.format - return this.customFetch(`${baseUrl || this.baseUrl || ''}${path}${queryString ? `?${queryString}` : ''}`, { - ...requestParams, - headers: { - ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData ? { 'Content-Type': type } : {}), + return this.customFetch( + `${baseUrl || this.baseUrl || ''}${path}${queryString ? `?${queryString}` : ''}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData ? { 'Content-Type': type } : {}), + }, + signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, + body: typeof body === 'undefined' || body === null ? null : payloadFormatter(body), }, - signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, - body: typeof body === 'undefined' || body === null ? null : payloadFormatter(body), - }).then(async (response) => { + ).then(async (response) => { const r = response as HttpResponse r.data = null as unknown as T r.error = null as unknown as E @@ -749,6 +809,174 @@ export class HttpClient { * Artalk is a modern comment system based on Golang. */ export class Api extends HttpClient { + auth = { + /** + * @description Login by email with verify code (Need send email verify code first) or password + * + * @tags Auth + * @name LoginByEmail + * @summary Login by email + * @request POST:/auth/email/login + * @response `200` `HandlerResponseUserLogin` OK + * @response `400` `(HandlerMap & { + msg?: string, + +})` Bad Request + * @response `500` `(HandlerMap & { + msg?: string, + +})` Internal Server Error + */ + loginByEmail: (data: HandlerRequestAuthEmailLogin, params: RequestParams = {}) => + this.request< + HandlerResponseUserLogin, + HandlerMap & { + msg?: string + } + >({ + path: `/auth/email/login`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params, + }), + + /** + * @description Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first) + * + * @tags Auth + * @name RegisterByEmail + * @summary Register by email + * @request POST:/auth/email/register + * @response `200` `HandlerResponseUserLogin` OK + * @response `400` `(HandlerMap & { + msg?: string, + +})` Bad Request + * @response `500` `(HandlerMap & { + msg?: string, + +})` Internal Server Error + */ + registerByEmail: (data: HandlerRequestAuthEmailRegister, params: RequestParams = {}) => + this.request< + HandlerResponseUserLogin, + HandlerMap & { + msg?: string + } + >({ + path: `/auth/email/register`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params, + }), + + /** + * @description Send email including verify code to user + * + * @tags Auth + * @name SendVerifyEmail + * @summary Send verify email + * @request POST:/auth/email/send + * @response `200` `(HandlerMap & { + msg?: string, + +})` OK + * @response `400` `(HandlerMap & { + msg?: string, + +})` Bad Request + * @response `500` `(HandlerMap & { + msg?: string, + +})` Internal Server Error + */ + sendVerifyEmail: (data: HandlerRequestAuthEmailSend, params: RequestParams = {}) => + this.request< + HandlerMap & { + msg?: string + }, + HandlerMap & { + msg?: string + } + >({ + path: `/auth/email/send`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params, + }), + + /** + * @description Get all users with same email, if there are more than one user with same email, need merge + * + * @tags Auth + * @name CheckDataMerge + * @summary Check data merge + * @request GET:/auth/merge + * @secure + * @response `200` `HandlerResponseAuthDataMergeCheck` OK + * @response `400` `(HandlerMap & { + msg?: string, + +})` Bad Request + * @response `500` `(HandlerMap & { + msg?: string, + +})` Internal Server Error + */ + checkDataMerge: (params: RequestParams = {}) => + this.request< + HandlerResponseAuthDataMergeCheck, + HandlerMap & { + msg?: string + } + >({ + path: `/auth/merge`, + method: 'GET', + secure: true, + format: 'json', + ...params, + }), + + /** + * @description This function is to solve the problem of multiple users with the same email address, should be called after user login and then check, and perform data merge. + * + * @tags Auth + * @name ApplyDataMerge + * @summary Apply data merge + * @request POST:/auth/merge + * @secure + * @response `200` `HandlerResponseAuthDataMergeApply` OK + * @response `400` `(HandlerMap & { + msg?: string, + +})` Bad Request + * @response `500` `(HandlerMap & { + msg?: string, + +})` Internal Server Error + */ + applyDataMerge: (data: HandlerRequestAuthDataMergeApply, params: RequestParams = {}) => + this.request< + HandlerResponseAuthDataMergeApply, + HandlerMap & { + msg?: string + } + >({ + path: `/auth/merge`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + format: 'json', + ...params, + }), + } cache = { /** * @description Flush all cache on the server @@ -1117,6 +1345,32 @@ export class Api extends HttpClient + this.request< + HandlerResponseConfAuthProviders, + HandlerMap & { + msg?: string + } + >({ + path: `/conf/auth/providers`, + method: 'GET', + format: 'json', + ...params, + }), + + /** * @description Get Domain Info * * @tags System diff --git a/ui/artalk/src/i18n/en.ts b/ui/artalk/src/i18n/en.ts index cb98113f3..257ad6242 100644 --- a/ui/artalk/src/i18n/en.ts +++ b/ui/artalk/src/i18n/en.ts @@ -3,6 +3,8 @@ const en = { placeholder: 'Leave a comment', noComment: 'No Comment', send: 'Send', + signIn: 'Sign in', + signUp: 'Sign up', save: 'Save', nick: 'Nickname', email: 'Email', @@ -66,7 +68,27 @@ const en = { /* Sidebar */ msgCenter: 'Messages', ctrlCenter: 'Admin', + + /* Auth */ + noAccountPrompt: "Don't have an account?", + haveAccountPrompt: 'Already have an account?', + forgetPassword: 'Forget Password', + resetPassword: 'Reset Password', + verificationCode: 'Verification Code', + verifySend: 'Verify', + verifyResend: 'Resend', + waitSeconds: 'Wait {seconds}s', emailVerified: 'Email has been verified', + password: 'Password', + username: 'Username', + nextStep: 'Next Step', + skipNotVerify: 'Skip, do not verify', + logoutConfirm: 'Are you sure to logout?', + accountMergeNotice: 'Your email has multiple accounts with different id.', + accountMergeSelectOne: 'Please select one you want to merge all the data into it.', + accountMergeConfirm: 'All data will be merged into one account, the id is {id}.', + dismiss: 'Dismiss', + merge: 'Merge', /* General */ frontend: 'Frontend', diff --git a/ui/artalk/src/i18n/jp.ts b/ui/artalk/src/i18n/jp.ts index 5373155f4..f2c48de45 100644 --- a/ui/artalk/src/i18n/jp.ts +++ b/ui/artalk/src/i18n/jp.ts @@ -7,6 +7,8 @@ export default defineLocaleExternal( placeholder: '内容を入力してください...', noComment: 'コメントなし', send: 'コメントを送信', + signIn: 'サインイン', + signUp: 'サインアップ', save: 'コメントを保存', nick: 'ニックネーム', email: 'Eメール', @@ -70,7 +72,27 @@ export default defineLocaleExternal( /* Sidebar */ msgCenter: '通知センター', ctrlCenter: 'コントロールセンター', + + /* Auth */ + noAccountPrompt: 'アカウントがありませんか?', + haveAccountPrompt: 'アカウントをお持ちですか?', + forgetPassword: 'パスワードを忘れた', + resetPassword: 'パスワードをリセット', + verificationCode: '検証コード', + verifySend: '検証', + verifyResend: '再送信', + waitSeconds: '{seconds}秒待つ', emailVerified: 'メールアドレスが確認されました', + password: 'パスワード', + username: 'ユーザー名', + nextStep: '次のステップ', + skipNotVerify: 'スキップ、検証しない', + logoutConfirm: 'ログアウトしてもよろしいですか?', + accountMergeNotice: 'あなたのメールには異なるIDを持つ複数のアカウントがあります。', + accountMergeSelectOne: 'すべてのデータを統合するアカウントを選択してください。', + accountMergeConfirm: 'すべてのデータは1つのアカウントに統合されます。そのIDは{id}です。', + dismiss: '閉じる', + merge: '統合する', /* General */ frontend: 'フロントエンド', diff --git a/ui/artalk/src/i18n/zh-CN.ts b/ui/artalk/src/i18n/zh-CN.ts index 70dda4da9..4c0f72c9b 100644 --- a/ui/artalk/src/i18n/zh-CN.ts +++ b/ui/artalk/src/i18n/zh-CN.ts @@ -5,6 +5,8 @@ const zhCN: I18n = { placeholder: '键入内容...', noComment: '「此时无声胜有声」', send: '发送', + signIn: '登录', + signUp: '注册', save: '保存', nick: '昵称', email: '邮箱', @@ -68,7 +70,27 @@ const zhCN: I18n = { /* Sidebar */ msgCenter: '通知中心', ctrlCenter: '控制中心', + + /* Auth */ + noAccountPrompt: '没有账号?', + haveAccountPrompt: '已有账号?', + forgetPassword: '忘记密码', + resetPassword: '重置密码', + verificationCode: '验证码', + verifySend: '发送验证码', + verifyResend: '重新发送', + waitSeconds: '等待 {seconds}秒', emailVerified: '邮箱已验证', + password: '密码', + username: '用户名', + nextStep: '下一步', + skipNotVerify: '跳过,不验证', + logoutConfirm: '确定要退出登录吗?', + accountMergeNotice: '您的电子邮件下有多个不同 ID 的账户。', + accountMergeSelectOne: '请选择将所有数据合并到其中的一个。', + accountMergeConfirm: '所有数据将合并到 ID 为 {id} 的账户中。', + dismiss: '忽略', + merge: '合并', /* General */ frontend: '前端', diff --git a/ui/artalk/src/i18n/zh-TW.ts b/ui/artalk/src/i18n/zh-TW.ts index 2d4ecf4b3..a24c4dfe9 100644 --- a/ui/artalk/src/i18n/zh-TW.ts +++ b/ui/artalk/src/i18n/zh-TW.ts @@ -5,6 +5,8 @@ export default defineLocaleExternal('zh-TW', { placeholder: '輸入內容...', noComment: '「此時無聲勝有聲」', send: '發送', + signIn: '登入', + signUp: '註冊', save: '保存', nick: '暱稱', email: '電子郵件', @@ -68,7 +70,27 @@ export default defineLocaleExternal('zh-TW', { /* Sidebar */ msgCenter: '通知中心', ctrlCenter: '控制中心', + + /* Auth */ + noAccountPrompt: '沒有帳號?', + haveAccountPrompt: '已有帳號?', + forgetPassword: '忘記密碼', + resetPassword: '重置密碼', + verificationCode: '驗證碼', + verifySend: '發送驗證碼', + verifyResend: '重新發送', + waitSeconds: '等待 {seconds}秒', emailVerified: '郵箱已驗證', + password: '密碼', + username: '用戶名', + nextStep: '下一步', + skipNotVerify: '跳過,不驗證', + logoutConfirm: '確定要登出嗎?', + accountMergeNotice: '您的電子郵件下有多個不同 ID 的帳戶。', + accountMergeSelectOne: '請選擇要將所有數據合併到其中的一個。', + accountMergeConfirm: '所有數據將合併到 ID 為 {id} 的帳戶中。', + dismiss: '忽略', + merge: '合併', /* General */ frontend: '前端', diff --git a/ui/plugin-auth/Dialog.tsx b/ui/plugin-auth/Dialog.tsx new file mode 100644 index 000000000..cc65f29f0 --- /dev/null +++ b/ui/plugin-auth/Dialog.tsx @@ -0,0 +1,26 @@ +import { JSX } from 'solid-js' +import { DialogHeader } from './DialogHeader' + +interface DialogProps { + name?: string + showBackBtn?: () => boolean + onBack?: () => void + onClose: () => void + title: () => string + children?: () => JSX.Element + extras?: () => JSX.Element +} + +export const Dialog = (props: DialogProps) => { + const { showBackBtn, onBack, onClose, title, ...others } = props + + return ( +
+
+ +
{others.children?.()}
+
+ {others.extras?.()} +
+ ) +} diff --git a/ui/plugin-auth/DialogExample.tsx b/ui/plugin-auth/DialogExample.tsx new file mode 100644 index 000000000..14bd3b2b0 --- /dev/null +++ b/ui/plugin-auth/DialogExample.tsx @@ -0,0 +1,25 @@ +import type { ContextApi } from 'artalk' +import { createSignal } from 'solid-js' +import { Dialog } from './Dialog' + +interface DialogExampleProps { + ctx: ContextApi + onClose: () => void +} + +export const DialogExample = (props: DialogExampleProps) => { + const { ctx, onClose, ...others } = props + + const pages = { + methods: () => <>Tool, + } + + const homePage = 'methods' + const [page, setPage] = createSignal(homePage) + + return ( + ctx.$t('signIn')}> + {() => pages[page()]()} + + ) +} diff --git a/ui/plugin-auth/DialogFooter.tsx b/ui/plugin-auth/DialogFooter.tsx new file mode 100644 index 000000000..a3dd2773c --- /dev/null +++ b/ui/plugin-auth/DialogFooter.tsx @@ -0,0 +1,22 @@ +export interface DialogFooterProps { + noText: string + yesText: string + onYes?: () => void + onNo?: () => void + yesDisabled?: () => boolean +} + +export const DialogFooter = (props: DialogFooterProps) => ( + +) diff --git a/ui/plugin-auth/DialogHeader.tsx b/ui/plugin-auth/DialogHeader.tsx new file mode 100644 index 000000000..dca5b4577 --- /dev/null +++ b/ui/plugin-auth/DialogHeader.tsx @@ -0,0 +1,20 @@ +export interface DialogHeaderProps { + title: () => string + showBackBtn?: () => boolean + onBack?: () => void + onClose: () => void +} + +export const DialogHeader = (props: DialogHeaderProps) => { + const { title, showBackBtn, onBack, onClose } = props + + return ( +
+ {showBackBtn?.() && ( +
+ )} +
{title()}
+
onClose()}>
+
+ ) +} diff --git a/ui/plugin-auth/DialogMain.tsx b/ui/plugin-auth/DialogMain.tsx new file mode 100644 index 000000000..31e7c633c --- /dev/null +++ b/ui/plugin-auth/DialogMain.tsx @@ -0,0 +1,111 @@ +import type { ContextApi } from 'artalk' +import { Show, createResource, createSignal } from 'solid-js' +import { Dialog } from './Dialog' +import { DialogMethods } from './DialogMethods' +import { DialogPageLogin } from './DialogPageLogin' +import { DialogPageRegister } from './DialogPageRegister' +import { createLayer } from './lib/layer' +import { DialogMerge } from './merge/DialogMerge' + +interface DialogMainProps { + ctx: ContextApi + onClose: () => void + onSkip: () => void +} + +export const DialogMain = (props: DialogMainProps) => { + const { ctx, onClose, onSkip, ...others } = props + + const [allowAnonymous, setAllowAnonymous] = createSignal(false) + const [methods] = createResource(async () => { + const { data } = await ctx.getApi().conf.getSocialLoginProviders() + setAllowAnonymous(data.anonymous) + return data.providers + .map(({ name, label, icon, path }) => { + const mm: LoginMethod = { name, label, icon, link: path } + if (name === 'email') mm.onClick = () => setPage('login') + return mm + }) + .sort((a, b) => { + // email always on top + if (a.name === 'email') return -1 + if (b.name === 'email') return 1 + // others by label + return a.label.localeCompare(b.label) + }) + }) + + const [title, setTitle] = createSignal('Login') + const onComplete = () => { + onClose() + + ctx.get('editor').getUI().$header.style.display = 'none' + + // Check need to merge + ctx + .getApi() + .auth.checkDataMerge() + .then(({ data }) => { + if (data.need_merge) { + setTimeout(() => { + createLayer(ctx).show((layer) => ( + layer.destroy()} usernames={data.user_names} /> + )) + }, 500) + } + }) + } + + const pages = { + methods: () => ( + + ), + login: () => ( + setPage('register')} + changeTitle={setTitle} + onComplete={onComplete} + /> + ), + register: () => ( + setPage('login')} + changeTitle={setTitle} + onComplete={onComplete} + /> + ), + } + + const homePage = 'methods' + const [page, setPage] = createSignal(homePage) + const showBackBtn = () => page() !== homePage && !(page() == 'login' && methods()?.length === 1) + const backHome = () => { + setPage(homePage) + } + + return ( + ( + +
{ + onSkip() + onClose() + }} + > + {ctx.$t('skipNotVerify')} +
+
+ )} + > + {() => pages[page()]()} +
+ ) +} diff --git a/ui/plugin-auth/DialogMethods.tsx b/ui/plugin-auth/DialogMethods.tsx new file mode 100644 index 000000000..68d4994fa --- /dev/null +++ b/ui/plugin-auth/DialogMethods.tsx @@ -0,0 +1,57 @@ +import { createEffect, createMemo, createSignal, For, Resource } from 'solid-js' +import type { ContextApi } from 'artalk' +import { startOAuthLogin } from './lib/oauth-login' +import { loginByToken } from './lib/token-login' + +export interface DialogMethodsProps { + ctx: ContextApi + methods: Resource + changeTitle: (title: string) => void + onComplete: () => void +} + +export const DialogMethods = (props: DialogMethodsProps) => { + const { ctx, methods } = props + + props.changeTitle(ctx.$t('signIn')) + + const clickHandler = (m: LoginMethod) => { + if (m.onClick) m.onClick() + else if (m.link) + (async () => { + const url = /^(http|https):\/\//.test(m.link!) + ? m.link! + : `${ctx.getConf().server}${m.link}` + const { token } = await startOAuthLogin(ctx, url) + loginByToken(ctx, token) + props.onComplete() + })() + } + + createEffect(() => { + if (methods()?.length === 1) { + const m = methods()![0] + clickHandler(m) + } + }) + + const $methods = ( +
+ + {(m) => ( +
clickHandler(m)}> +
+
{m.label}
+
+ )} +
+
+ ) + + return
{$methods}
+} diff --git a/ui/plugin-auth/DialogPageLogin.tsx b/ui/plugin-auth/DialogPageLogin.tsx new file mode 100644 index 000000000..ab6a4545e --- /dev/null +++ b/ui/plugin-auth/DialogPageLogin.tsx @@ -0,0 +1,64 @@ +import type { ContextApi } from 'artalk' +import { createStore } from 'solid-js/store' +import { loginByApiRes } from './lib/token-login' + +export interface DialogPageLoginProps { + ctx: ContextApi + onRegisterNowClick: () => void + changeTitle: (title: string) => void + onComplete: () => void +} + +export const DialogPageLogin = (props: DialogPageLoginProps) => { + const { ctx, onRegisterNowClick } = props + + props.changeTitle(ctx.$t('signIn')) + + const [fields, setFields] = createStore({ + email: '', + password: '', + }) + + const submitHandler = (e: SubmitEvent) => { + e.preventDefault() + ctx + .getApi() + .auth.loginByEmail(fields) + .then((res) => { + console.log(res.data) + loginByApiRes(ctx, res.data) + props.onComplete() + }) + .catch((err) => { + alert(err.message) + }) + } + + return ( + + ) +} diff --git a/ui/plugin-auth/DialogPageRegister.tsx b/ui/plugin-auth/DialogPageRegister.tsx new file mode 100644 index 000000000..8596b18cf --- /dev/null +++ b/ui/plugin-auth/DialogPageRegister.tsx @@ -0,0 +1,138 @@ +import type { ContextApi } from 'artalk' +import { Show, createSignal, createEffect } from 'solid-js' +import { createStore, reconcile } from 'solid-js/store' +import { VerifyButton } from './VerifyButton' +import { loginByApiRes } from './lib/token-login' + +export interface DialogPageRegisterProps { + ctx: ContextApi + onLoginNowClick: () => void + changeTitle: (title: string) => void + onComplete: () => void +} + +export const DialogPageRegister = (props: DialogPageRegisterProps) => { + const { ctx, onLoginNowClick } = props + + const [mode, setMode] = createSignal<'register' | 'forget'>('register') + + const [fields, setFields] = createStore({ + email: '', + code: '', + username: '', + password: '', + }) + + createEffect(() => { + if (mode() === 'register') props.changeTitle(ctx.$t('signUp')) + else props.changeTitle(ctx.$t('resetPassword')) + setFields({ email: '', code: '', username: '', password: '' }) + }) + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault() + + const data = + mode() === 'register' + ? { + email: fields.email, + code: fields.code, + name: fields.username, + password: fields.password, + } + : { + email: fields.email, + code: fields.code, + password: fields.password, + } + + ctx + .getApi() + .auth.registerByEmail(data) + .then((res) => { + if (mode() === 'register') { + loginByApiRes(ctx, res.data) + props.onComplete() + } else { + onLoginNowClick() + } + }) + .catch((err) => { + alert(err.message) + }) + } + + return ( +
+
+ + setFields('username', e.target.value.trim())} + required + /> + setFields('password', e.target.value.trim())} + required + /> + +
+ setFields('email', e.target.value.trim())} + required + /> + fields.email} /> +
+ setFields('code', e.target.value.trim())} + required + /> + + setFields('password', e.target.value.trim())} + required + /> + + +
+ {ctx.$t('haveAccountPrompt')}{' '} + onLoginNowClick()}> + {ctx.$t('signIn')} + {' '} + |{' '} + + {mode() === 'register' && ( + setMode('forget')}> + {ctx.$t('forgetPassword')} + + )} + {mode() === 'forget' && ( + setMode('register')}> + {ctx.$t('signUp')} + + )} + +
+
+
+ ) +} diff --git a/ui/plugin-auth/EditorUser.tsx b/ui/plugin-auth/EditorUser.tsx new file mode 100644 index 000000000..be6bd94ba --- /dev/null +++ b/ui/plugin-auth/EditorUser.tsx @@ -0,0 +1,66 @@ +import type { ContextApi } from 'artalk' +import { Show, onCleanup, createSignal } from 'solid-js' +import { render } from 'solid-js/web' + +const EditorUser = ({ ctx }: { ctx: ContextApi }) => { + const logoutHandler = () => { + window.confirm(ctx.$t('logoutConfirm')) && + ctx.get('user').update({ + token: '', + nick: '', + email: '', + link: '', + isAdmin: false, + }) + } + + const getUser = () => ({ ...ctx.get('user').getData() }) // Must clone the object to avoid reactivity problem + + const [user, setUser] = createSignal(getUser()) + + const userChangedHandler = (u: any) => { + setUser(getUser()) + } + ctx.on('user-changed', userChangedHandler) + + onCleanup(() => { + ctx.off('user-changed', userChangedHandler) + }) + + return ( +
+ +
+ +
+ + + +
+
+
+
+ ) +} + +export const RenderEditorUser = (ctx: ContextApi) => { + const editor = ctx.get('editor') + const findEl = () => editor.getEl().querySelector('.atk-editor-user-wrap') + + if (!findEl()) { + const el = document.createElement('div') + render(() => , el) + editor.getUI().$header.after(el) + } +} diff --git a/ui/plugin-auth/README.md b/ui/plugin-auth/README.md new file mode 100644 index 000000000..1884978b6 --- /dev/null +++ b/ui/plugin-auth/README.md @@ -0,0 +1,3 @@ +# @artalk/plugin-auth + +食用方法参考:[官方文档](https://artalk.js.org/guide/frontend/auth.html) diff --git a/ui/plugin-auth/VerifyButton.tsx b/ui/plugin-auth/VerifyButton.tsx new file mode 100644 index 000000000..59b68477a --- /dev/null +++ b/ui/plugin-auth/VerifyButton.tsx @@ -0,0 +1,51 @@ +import type { ContextApi } from 'artalk' +import { createSignal } from 'solid-js' + +interface VerifyButtonProps { + ctx: ContextApi + getEmail: () => string + onSend?: () => void +} + +export const VerifyButton = (props: VerifyButtonProps) => { + const { ctx, onSend, getEmail } = props + const [btnText, setBtnText] = createSignal(ctx.$t('verifySend')) + + let sent = false + let timer: any = null + let duration = 60 + const clickHandler = () => { + if (sent) return + sent = true + ctx + .getApi() + .auth.sendVerifyEmail({ + email: getEmail(), + }) + .then(() => { + timer && clearInterval(timer) + timer = setInterval(() => { + if (duration <= 0) { + clearInterval(timer) + setBtnText(ctx.$t('verifyResend')) + sent = false + return + } + duration-- + setBtnText(ctx.$t('waitSeconds', { seconds: `${duration}` })) + }, 1000) + onSend?.() + }) + .catch((e) => { + sent = false + console.log(e.message) + alert(e.message) + }) + } + + return ( +
+ {btnText()} +
+ ) +} diff --git a/ui/plugin-auth/artalk-plugin-shim.d.ts b/ui/plugin-auth/artalk-plugin-shim.d.ts new file mode 100644 index 000000000..696c7df46 --- /dev/null +++ b/ui/plugin-auth/artalk-plugin-shim.d.ts @@ -0,0 +1,9 @@ +import type { ArtalkPlugin } from 'artalk' + +export {} + +declare global { + interface Window { + ArtalkPlugins?: { [name: string]: ArtalkPlugin } + } +} diff --git a/ui/plugin-auth/index.html b/ui/plugin-auth/index.html new file mode 100644 index 000000000..a03f57e6e --- /dev/null +++ b/ui/plugin-auth/index.html @@ -0,0 +1,26 @@ + + + + + + + plugin-auth + + +
+ + + + diff --git a/ui/plugin-auth/lib/layer.ts b/ui/plugin-auth/lib/layer.ts new file mode 100644 index 000000000..d9447c00a --- /dev/null +++ b/ui/plugin-auth/lib/layer.ts @@ -0,0 +1,18 @@ +import type { ContextApi } from 'artalk' +import { JSX } from 'solid-js' +import { render } from 'solid-js/web' + +export const createLayer = (ctx: ContextApi) => { + const layer = ctx.get('layerManager').create('login') + const show = (el: (l: typeof layer) => JSX.Element) => { + const $el = document.createElement('div') + render(() => el(layer), $el) + layer.getEl().append($el.firstChild!) + + layer.show() + } + + return { + show, + } +} diff --git a/ui/plugin-auth/lib/oauth-login.ts b/ui/plugin-auth/lib/oauth-login.ts new file mode 100644 index 000000000..816267741 --- /dev/null +++ b/ui/plugin-auth/lib/oauth-login.ts @@ -0,0 +1,46 @@ +import type { ContextApi } from 'artalk' + +let watchTimer: any = null +let messageHandler: ((evt: MessageEvent) => void) | null = null + +const clearListener = () => { + watchTimer && clearInterval(watchTimer) + messageHandler && window.removeEventListener('message', messageHandler) +} + +export const startOAuthLogin = (ctx: ContextApi, url: string) => { + clearListener() + + const width = 1020 + const height = 618 + const left = (window.innerWidth - width) / 2 + const top = (window.innerHeight - height) / 2 + + const handler = window.open( + url, + '_blank', + `width=${width},height=${height},left=${left},top=${top},scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no`, + ) + + return new Promise<{ token: string }>((resolve, reject) => { + watchTimer = setInterval(() => { + if (handler?.closed) { + clearListener() + reject(new Error('Login canceled')) + return + } + handler?.postMessage({ type: 'ATK_LOGIN' }, '*') + }, 1000) + + messageHandler = ({ data }: MessageEvent): void => { + if (data.type === 'ATK_AUTH_CALLBACK' && data.payload) { + clearListener() + + handler?.close() + console.log(data.payload) + resolve({ token: data.payload }) + } + } + window.addEventListener('message', messageHandler) + }) +} diff --git a/ui/plugin-auth/lib/token-login.ts b/ui/plugin-auth/lib/token-login.ts new file mode 100644 index 000000000..d05280c7a --- /dev/null +++ b/ui/plugin-auth/lib/token-login.ts @@ -0,0 +1,38 @@ +import type { ContextApi } from 'artalk' + +interface ResponseLoginData { + user: { + name: string + email: string + link: string + is_admin: boolean + } + token: string +} + +export const loginByApiRes = (ctx: ContextApi, data: ResponseLoginData) => { + const { user, token } = data + ctx.get('user').update({ + nick: user.name, + email: user.email, + link: user.link, + isAdmin: user.is_admin, + token, + }) +} + +export const loginByToken = (ctx: ContextApi, token: string) => { + ctx.get('user').update({ token }) + ctx + .getApi() + .user.getUser() + .then((res) => { + const { user } = res.data + ctx.get('user').update({ + nick: user.name, + email: user.email, + link: user.link, + isAdmin: user.is_admin, + }) + }) +} diff --git a/ui/plugin-auth/main.tsx b/ui/plugin-auth/main.tsx new file mode 100644 index 000000000..5c346712d --- /dev/null +++ b/ui/plugin-auth/main.tsx @@ -0,0 +1,68 @@ +/* @refresh reload */ +import './style.scss' +import Artalk, { ArtalkPlugin } from 'artalk' +import { DialogMain } from './DialogMain' +import { createLayer } from './lib/layer' +import { RenderEditorUser } from './EditorUser' + +export const ArtalkAuthPlugin: ArtalkPlugin = (ctx) => { + ctx.getApiHandlers().add('need_auth_login', () => { + openAuthDialog() + throw new Error('Login required') + }) + + let anonymous = false + const refreshBtn = () => { + ctx.get('editor').getUI().$submitBtn.innerText = + ctx.get('user').getData().token || anonymous + ? ctx.conf.sendBtn || ctx.$t('send') + : ctx.$t('signIn') + } + + ctx.watchConf(['locale', 'sendBtn'], () => refreshBtn()) + ctx.on('user-changed', () => refreshBtn()) + + ctx.on('mounted', () => { + ctx.get('editor').getUI().$header.style.display = 'none' + + RenderEditorUser(ctx) + }) + + const onSkip = () => { + ctx.get('editor').getUI().$header.style.display = '' + ctx.get('editor').getUI().$nick.focus() + ctx.updateConf({ + beforeSubmit: undefined, + }) + + anonymous = true + refreshBtn() + } + + const openAuthDialog = () => { + createLayer(ctx).show((layer) => ( + layer.destroy()} onSkip={onSkip} /> + )) + } + + ctx.updateConf({ + beforeSubmit: (editor, next) => { + if (!ctx.get('user').getData().token) { + openAuthDialog() + } else { + next() + } + }, + }) +} + +if ((window as any)?.Artalk) { + ;(window as any).Artalk.use(ArtalkAuthPlugin) +} else if (Artalk) { + Artalk.use(ArtalkAuthPlugin) +} + +if (window) { + !window.ArtalkPlugins && (window.ArtalkPlugins = {}) + window.ArtalkPlugins.Auth = ArtalkAuthPlugin +} diff --git a/ui/plugin-auth/merge/DialogMerge.tsx b/ui/plugin-auth/merge/DialogMerge.tsx new file mode 100644 index 000000000..cce0442f6 --- /dev/null +++ b/ui/plugin-auth/merge/DialogMerge.tsx @@ -0,0 +1,29 @@ +import type { ContextApi } from 'artalk' +import { createSignal } from 'solid-js' +import { Dialog } from '../Dialog' +import { DialogMergePageConfirm } from './DialogMergePageConfirm' + +interface DialogMergeProps { + ctx: ContextApi + usernames: string[] + onClose: () => void +} + +export const DialogMerge = (props: DialogMergeProps) => { + const { ctx, onClose, ...others } = props + + const pages = { + confirm: () => ( + + ), + } + + const homePage = 'confirm' + const [page, setPage] = createSignal(homePage) + + return ( + 'Merge Tool'}> + {() => pages[page()]()} + + ) +} diff --git a/ui/plugin-auth/merge/DialogMergePageConfirm.tsx b/ui/plugin-auth/merge/DialogMergePageConfirm.tsx new file mode 100644 index 000000000..82b7b4ce9 --- /dev/null +++ b/ui/plugin-auth/merge/DialogMergePageConfirm.tsx @@ -0,0 +1,65 @@ +import { createSignal } from 'solid-js' +import type { ContextApi } from 'artalk' +import { DialogFooter } from '../DialogFooter' +import { loginByToken } from '../lib/token-login' + +interface DialogMergePageConfirmProps { + ctx: ContextApi + usernames: string[] + onClose: () => void +} + +export const DialogMergePageConfirm = (props: DialogMergePageConfirmProps) => { + const { usernames, ctx, onClose } = props + const [targetName, setTargetName] = createSignal('') + + const selectUsername = (u: string) => { + setTargetName(targetName() !== u ? u : '') + } + + const confirmMerge = () => { + ctx + .getApi() + .auth.applyDataMerge({ + user_name: targetName(), + }) + .then(({ data }) => { + loginByToken(ctx, data.user_token) + console.log(data) + onClose() + }) + } + + return ( +
+
{ctx.$t('accountMergeNotice')}
+
+ {usernames.map((u) => ( +
selectUsername(u)} + > +
{u}
+
+ ))} +
+
+ {!targetName() + ? ctx.$t('accountMergeSelectOne') + : ctx.$t('accountMergeConfirm', { id: targetName() })} +
+ { + onClose() + }} + onYes={() => { + confirmMerge() + }} + yesDisabled={() => !targetName()} + /> +
+ ) +} diff --git a/ui/plugin-auth/package.json b/ui/plugin-auth/package.json new file mode 100644 index 000000000..18c07f566 --- /dev/null +++ b/ui/plugin-auth/package.json @@ -0,0 +1,40 @@ +{ + "name": "@artalk/plugin-auth", + "version": "0.0.1", + "license": "LGPL-3.0-only", + "description": "Auth plugin for artalk", + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/artalk-plugin-auth.js", + "module": "./dist/artalk-plugin-auth.mjs", + "types": "./dist/artalk-plugin-auth.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/artalk-plugin-auth.d.ts", + "default": "./dist/artalk-plugin-auth.mjs" + }, + "require": { + "types": "./dist/artalk-plugin-auth.d.cts", + "default": "./dist/artalk-plugin-auth.cjs" + } + } + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "prepublish": "pnpm build", + "publish": "pnpm publish --access=public" + }, + "dependencies": { + "artalk": "workspace:^", + "solid-js": "^1.8.14" + }, + "devDependencies": { + "vite-plugin-css-injected-by-js": "^3.4.0", + "vite-plugin-solid": "^2.9.1" + } +} diff --git a/ui/plugin-auth/public/artalk-plugin-auth.d.cts b/ui/plugin-auth/public/artalk-plugin-auth.d.cts new file mode 100644 index 000000000..8c0124a87 --- /dev/null +++ b/ui/plugin-auth/public/artalk-plugin-auth.d.cts @@ -0,0 +1,3 @@ +import type { ArtalkPlugin } from 'artalk' + +declare const ArtalkAuthPlugin: ArtalkPlugin diff --git a/ui/plugin-auth/public/artalk-plugin-auth.d.ts b/ui/plugin-auth/public/artalk-plugin-auth.d.ts new file mode 100644 index 000000000..8c0124a87 --- /dev/null +++ b/ui/plugin-auth/public/artalk-plugin-auth.d.ts @@ -0,0 +1,3 @@ +import type { ArtalkPlugin } from 'artalk' + +declare const ArtalkAuthPlugin: ArtalkPlugin diff --git a/ui/plugin-auth/style.scss b/ui/plugin-auth/style.scss new file mode 100644 index 000000000..f8a2b7f41 --- /dev/null +++ b/ui/plugin-auth/style.scss @@ -0,0 +1,340 @@ +.atk-auth-plugin-dialog-wrap { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; + + .atk-skip-btn { + cursor: pointer; + margin: 0 auto; + margin-top: 10px; + display: flex; + justify-content: center; + place-items: center; + height: 40px; + padding: 0 20px; + font-size: 16px; + border-radius: 50px; + color: var(--at-color-bg); + + &:hover { + opacity: 0.9; + } + } +} + +.atk-auth-plugin-dialog { + $h: 500px; + $w: 360px; + + overflow: hidden; + transform: translateY(0px) scale(1); + animation: atkLoginDialogShowAnim 0.3s ease-in-out; + will-change: transform, opacity, width, height; + transition-property: transform, opacity, width, height; + transition-duration: 0.2s; + transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); + display: flex; + flex-direction: column; + width: $w; + max-height: $h; + background: var(--at-color-bg); + border-radius: 18px; + + @keyframes atkLoginDialogShowAnim { + 0% { + opacity: 0; + transform: translateY(15px) scale(1.02); + } + 100% { + opacity: 1; + transform: translateY(0px) scale(1); + } + } + + .atk-auth-dialog-title { + display: flex; + justify-content: space-between; + place-items: center; + padding: 25px 25px 0; + font-size: 20px; + font-weight: 500; + color: var(--at-color-font); + + .atk-text { + margin: 0 10px; + } + + .atk-icon { + display: flex; + justify-content: center; + place-items: center; + height: 40px; + width: 40px; + border-radius: 50%; + + &:hover { + background: var(--at-color-bg-grey); + } + } + } + + .atk-view-wrap { + max-height: calc(100% - 65px); + overflow-y: auto; + } + + // Methods Page + .atk-methods-page .atk-methods { + padding: 12px 20px 20px 20px; + + .atk-method-item { + display: flex; + align-items: center; + padding: 15px 25px; + cursor: pointer; + border-radius: 10px; + + &:hover { + background: var(--at-color-bg-grey); + } + + .atk-method-icon { + width: 40px; + height: 40px; + margin-right: 20px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + } + + .atk-method-text { + font-size: 16px; + font-weight: 500; + color: var(--at-color-font); + } + } + } + + .atk-register-page { + } + + .atk-form { + padding: 20px 30px; + + .atk-form-bottom { + padding: 5px 0; + text-align: center; + font-size: 14px; + + .atk-link { + cursor: pointer; + text-align: center; + color: var(--at-color-main); + &:hover { + text-decoration: underline; + } + } + } + + input { + width: 100%; + padding: 10px 20px; + margin: 5px 0; + font-size: 16px; + border-radius: 10px; + border: 1px solid var(--at-color-bg-grey); + background: var(--at-color-bg-grey); + color: var(--at-color-font); + transition: border-color 0.2s; + outline: none; + + &:focus { + border-color: var(--at-color-primary); + } + } + + button[type='submit'] { + width: 100%; + padding: 10px 20px; + margin: 10px 0; + font-size: 16px; + border-radius: 10px; + background: var(--at-color-main); + color: var(--at-color-bg); + cursor: pointer; + transition: background 0.2s; + border: 0; + + &:hover { + opacity: 0.9; + } + } + + .atk-input-grp { + position: relative; + + & > input { + padding-right: 115px; + } + } + + .atk-input-grp-btn { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + padding: 10px 15px; + font-size: 14px; + color: var(--at-color-main); + cursor: pointer; + } + } + + &[data-dialog-name='merge'] { + background-size: 100% auto; + background-position: center top; + background-repeat: no-repeat; + background-image: url('data:image/svg+xml,'); + + .atk-auth-dialog-title { + padding-top: 50px; + color: #361e6a; + } + + .atk-text:first-of-type { + color: #472a84; + } + } + + /* Merge Tool */ + .atk-merge-confirm-page { + padding: 15px 35px 30px 35px; + + .atk-text { + font-size: 16px; + color: var(--at-color-font); + margin-bottom: 20px; + } + + .atk-usernames { + display: flex; + flex-direction: column; + color: var(--at-color-font); + margin-bottom: 20px; + + .atk-item { + border-radius: 10px; + padding: 10px 15px; + margin: 5px 0; + display: flex; + justify-content: space-between; + place-items: center; + cursor: pointer; + border: 1px solid var(--at-color-border); + + &.active { + background: var(--at-color-bg-grey); + } + + .atk-username { + flex: auto; + text-align: center; + } + } + } + + .atk-dialog-footer { + display: flex; + justify-content: space-between; + place-items: center; + margin-top: 20px; + + .atk-btn { + padding: 10px 20px; + font-size: 16px; + border-radius: 10px; + cursor: pointer; + + &:hover { + opacity: 0.9; + } + + &.atk-btn-no { + background: var(--at-color-bg-grey); + color: var(--at-color-font); + } + + &.atk-btn-yes { + background: var(--at-color-main); + color: var(--at-color-bg); + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + } + } +} + +.atk-main-editor { + .atk-editor-user-wrap { + margin-top: 10px; + + .atk-editor-user { + display: inline-flex; + flex-direction: row; + place-items: center; + cursor: pointer; + border-radius: 3px; + padding: 0 10px; + + .atk-user-btn { + display: flex; + place-items: center; + justify-content: center; + padding: 5px; + border-radius: 3px; + + &:hover { + background: var(--at-color-bg-grey-transl); + } + + &:not(:last-child) { + margin-right: 5px; + } + } + + .atk-user-profile-btn { + padding-left: 12px; + padding-right: 12px; + } + + .atk-name { + color: var(--at-color-font); + } + + .atk-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 15px; + background-position: center; + background-size: cover; + background-color: #d5d5d5; + } + + .atk-logout { + height: 100%; + color: var(--at-color-font); + font-size: 1.3em; + display: flex; + place-items: center; + justify-content: center; + } + } + } +} diff --git a/ui/plugin-auth/tsconfig.json b/ui/plugin-auth/tsconfig.json new file mode 100644 index 000000000..a7c0c110b --- /dev/null +++ b/ui/plugin-auth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "baseUrl": ".", + "outDir": "dist" + } +} diff --git a/ui/plugin-auth/types.d.ts b/ui/plugin-auth/types.d.ts new file mode 100644 index 000000000..98af77c17 --- /dev/null +++ b/ui/plugin-auth/types.d.ts @@ -0,0 +1,7 @@ +interface LoginMethod { + name: string + label: string + icon: string + link?: string + onClick?: () => void +} diff --git a/ui/plugin-auth/vite.config.ts b/ui/plugin-auth/vite.config.ts new file mode 100644 index 000000000..c44003654 --- /dev/null +++ b/ui/plugin-auth/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vite' +import { resolve, dirname } from 'node:path' +import tsconfigPaths from 'vite-tsconfig-paths' +import solidPlugin from 'vite-plugin-solid' +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +function getFileName(name: string, format: string) { + if (format == 'umd') return `${name}.js` + else if (format == 'cjs') return `${name}.cjs` + else if (format == 'es') return `${name}.mjs` + return `${name}.${format}.js` +} + +export default defineConfig({ + build: { + target: 'es2015', + minify: 'terser', + lib: { + entry: resolve(__dirname, './main.tsx'), + name: 'artalk-plugin-auth', + fileName: (format) => getFileName('artalk-plugin-auth', format), + formats: ['es', 'umd', 'cjs', 'iife'], + }, + rollupOptions: { + external: ['artalk'], + output: { + globals: { + artalk: 'Artalk', + }, + extend: true, + }, + }, + }, + plugins: [tsconfigPaths(), solidPlugin(), cssInjectedByJsPlugin()], +})