├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── components ├── Footer.tsx ├── Head.tsx └── Layout.tsx ├── config.common.ts ├── config.server.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── auth │ │ └── [...nextauth].ts │ ├── comment │ │ ├── [commentId].ts │ │ └── [commentId] │ │ │ ├── approve.ts │ │ │ └── replyAsModerator.ts │ ├── open │ │ ├── approve.ts │ │ ├── comments.ts │ │ ├── project │ │ │ └── [projectId] │ │ │ │ └── comments │ │ │ │ ├── count.ts │ │ │ │ └── latest.ts │ │ └── unsubscribe.ts │ ├── project │ │ ├── [projectId].ts │ │ └── [projectId] │ │ │ ├── comments.ts │ │ │ ├── data │ │ │ └── import.ts │ │ │ └── generateToken.ts │ ├── projects.ts │ ├── subscription.ts │ └── user.ts ├── dashboard │ ├── index.tsx │ └── project │ │ ├── [projectId].tsx │ │ └── [projectId] │ │ └── settings.tsx ├── error.tsx ├── forbidden.tsx ├── getting-start.tsx ├── index.tsx ├── open │ └── approve.tsx └── privacy-policy.tsx ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── mysql │ ├── migrations │ │ ├── 20211121064912_init │ │ │ └── migration.sql │ │ ├── 20211124155649_add_display_name │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── pgsql │ ├── migrations │ │ ├── 20210417102821_init │ │ │ └── migration.sql │ │ ├── 20210422105059_add_project_token │ │ │ └── migration.sql │ │ ├── 20210423071344_notification │ │ │ └── migration.sql │ │ ├── 20210426145530_project_webhook │ │ │ └── migration.sql │ │ ├── 20210427123424_project_deleted_at │ │ │ └── migration.sql │ │ ├── 20211124155429_add_display_name │ │ │ └── migration.sql │ │ ├── 20230713082802_subscription │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma └── sqlite │ ├── migrations │ ├── 20210419153654_init │ │ └── migration.sql │ ├── 20210422061617_add_project_token │ │ └── migration.sql │ ├── 20210422101358_add_fetch_latest_comments_at │ │ └── migration.sql │ ├── 20210422171715_notification_info │ │ └── migration.sql │ ├── 20210423063317_project_notification_settings │ │ └── migration.sql │ ├── 20210426135406_add_project_webhook │ │ └── migration.sql │ ├── 20210427114259_add_project_deleted_at │ │ └── migration.sql │ ├── 20211124142411_add_display_name │ │ └── migration.sql │ ├── 20230713042856_subscription │ │ └── migration.sql │ ├── 20230713055831_subscription │ │ └── migration.sql │ ├── 20230713070441_usage │ │ └── migration.sql │ └── migration_lock.toml │ └── schema.prisma ├── public ├── discussion.png ├── doc │ ├── README.md │ ├── _sidebar.md │ ├── advanced │ │ ├── i18n.md │ │ ├── sdk.md │ │ ├── show-comment-count.md │ │ └── webhook.md │ ├── contributing.md │ ├── faq.md │ ├── features │ │ ├── moderation.md │ │ └── notification.md │ ├── images │ │ ├── advance-notification-settings.png │ │ ├── disable-notification-in-project.png │ │ ├── email.png │ │ ├── enable_webhook.png │ │ ├── notification.png │ │ ├── pull-based-notification.png │ │ ├── redeploy.png │ │ ├── webhook-notification.png │ │ └── y3FkAY.png │ ├── index.html │ ├── integration │ │ ├── docsify.md │ │ ├── jekyll.md │ │ ├── mkdocs.md │ │ └── publii.md │ └── self-host │ │ ├── docker.md │ │ ├── manual.md │ │ ├── railway.md │ │ └── vercel.md ├── images │ ├── artworks │ │ ├── logo-256.png │ │ └── logo-gray-256.png │ ├── docsify.svg │ ├── email_notification.png │ ├── hexo.svg │ ├── intro-approval.png │ ├── intro-bot.png │ ├── intro-dashboard-2.png │ ├── intro-dashboard.png │ ├── intro-email.png │ ├── intro-widget.png │ ├── landing.png │ ├── og.png │ ├── react.png │ ├── svelte.svg │ ├── telegram_bot.png │ ├── vanilla.png │ └── vue.png ├── js │ └── cusdis.docsify.js └── landing.png ├── scripts ├── vite.config.js ├── vite.count.config.js ├── vite.iframe.config.js ├── vite.sdk.config.js └── vite.widget.config.js ├── service ├── auth.service.ts ├── comment.service.ts ├── data.service.ts ├── email.service.ts ├── hook.service.ts ├── index.ts ├── notification.service.ts ├── page.service.ts ├── project.service.ts ├── stat.service.ts ├── subscription.service.ts ├── token.service.ts ├── usage.service.ts ├── user.service.ts ├── viewData.service.ts └── webhook.service.ts ├── style.css ├── templates ├── confirm_reply_notification.ts └── new_comment.ts ├── tsconfig.json ├── utils.client.ts ├── utils.server.ts ├── widget ├── README.md ├── Widget.svelte ├── components │ ├── Comment.svelte │ └── Reply.svelte ├── count.html ├── count.js ├── i18n.js ├── iframe.js ├── index.html ├── index.js ├── lang │ ├── ar.js │ ├── bg.js │ ├── ca.js │ ├── cs.js │ ├── de.js │ ├── en.js │ ├── es.js │ ├── fi.js │ ├── fr.js │ ├── hu.js │ ├── id.js │ ├── ja.js │ ├── ko.js │ ├── oc.js │ ├── pl │ ├── pt-br.js │ ├── ru.js │ ├── th.js │ ├── tr.js │ ├── vi.js │ ├── zh-cn.js │ └── zh-tw.js ├── package.json ├── sdk.js └── theme.css └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: djyde 4 | patreon: # Replace with a single Patreon username 5 | open_collective: cusdis 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - dev 8 | paths-ignore: 9 | - '**.md' 10 | 11 | jobs: 12 | image-test: 13 | name: Image Test 14 | runs-on: ubuntu-latest 15 | permissions: write-all 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Lower case for ghcr 19 | id: ghcr_string 20 | uses: ASzc/change-string-case-action@v1 21 | with: 22 | string: ${{ github.event.repository.full_name }} 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v1 26 | 27 | - name: Cache Docker layers 28 | uses: actions/cache@v2 29 | with: 30 | path: /tmp/.buildx-cache 31 | key: ${{ runner.os }}-buildx-${{ github.sha }} 32 | restore-keys: | 33 | ${{ runner.os }}-buildx- 34 | 35 | - name: Build image for testing 36 | uses: docker/build-push-action@v2 37 | with: 38 | context: . 39 | platforms: linux/amd64 40 | load: true 41 | tags: | 42 | ${{ steps.ghcr_string.outputs.lowercase }} 43 | cache-from: type=local,src=/tmp/.buildx-cache 44 | cache-to: type=local,dest=/tmp/.buildx-cache-new 45 | 46 | - name: Move cache 47 | run: | 48 | rm -rf /tmp/.buildx-cache 49 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 50 | 51 | - name: Install trivy 52 | run: | 53 | wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - 54 | echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | sudo tee -a /etc/apt/sources.list.d/trivy.list 55 | sudo apt-get update 56 | sudo apt-get install trivy -y 57 | 58 | - name: Scan for CVEs 59 | uses: mathiasvr/command-output@v1 60 | id: trivy 61 | with: 62 | run: | 63 | trivy image --no-progress --severity "HIGH,CRITICAL" ${{ steps.ghcr_string.outputs.lowercase }} 64 | 65 | - name: Comment CVE info on PR 66 | uses: thollander/actions-comment-pull-request@v1 67 | with: 68 | message: | 69 | ``` 70 | ${{ steps.trivy.outputs.stdout }} 71 | ``` 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: Publish to Registry 9 | uses: elgohr/Publish-Docker-Github-Action@master 10 | with: 11 | name: djyde/cusdis 12 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 13 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 14 | tag_semver: true 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node 5 | 6 | ### macOS ### 7 | # General 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | 12 | # Icon must end with two \r 13 | Icon 14 | 15 | 16 | # Thumbnails 17 | ._* 18 | 19 | # Files that might appear in the root of a volume 20 | .DocumentRevisions-V100 21 | .fseventsd 22 | .Spotlight-V100 23 | .TemporaryItems 24 | .Trashes 25 | .VolumeIcon.icns 26 | .com.apple.timemachine.donotpresent 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | ### Node ### 36 | # Logs 37 | logs 38 | *.log 39 | npm-debug.log* 40 | yarn-debug.log* 41 | yarn-error.log* 42 | lerna-debug.log* 43 | 44 | # Diagnostic reports (https://nodejs.org/api/report.html) 45 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 46 | 47 | # Runtime data 48 | pids 49 | *.pid 50 | *.seed 51 | *.pid.lock 52 | 53 | # Directory for instrumented libs generated by jscoverage/JSCover 54 | lib-cov 55 | 56 | # Coverage directory used by tools like istanbul 57 | coverage 58 | *.lcov 59 | 60 | # nyc test coverage 61 | .nyc_output 62 | 63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 64 | .grunt 65 | 66 | # Bower dependency directory (https://bower.io/) 67 | bower_components 68 | 69 | # node-waf configuration 70 | .lock-wscript 71 | 72 | # Compiled binary addons (https://nodejs.org/api/addons.html) 73 | build/Release 74 | 75 | # Dependency directories 76 | node_modules/ 77 | jspm_packages/ 78 | 79 | # TypeScript v1 declaration files 80 | typings/ 81 | 82 | # TypeScript cache 83 | *.tsbuildinfo 84 | 85 | # Optional npm cache directory 86 | .npm 87 | 88 | # Optional eslint cache 89 | .eslintcache 90 | 91 | # Microbundle cache 92 | .rpt2_cache/ 93 | .rts2_cache_cjs/ 94 | .rts2_cache_es/ 95 | .rts2_cache_umd/ 96 | 97 | # Optional REPL history 98 | .node_repl_history 99 | 100 | # Output of 'npm pack' 101 | *.tgz 102 | 103 | # Yarn Integrity file 104 | .yarn-integrity 105 | 106 | # dotenv environment variables file 107 | .env 108 | .env.test 109 | .env*.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | .cache 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | .next 117 | 118 | # Nuxt.js build / generate output 119 | .nuxt 120 | dist 121 | 122 | # Gatsby files 123 | .cache/ 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | # https://nextjs.org/blog/next-9-1#public-directory-support 126 | # public 127 | 128 | # vuepress build output 129 | .vuepress/dist 130 | 131 | # Serverless directories 132 | .serverless/ 133 | 134 | # FuseBox cache 135 | .fusebox/ 136 | 137 | # DynamoDB Local files 138 | .dynamodb/ 139 | 140 | # TernJS port file 141 | .tern-port 142 | 143 | # Stores VSCode versions used for testing VSCode extensions 144 | .vscode-test 145 | 146 | ### VisualStudioCode ### 147 | .vscode/* 148 | !.vscode/tasks.json 149 | !.vscode/launch.json 150 | *.code-workspace 151 | 152 | ### VisualStudioCode Patch ### 153 | # Ignore all local history of files 154 | .history 155 | .ionide 156 | .idea/ 157 | 158 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 159 | 160 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 161 | 162 | generated/* 163 | db.sqlite 164 | public/js 165 | widget/index.html 166 | db.sqlite-journal -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.15 as builder 2 | 3 | VOLUME [ "/data" ] 4 | 5 | ARG DB_TYPE=sqlite 6 | ENV DB_TYPE=$DB_TYPE 7 | 8 | RUN apk add --no-cache python3 py3-pip make gcc g++ 9 | 10 | COPY . /app 11 | 12 | COPY package.json yarn.lock /app/ 13 | 14 | WORKDIR /app 15 | 16 | RUN npm install -g pnpm 17 | RUN yarn install --frozen-lockfile && npx browserslist@latest --update-db 18 | RUN npm run build:without-migrate 19 | 20 | FROM node:16-alpine3.15 as runner 21 | 22 | ENV NODE_ENV=production 23 | ARG DB_TYPE=sqlite 24 | ENV DB_TYPE=$DB_TYPE 25 | 26 | WORKDIR /app 27 | 28 | COPY --from=builder /app/node_modules ./node_modules 29 | COPY --from=builder /app/public ./public 30 | COPY --from=builder /app/.next ./.next 31 | COPY . /app 32 | 33 | EXPOSE 3000/tcp 34 | 35 | CMD ["npm", "run", "start:with-migrate"] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

logo

3 | 4 | Cusdis is an open-source, lightweight (~5kb gzip), privacy-friendly alternative to Disqus. 5 | 6 | > Contact me if you want to buy/acquire this project 💖 7 | 8 | ![](/public/images/landing.png) 9 | 10 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fdjyde%2Fcusdis&plugins=postgresql&envs=NEXTAUTH_URL%2CDB_TYPE%2CDB_URL%2CUSERNAME%2CPASSWORD%2CHOST%2CJWT_SECRET%2CPORT&NEXTAUTH_URLDesc=Don%27t+modify+it&DB_TYPEDesc=Don%27t+modify+it&DB_URLDesc=Don%27t+modify+it&USERNAMEDesc=Username+to+sign+in&PASSWORDDesc=Password+to+sign+in&HOSTDesc=Don%27t+modify+it&JWT_SECRETDesc=A+secret+key+to+encrypt+JWT+token&PORTDesc=Don%27t+modify+it&NEXTAUTH_URLDefault=%24%7B%7B+RAILWAY_STATIC_URL+%7D%7D&DB_TYPEDefault=pgsql&DB_URLDefault=%24%7B%7B+DATABASE_URL+%7D%7D&HOSTDefault=https%3A%2F%2F%24%7B%7B+RAILWAY_STATIC_URL+%7D%7D&PORTDefault=3000&referralCode=randyloop) 11 | 12 | ## 💝 Sponsor this project 13 | 14 | If you like Cusdis, please consider sponsoring us to help us be sustainable. 15 | 16 | ### Principle Sponsor 17 | 18 | [![Slide 16_9 - 1](https://github.com/djyde/cusdis/assets/914329/0a773f41-6baf-4bdc-897e-e96f56991acc)](https://epubkit.app) 19 | 20 | 21 | [![Contributors](https://opencollective.com/cusdis/tiers/organization-support/0/avatar.svg)](https://opencollective.com/cusdis/tiers/organization-support/0/website) 22 | 23 | [Become a principle sponsor](https://opencollective.com/cusdis/contribute/organization-support-27992/checkout) 24 | 25 | ### Sponsors / Backers 26 | 27 | [![Contributors](https://opencollective.com/cusdis/tiers/sponsor.svg?avatarHeight=50)](https://opencollective.com/cusdis) 28 | [![Contributors](https://opencollective.com/cusdis/tiers/backer.svg?avatarHeight=50)](https://opencollective.com/cusdis) 29 | 30 | ## Features 31 | 32 | - Lightweight comment widget, with i18n, dark mode. 33 | - Email notification 34 | - Webhook 35 | - Easy to self-host 36 | - Many integrations 37 | 38 | ## Documentation 39 | 40 | https://cusdis.com/doc 41 | 42 | ## Community 43 | 44 | [Discord](https://discord.gg/eDs5fc4Jcq) 45 | 46 | ## FAQ 47 | 48 | ## Compared to Disqus 49 | 50 | Cusdis is not designed to be a complete alternative to Disqus. It's aim is to implement a minimalist and embeddable comment system for small websites (such as your static blog). 51 | 52 | Given below are the pros and cons of Cusdis: 53 | 54 | ### Pros 55 | 56 | - Cusdis is open-source and self-hostable. Hence, you own your data. 57 | - The SDK is lightweight(~5kb gzipped). 58 | - Cusdis doesn't require your user to sign in to make a comment. 59 | - Cusdis doesn't use cookies at all. 60 | 61 | ### Cons 62 | 63 | - Cusdis is on the early stages of its development. 64 | - There is no spam filter, hence, you will have to manually moderate your comment section and comments won't be displayed until you approve them. 65 | - Disqus is a company, we aren't. 66 | 67 | ## Contributing 68 | 69 | [Contributing Guide](https://cusdis.com/doc#/contributing) 70 | 71 | If you are going to make a PR, remember to choose `dev` as the base branch. 72 | 73 | # License 74 | 75 | GNU GPLv3 76 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, Box, Group, List, Stack, Text, Image, Center } from '@mantine/core' 2 | import * as React from 'react' 3 | import { VERSION } from '../utils.client' 4 | 5 | export function Footer(props: { 6 | maxWidth?: string 7 | }) { 8 | return ( 9 | 10 |
11 | 12 | 13 | 14 | Contact 15 | 16 | 17 | Twitter 18 | 19 | 20 | Blog 21 | 22 | 23 | GitHub 24 | 25 | 26 | hi@cusdis.com 27 | 28 | 29 | 30 | 31 | Resources 32 | 33 | 34 | Documentation 35 | 36 | 37 | Sponsor 38 | 39 | 40 | Privacy Policy 41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 | 49 | v{VERSION} 50 | 51 | 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/Head.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import NextHead from 'next/head' 3 | 4 | export function Head(props: { 5 | title: string 6 | }) { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {props.title} 26 | 27 | 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /config.common.ts: -------------------------------------------------------------------------------- 1 | export enum UsageLabel { 2 | ApproveComment = 'approve_comment', 3 | QuickApprove = 'quick_approve', 4 | CreateSite = 'create_site' 5 | } 6 | 7 | export const usageLimitation = { 8 | [UsageLabel.ApproveComment]: 100, 9 | [UsageLabel.QuickApprove]: 10, 10 | [UsageLabel.CreateSite]: 1 11 | } 12 | -------------------------------------------------------------------------------- /config.server.ts: -------------------------------------------------------------------------------- 1 | import Providers, { AppProviders } from 'next-auth/providers' 2 | import { prisma, resolvedConfig } from './utils.server' 3 | 4 | /** 5 | * Auth Providers 6 | * https://next-auth.js.org/configuration/providers 7 | */ 8 | 9 | const providers: AppProviders = [] 10 | 11 | if (resolvedConfig.useLocalAuth) { 12 | providers.push( 13 | Providers.Credentials({ 14 | name: 'Username', 15 | credentials: { 16 | username: { 17 | label: 'Username', 18 | type: 'text', 19 | placeholder: 'env: USERNAME', 20 | }, 21 | password: { 22 | label: 'Password', 23 | type: 'password', 24 | placeholder: 'env: PASSWORD', 25 | }, 26 | }, 27 | async authorize(credentials: { username: string; password: string }) { 28 | if ( 29 | credentials.username === process.env.USERNAME && 30 | credentials.password === process.env.PASSWORD 31 | ) { 32 | const user = await prisma.user.upsert({ 33 | where: { 34 | id: credentials.username, 35 | }, 36 | create: { 37 | id: credentials.username, 38 | name: credentials.username, 39 | }, 40 | update: { 41 | id: credentials.username, 42 | name: credentials.username, 43 | }, 44 | }) 45 | return user 46 | } else { 47 | return null 48 | } 49 | }, 50 | }), 51 | ) 52 | } 53 | 54 | if (resolvedConfig.useGithub) { 55 | providers.push( 56 | Providers.GitHub({ 57 | clientId: process.env.GITHUB_ID, 58 | clientSecret: process.env.GITHUB_SECRET, 59 | scope: 'read:user,user:email', 60 | }), 61 | ) 62 | } 63 | 64 | if (resolvedConfig.useGitlab) { 65 | providers.push( 66 | Providers.GitLab({ 67 | clientId: process.env.GITLAB_ID, 68 | clientSecret: process.env.GITLAB_SECRET, 69 | }) 70 | ) 71 | } 72 | 73 | if (resolvedConfig.google.id) { 74 | providers.push( 75 | Providers.Google({ 76 | clientId: resolvedConfig.google.id, 77 | clientSecret: resolvedConfig.google.secret, 78 | }), 79 | ) 80 | } 81 | export const authProviders = providers 82 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rewrites() { 3 | return [ 4 | { 5 | source: '/doc', 6 | destination: '/doc/index.html' 7 | } 8 | ] 9 | } 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cusdis", 3 | "scripts": { 4 | "dev": "DB_TYPE=sqlite npm run dev:general", 5 | "dev:pg": "DB_TYPE=pgsql npm run dev:general", 6 | "dev:mysql": "DB_TYPE=mysql npm run dev:general", 7 | "dev:general": "npm run db:deploy && npm run db:generate && next dev", 8 | "build": "DB_TYPE=pgsql npm run db:generate && DB_TYPE=pgsql npm run db:deploy && next build && npm run build:widget", 9 | "build:without-migrate": "npm run db:generate && next build && npm run build:widget", 10 | "db:deploy": "prisma migrate deploy --schema ./prisma/$DB_TYPE/schema.prisma", 11 | "db:generate": "prisma generate --schema ./prisma/$DB_TYPE/schema.prisma", 12 | "db:push": "DB_TYPE=sqlite npm run db:push:general", 13 | "db:push:pg": "DB_TYPE=pgsql npm run db:push:general", 14 | "db:push:mysql": "DB_TYPE=mysql npm run db:push:general", 15 | "db:push:general": "prisma db push --schema ./prisma/$DB_TYPE/schema.prisma --preview-feature", 16 | "db:migrate": "prisma migrate dev --schema ./prisma/$DB_TYPE/schema.prisma", 17 | "db:migrateAll": "prisma migrate dev --schema ./prisma/sqlite/schema.prisma && prisma migrate dev --schema ./prisma/pgsql/schema.prisma && prisma migrate dev --schema ./prisma/mysql/schema.prisma", 18 | "admin": "prisma studio", 19 | "widget": "vite --config scripts/vite.config.js", 20 | "start": "next start -p 3000", 21 | "start:with-migrate": "npm run db:generate && npm run db:deploy && next start -p 3000", 22 | "cp-widget-lang": "mkdir -p public/js/widget/lang && cp -r widget/lang public/js/widget", 23 | "build:widget": "npm run cp-widget-lang && vite build --config scripts/vite.widget.config.js && npm run build:sdk && npm run build:iframe && npm run build:count", 24 | "build:sdk": "vite build --config scripts/vite.sdk.config.js", 25 | "build:iframe": "vite build --config scripts/vite.iframe.config.js", 26 | "build:count": "vite build --config scripts/vite.count.config.js" 27 | }, 28 | "prettier": "@egoist/prettier-config", 29 | "dependencies": { 30 | "@emotion/react": "^11.10.4", 31 | "@emotion/server": "^11.11.0", 32 | "@hapi/boom": "^9.1.2", 33 | "@mantine/core": "^6.0.16", 34 | "@mantine/form": "^6.0.16", 35 | "@mantine/hooks": "^6.0.16", 36 | "@mantine/modals": "^6.0.16", 37 | "@mantine/next": "^6.0.16", 38 | "@mantine/notifications": "^6.0.16", 39 | "@prisma/client": "^5.0.0", 40 | "@sendgrid/mail": "^7.4.2", 41 | "@sentry/node": "^6.3.1", 42 | "@sentry/tracing": "^6.3.1", 43 | "autoprefixer": "^10.2.5", 44 | "axios": "^0.21.1", 45 | "class-validator": "^0.13.1", 46 | "cors": "^2.8.5", 47 | "dayjs": "^1.10.4", 48 | "formidable": "^1.2.2", 49 | "framer-motion": "^3.2.1", 50 | "jsonwebtoken": "^8.5.1", 51 | "markdown-it": "^12.0.6", 52 | "milligram": "^1.4.1", 53 | "mini-capture": "^0.1.1", 54 | "nanoid": "^3.1.22", 55 | "next": "^13.4.9", 56 | "next-auth": "3.29.10", 57 | "next-connect": "^0.10.1", 58 | "nodemailer": "^6.5.0", 59 | "postcss": "^8.2.10", 60 | "react": "^18.2.0", 61 | "react-dom": "^18.2.0", 62 | "react-hook-form": "^7.1.1", 63 | "react-icons": "^4.3.1", 64 | "react-query": "3.6.0", 65 | "redaxios": "^0.4.1", 66 | "reflect-metadata": "^0.1.13", 67 | "rollup-plugin-svelte": "^7.1.0", 68 | "svelte": "^3.37.0", 69 | "svelte-preprocess": "^4.7.0", 70 | "tailwindcss": "^2.1.1", 71 | "turndown": "^7.0.0", 72 | "vite": "^2.1.5", 73 | "xml2json": "^0.12.0" 74 | }, 75 | "devDependencies": { 76 | "@babel/core": "^7.12.10", 77 | "@babel/plugin-proposal-decorators": "^7.12.12", 78 | "@egoist/prettier-config": "^0.1.0", 79 | "@types/node": "^14.14.21", 80 | "@types/react": "^18.2.14", 81 | "@types/react-dom": "^18.2.6", 82 | "babel-plugin-transform-typescript-metadata": "^0.3.1", 83 | "prettier": "^2.2.1", 84 | "pretty-quick": "^3.1.0", 85 | "prisma": "^5.0.0", 86 | "typegraphql-prisma": "^0.9.4", 87 | "typescript": "^4.8.2" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | function NotFoundPage() { 2 | return ( 3 | <> 4 | Not found 5 | 6 | ) 7 | } 8 | 9 | 10 | export default NotFoundPage -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'next-auth/client' 2 | import { QueryClient, QueryClientProvider } from 'react-query' 3 | import { MantineProvider } from '@mantine/core' 4 | import { Notifications } from '@mantine/notifications'; 5 | import { ModalsProvider } from '@mantine/modals'; 6 | 7 | import '../style.css' 8 | 9 | const queryClient = new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | refetchOnWindowFocus: false 13 | } 14 | } 15 | }) 16 | 17 | export default function App({ Component, pageProps }) { 18 | return ( 19 | 20 | 21 | {/* @ts-ignore */} 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | import { resolvedConfig } from '../utils.server' 3 | 4 | class MyDocument extends Document { 5 | 6 | render() { 7 | return ( 8 | 9 | 10 | {resolvedConfig.umami.id && } 11 | 12 | 13 |
14 | 15 | 16 | 17 | ) 18 | } 19 | } 20 | 21 | export default MyDocument -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Adapters from "next-auth/adapters"; 3 | import { prisma, resolvedConfig, singletonSync } from "../../../utils.server"; 4 | import { authProviders } from "../../../config.server"; 5 | import { statService } from "../../../service/stat.service"; 6 | 7 | // Using Module Augmentation 8 | // https://next-auth.js.org/getting-started/typescript 9 | 10 | declare module "next-auth" { 11 | interface Session { 12 | uid: string 13 | } 14 | interface User { 15 | id: string 16 | } 17 | } 18 | 19 | declare module "next-auth/jwt" { 20 | interface JWT { 21 | id: string 22 | } 23 | } 24 | 25 | export default NextAuth({ 26 | // Configure one or more authentication providers 27 | providers: authProviders, 28 | 29 | adapter: Adapters.Prisma.Adapter({ prisma: prisma }), 30 | 31 | session: { 32 | jwt: !!resolvedConfig.useLocalAuth, 33 | }, 34 | 35 | jwt: { 36 | secret: resolvedConfig.jwtSecret, 37 | }, 38 | 39 | callbacks: { 40 | session(session, user) { 41 | session.uid = user.id 42 | return session 43 | }, 44 | jwt(token, user) { 45 | if (user) { 46 | token.id = user.id 47 | } 48 | return token 49 | }, 50 | signIn() { 51 | statService.capture('signIn') 52 | return true 53 | } 54 | }, 55 | 56 | events: { 57 | async error(message) { 58 | console.log(message) 59 | }, 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /pages/api/comment/[commentId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { CommentService } from "../../../service/comment.service"; 3 | 4 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 5 | const commentService = new CommentService(req) 6 | if (req.method === 'DELETE') { 7 | await commentService.delete(req.query.commentId as string) 8 | res.json({ 9 | message: 'Success' 10 | }) 11 | } 12 | } -------------------------------------------------------------------------------- /pages/api/comment/[commentId]/approve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { UsageLabel, usageLimitation } from '../../../../config.common' 3 | import { AuthService } from '../../../../service/auth.service' 4 | import { CommentService } from '../../../../service/comment.service' 5 | import { SubscriptionService } from '../../../../service/subscription.service' 6 | import { UsageService } from '../../../../service/usage.service' 7 | import { getSession } from '../../../../utils.server' 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse, 12 | ) { 13 | const commentService = new CommentService(req) 14 | const authService = new AuthService(req, res) 15 | const usageService = new UsageService(req) 16 | const session = await getSession(req) 17 | 18 | const subscriptionService = new SubscriptionService() 19 | 20 | if (req.method === 'POST') { 21 | const commentId = req.query.commentId as string 22 | 23 | // only project owner can approve 24 | const project = await commentService.getProject(commentId) 25 | if (!(await authService.projectOwnerGuard(project))) { 26 | return 27 | } 28 | 29 | // check usage 30 | if (!await subscriptionService.approveCommentValidate(session.uid)) { 31 | res.status(402).json({ 32 | error: 33 | `You have reached the maximum number of approving comments on free plan (${usageLimitation['approve_comment']}/month). Please upgrade to Pro plan to approve more comments.`, 34 | }) 35 | return 36 | } 37 | 38 | await commentService.approve(commentId) 39 | await usageService.incr(UsageLabel.ApproveComment) 40 | 41 | res.json({ 42 | message: 'success', 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pages/api/comment/[commentId]/replyAsModerator.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { AuthService } from '../../../../service/auth.service' 3 | import { CommentService } from '../../../../service/comment.service' 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | const commentService = new CommentService(req) 10 | const authService = new AuthService(req, res) 11 | 12 | if (req.method === 'POST') { 13 | const body = req.body as { 14 | content: string 15 | } 16 | const commentId = req.query.commentId as string 17 | 18 | const project = await commentService.getProject(commentId) 19 | if (!(await authService.projectOwnerGuard(project))) { 20 | return 21 | } 22 | const created = await commentService.addCommentAsModerator( 23 | commentId, 24 | body.content, 25 | ) 26 | res.json({ 27 | data: created, 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/open/approve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { resolvedConfig } from '../../../utils.server' 3 | import jwt from 'jsonwebtoken' 4 | import { CommentService } from '../../../service/comment.service' 5 | import { SecretKey, TokenBody, TokenService } from '../../../service/token.service' 6 | import { UsageService } from '../../../service/usage.service' 7 | import { SubscriptionService } from '../../../service/subscription.service' 8 | import { UsageLabel, usageLimitation } from '../../../config.common' 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse, 13 | ) { 14 | const commentService = new CommentService(req) 15 | const usageService = new UsageService(req) 16 | const subscriptionService = new SubscriptionService() 17 | 18 | const tokenService = new TokenService() 19 | 20 | if (req.method === 'GET') { 21 | const { token } = req.query as { 22 | token?: string 23 | } 24 | 25 | if (!token) { 26 | res.send('Invalid token') 27 | return 28 | } 29 | 30 | const secret = `${resolvedConfig.jwtSecret}-approve_comment` 31 | 32 | try { 33 | const result = jwt.verify(token, secret) as { 34 | commentId: string 35 | } 36 | await commentService.approve(result.commentId) 37 | res.send('Approved!') 38 | return 39 | } catch (e) { 40 | res.send('Invalid token') 41 | return 42 | } 43 | } else if (req.method === 'POST') { 44 | const { token } = req.query as { 45 | token?: string 46 | } 47 | 48 | const { replyContent } = req.body as { 49 | replyContent?: string 50 | } 51 | 52 | if (!token) { 53 | res.status(403) 54 | res.send('Invalid token') 55 | return 56 | } 57 | 58 | let tokenBody: TokenBody.ApproveComment 59 | 60 | try { 61 | tokenBody = tokenService.validate(token, SecretKey.ApproveComment) as TokenBody.ApproveComment 62 | } catch (e) { 63 | res.status(403) 64 | res.send('Invalid token') 65 | return 66 | } 67 | 68 | // check usage 69 | if (!await subscriptionService.quickApproveValidate(tokenBody.owner.id)) { 70 | res.status(402).json({ 71 | error: `You have reached the maximum number of Quick Approve on free plan (${usageLimitation.quick_approve}/month). Please upgrade to Pro plan to use Quick Approve more.` 72 | }) 73 | return 74 | } 75 | 76 | // firstly, approve comment 77 | await commentService.approve(tokenBody.commentId) 78 | 79 | // then append reply 80 | if (replyContent) { 81 | await commentService.addCommentAsModerator(tokenBody.commentId, replyContent, { 82 | owner: tokenBody.owner 83 | }) 84 | } 85 | 86 | await usageService.incr(UsageLabel.QuickApprove) 87 | 88 | res.json({ 89 | message: 'success' 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pages/api/open/comments.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { 3 | CommentService, 4 | CommentWrapper, 5 | } from '../../../service/comment.service' 6 | import { apiHandler } from '../../../utils.server' 7 | import Cors from 'cors' 8 | import { ProjectService } from '../../../service/project.service' 9 | import { statService } from '../../../service/stat.service' 10 | 11 | export default apiHandler() 12 | .use( 13 | Cors({ 14 | // Only allow requests with GET, POST and OPTIONS 15 | methods: ['GET', 'POST', 'OPTIONS'], 16 | }), 17 | ) 18 | .get(async (req, res) => { 19 | const commentService = new CommentService(req) 20 | const projectService = new ProjectService(req) 21 | 22 | // get all comments 23 | const query = req.query as { 24 | page?: string 25 | appId: string 26 | pageId: string 27 | } 28 | 29 | const timezoneOffsetInHour = req.headers['x-timezone-offset'] 30 | 31 | const isDeleted = await projectService.isDeleted(query.appId) 32 | 33 | if (isDeleted) { 34 | res.status(404) 35 | res.json({ 36 | data: { 37 | commentCount: 0, 38 | data: [], 39 | pageCount: 0, 40 | pageSize: 10, 41 | } as CommentWrapper, 42 | }) 43 | return 44 | } 45 | 46 | statService.capture('get_comments', { 47 | identity: query.appId, 48 | properties: { 49 | from: 'open_api', 50 | }, 51 | }) 52 | 53 | const queryCommentStat = statService.start( 54 | 'query_comments', 55 | 'Query Comments', 56 | { 57 | tags: { 58 | project_id: query.appId, 59 | from: 'open_api', 60 | }, 61 | }, 62 | ) 63 | 64 | const comments = await commentService.getComments( 65 | query.appId, 66 | Number(timezoneOffsetInHour), 67 | { 68 | approved: true, 69 | parentId: null, 70 | pageSlug: query.pageId, 71 | page: Number(query.page) || 1, 72 | select: { 73 | by_nickname: true, 74 | moderator: { 75 | select: { 76 | displayName: true 77 | } 78 | } 79 | }, 80 | }, 81 | ) 82 | 83 | queryCommentStat.end() 84 | 85 | res.json({ 86 | data: comments, 87 | }) 88 | }) 89 | .post(async (req, res) => { 90 | const commentService = new CommentService(req) 91 | const projectService = new ProjectService(req) 92 | // add comment 93 | const body = req.body as { 94 | parentId?: string 95 | appId: string 96 | pageId: string 97 | content: string 98 | acceptNotify?: boolean 99 | email: string 100 | nickname: string 101 | pageUrl?: string 102 | pageTitle?: string 103 | } 104 | 105 | const isDeleted = await projectService.isDeleted(body.appId) 106 | 107 | if (isDeleted) { 108 | res.status(404) 109 | res.json({ 110 | message: 'Project not found', 111 | }) 112 | return 113 | } 114 | 115 | const comment = await commentService.addComment( 116 | body.appId, 117 | body.pageId, 118 | { 119 | content: body.content, 120 | email: body.email, 121 | nickname: body.nickname, 122 | pageTitle: body.pageTitle, 123 | pageUrl: body.pageUrl, 124 | }, 125 | body.parentId, 126 | ) 127 | 128 | // send confirm email 129 | if (body.acceptNotify === true && body.email) { 130 | try { 131 | commentService.sendConfirmReplyNotificationEmail( 132 | body.email, 133 | body.pageTitle, 134 | comment.id, 135 | ) 136 | } catch (e) { 137 | // TODO: log error 138 | } 139 | } 140 | 141 | statService.capture('add_comment') 142 | 143 | res.json({ 144 | data: comment, 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /pages/api/open/project/[projectId]/comments/count.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { 3 | apiHandler, 4 | initMiddleware, 5 | prisma, 6 | } from '../../../../../../utils.server' 7 | import Cors from 'cors' 8 | 9 | export default apiHandler() 10 | .use( 11 | Cors({ 12 | // Only allow requests with GET, POST and OPTIONS 13 | methods: ['GET', 'POST', 'OPTIONS'], 14 | }) 15 | ) 16 | .get(async (req, res) => { 17 | const { projectId, pageIds } = req.query as { 18 | pageIds: string 19 | projectId: string 20 | } 21 | 22 | const data = {} 23 | 24 | const counts = ( 25 | await prisma.$transaction( 26 | pageIds.split(',').map((id) => { 27 | return prisma.comment.count({ 28 | where: { 29 | deletedAt: null, 30 | approved: true, 31 | page: { 32 | slug: id, 33 | projectId, 34 | }, 35 | }, 36 | }) 37 | }), 38 | ) 39 | ).forEach((count, index) => { 40 | data[pageIds.split(',')[index]] = count 41 | }) 42 | 43 | res.json({ 44 | data, 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /pages/api/open/project/[projectId]/comments/latest.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { ProjectService } from "../../../../../../service/project.service"; 3 | import { prisma } from "../../../../../../utils.server"; 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | const projectService = new ProjectService(req) 7 | if (req.method === 'GET') { 8 | const { projectId, token } = req.query as { 9 | projectId: string, 10 | token?: string 11 | } 12 | 13 | if (!token) { 14 | res.status(403) 15 | res.json({ 16 | message: 'Invalid token' 17 | }) 18 | return 19 | } 20 | 21 | const project = await prisma.project.findUnique({ 22 | where: { 23 | id: projectId 24 | }, 25 | select:{ 26 | token: true, 27 | fetchLatestCommentsAt: true 28 | } 29 | }) 30 | 31 | if (project.token !== token) { 32 | res.status(403) 33 | res.json({ 34 | message: 'Invalid token', 35 | }) 36 | return 37 | } 38 | 39 | const comments = await projectService.fetchLatestComment(projectId, { 40 | from: project.fetchLatestCommentsAt, 41 | markAsRead: true 42 | }) 43 | 44 | res.json({ 45 | comments: comments 46 | }) 47 | } 48 | } -------------------------------------------------------------------------------- /pages/api/open/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { resolvedConfig } from '../../../utils.server' 3 | import jwt from 'jsonwebtoken' 4 | import { UserService } from '../../../service/user.service' 5 | import { UnSubscribeType } from '../../../service/token.service' 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | const userService = new UserService(req) 12 | 13 | if (req.method === 'GET') { 14 | const { token } = req.query as { 15 | token?: string 16 | } 17 | 18 | if (!token) { 19 | res.send('Invalid token') 20 | return 21 | } 22 | 23 | const secret = `${resolvedConfig.jwtSecret}-unsubscribe` 24 | 25 | try { 26 | const result = jwt.verify(token, secret) as { 27 | type: UnSubscribeType 28 | userId: string 29 | } 30 | 31 | switch (result.type) { 32 | case UnSubscribeType.NEW_COMMENT: 33 | { 34 | await userService.update(result.userId, { 35 | enableNewCommentNotification: false, 36 | }) 37 | } 38 | break 39 | } 40 | 41 | res.send('Unsubscribe!') 42 | return 43 | } catch (e) { 44 | res.send('Invalid token') 45 | return 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pages/api/project/[projectId].ts: -------------------------------------------------------------------------------- 1 | import { Project } from "@prisma/client"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { AuthService } from "../../../service/auth.service"; 4 | import { ProjectService } from "../../../service/project.service"; 5 | import { prisma } from "../../../utils.server"; 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | 9 | const authService = new AuthService(req, res) 10 | const projectService = new ProjectService(req) 11 | 12 | if (req.method === 'PUT') { 13 | const { projectId } = req.query as { 14 | projectId: string 15 | } 16 | const body = req.body as { 17 | enableNotification?: boolean, 18 | webhookUrl?: string, 19 | enableWebhook?: boolean 20 | } 21 | 22 | const project = (await projectService.get(projectId, { 23 | select: { 24 | ownerId: true, 25 | }, 26 | })) as Pick 27 | 28 | if (!(await authService.projectOwnerGuard(project))) { 29 | return 30 | } 31 | 32 | await prisma.project.update({ 33 | where: { 34 | id: projectId, 35 | }, 36 | data: { 37 | enableNotification: body.enableNotification, 38 | enableWebhook: body.enableWebhook, 39 | webhook: body.webhookUrl 40 | }, 41 | }) 42 | 43 | res.json({ 44 | message: 'success' 45 | }) 46 | } else if (req.method === 'DELETE') { 47 | const { projectId } = req.query as { 48 | projectId: string 49 | } 50 | 51 | const project = (await projectService.get(projectId, { 52 | select: { 53 | ownerId: true, 54 | }, 55 | })) as Pick 56 | 57 | if (!(await authService.projectOwnerGuard(project))) { 58 | return 59 | } 60 | 61 | await projectService.delete(projectId) 62 | 63 | res.json({ 64 | message: 'success' 65 | }) 66 | } 67 | } -------------------------------------------------------------------------------- /pages/api/project/[projectId]/comments.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@prisma/client' 2 | import { NextApiRequest, NextApiResponse } from 'next' 3 | import { AuthService } from '../../../../service/auth.service' 4 | import { CommentService } from '../../../../service/comment.service' 5 | import { ProjectService } from '../../../../service/project.service' 6 | import { statService } from '../../../../service/stat.service' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | const projectService = new ProjectService(req) 13 | const commentService = new CommentService(req) 14 | const authService = new AuthService(req, res) 15 | if (req.method === 'GET') { 16 | const { projectId, page } = req.query as { 17 | projectId: string 18 | page: string 19 | } 20 | 21 | const timezoneOffsetInHour = req.headers['x-timezone-offset'] || 0 22 | 23 | // only owner can get comments 24 | const project = (await projectService.get(projectId, { 25 | select: { 26 | ownerId: true, 27 | }, 28 | })) as Pick 29 | 30 | if (!(await authService.projectOwnerGuard(project))) { 31 | return 32 | } 33 | 34 | const queryCommentStat = statService.start('query_comments', 'Query Comments', { 35 | tags: { 36 | project_id: projectId, 37 | from: 'dashboard' 38 | } 39 | }) 40 | 41 | const comments = await commentService.getComments(projectId, Number(timezoneOffsetInHour), { 42 | // parentId: null, 43 | page: Number(page), 44 | onlyOwn: true, 45 | select: { 46 | by_nickname: true, 47 | by_email: true, 48 | approved: true, 49 | }, 50 | }) 51 | 52 | queryCommentStat.end() 53 | 54 | res.json({ 55 | data: comments, 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/api/project/[projectId]/data/import.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import formidable from 'formidable' 4 | import { DataService } from '../../../../../service/data.service' 5 | import * as fs from 'fs' 6 | import { AuthService } from '../../../../../service/auth.service' 7 | import { ProjectService } from '../../../../../service/project.service' 8 | import { Project } from '@prisma/client' 9 | 10 | export const config = { 11 | api: { 12 | bodyParser: false, 13 | }, 14 | } 15 | 16 | export default async function handler( 17 | req: NextApiRequest, 18 | res: NextApiResponse, 19 | ) { 20 | const authService = new AuthService(req, res) 21 | const projectService = new ProjectService(req) 22 | 23 | if (req.method === 'POST') { 24 | const form = new formidable.IncomingForm() 25 | 26 | const dataService = new DataService() 27 | 28 | const { projectId } = req.query as { 29 | projectId: string 30 | } 31 | 32 | // only owner can import 33 | const project = (await projectService.get(projectId, { 34 | select: { 35 | ownerId: true, 36 | }, 37 | })) as Pick 38 | 39 | if (!(await authService.projectOwnerGuard(project))) { 40 | return 41 | } 42 | 43 | form.parse(req, async (err, fields, files) => { 44 | if (err) { 45 | res.status(503) 46 | res.json({ 47 | message: err.message, 48 | }) 49 | return 50 | } 51 | 52 | const imported = await dataService.importFromDisqus( 53 | projectId, 54 | fs.readFileSync(files.file.path, { encoding: 'utf-8' }), 55 | ) 56 | 57 | res.json({ 58 | data: { 59 | pageCount: imported.threads.length, 60 | commentCount: imported.posts.length, 61 | }, 62 | }) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pages/api/project/[projectId]/generateToken.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deprecated 3 | */ 4 | 5 | import { NextApiRequest, NextApiResponse } from "next"; 6 | import { ProjectService } from "../../../../service/project.service"; 7 | 8 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 9 | 10 | const projectService = new ProjectService(req) 11 | 12 | if (req.method === 'POST') { 13 | const projectId = req.query.projectId as string 14 | const token = await projectService.regenerateToken(projectId) 15 | res.json({ 16 | data: token 17 | }) 18 | } 19 | } -------------------------------------------------------------------------------- /pages/api/projects.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { ProjectService } from "../../service/project.service"; 3 | import { SubscriptionService } from "../../service/subscription.service"; 4 | import { getSession, prisma } from "../../utils.server"; 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const projectService = new ProjectService(req) 8 | const subscriptionService = new SubscriptionService() 9 | const session = await getSession(req) 10 | 11 | if (req.method === 'POST') { 12 | if (!session) { 13 | res.status(401).json({ 14 | error: 'Unauthorized' 15 | }) 16 | return 17 | } 18 | 19 | // check subscription 20 | if (!await subscriptionService.createProjectValidate(session.uid)) { 21 | // if (true) { 22 | res.status(402).json({ 23 | error: 'You have reached the maximum number of sites on free plan. Please upgrade to Pro plan to create more sites.' 24 | }) 25 | return 26 | } 27 | 28 | const { title } = req.body as { 29 | title: string 30 | } 31 | 32 | const created = await projectService.create(title) 33 | 34 | res.json({ 35 | data: { 36 | id: created.id 37 | } 38 | }) 39 | } else if (req.method === 'GET') { 40 | const projects = await projectService.list() 41 | res.json({ 42 | data: projects 43 | }) 44 | } 45 | } -------------------------------------------------------------------------------- /pages/api/subscription.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as crypto from 'crypto' 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | import { Readable } from 'stream'; 5 | import { SubscriptionService } from '../../service/subscription.service'; 6 | import { getSession, prisma, resolvedConfig } from '../../utils.server'; 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | }; 13 | 14 | // Get raw body as string 15 | async function getRawBody(readable: Readable): Promise { 16 | const chunks = []; 17 | for await (const chunk of readable) { 18 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); 19 | } 20 | return Buffer.concat(chunks); 21 | } 22 | 23 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 24 | 25 | if (req.method === 'POST') { 26 | const rawBody = await getRawBody(req); 27 | const secret = resolvedConfig.checkout.lemonSecret; 28 | console.log(req.headers) 29 | const hmac = crypto.createHmac('sha256', secret); 30 | const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8'); 31 | const signature = Buffer.from(req.headers['x-signature'] as string, 'utf8'); 32 | 33 | if (!crypto.timingSafeEqual(digest, signature)) { 34 | res.status(401).send('Invalid signature'); 35 | return 36 | } 37 | 38 | const data = JSON.parse(Buffer.from(rawBody).toString('utf8')); 39 | const subscriptionService = new SubscriptionService() 40 | const eventName = req.headers['x-event-name'] as string 41 | 42 | switch (eventName) { 43 | case 'subscription_created': 44 | case 'subscription_updated': { 45 | await subscriptionService.update(data) 46 | break 47 | } 48 | } 49 | 50 | res.json({ 51 | 52 | }) 53 | } else if (req.method === 'DELETE') { 54 | const session = await getSession(req) 55 | 56 | if (!session) { 57 | res.status(401).send('Unauthorized') 58 | return 59 | } 60 | 61 | const subscription = await prisma.subscription.findUnique({ 62 | where: { 63 | userId: session.uid 64 | }, 65 | }) 66 | 67 | if (!subscription) { 68 | res.status(404).send('Subscription not found') 69 | return 70 | } 71 | 72 | try { 73 | const response = await axios.delete(`https://api.lemonsqueezy.com/v1/subscriptions/${subscription.lemonSubscriptionId}`, { 74 | headers: { 75 | 'Authorization': `Bearer ${resolvedConfig.checkout.lemonApiKey}`, 76 | 'Content-Type': 'application/vnd.api+json', 77 | 'Accept': "application/vnd.api+json" 78 | } 79 | }) 80 | } catch (e) { 81 | console.log(e.response.data, e.request) 82 | } 83 | 84 | res.json({ 85 | message: 'success' 86 | }) 87 | } 88 | } -------------------------------------------------------------------------------- /pages/api/user.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { AuthService } from "../../service/auth.service"; 3 | import { UserService } from "../../service/user.service"; 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | 7 | const userService = new UserService(req) 8 | const authService = new AuthService(req, res) 9 | 10 | if (req.method === 'PUT') { 11 | const { 12 | notificationEmail, 13 | enableNewCommentNotification, 14 | displayName 15 | } = req.body as { 16 | notificationEmail?: string 17 | enableNewCommentNotification?: boolean, 18 | displayName?: string 19 | } 20 | 21 | const user = await authService.authGuard() 22 | 23 | if (!user) { 24 | return 25 | } 26 | 27 | await userService.update(user.uid, { 28 | enableNewCommentNotification, 29 | notificationEmail, 30 | displayName 31 | }) 32 | 33 | res.json({ 34 | message: 'success' 35 | }) 36 | } 37 | } -------------------------------------------------------------------------------- /pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { apiClient } from "../../utils.client" 3 | import { Project } from "@prisma/client" 4 | import { ProjectService } from "../../service/project.service" 5 | import { getSession } from "../../utils.server" 6 | 7 | 8 | export const getAllProjects = async () => { 9 | const res = await apiClient.get<{ 10 | data: Project[] 11 | }>("/projects") 12 | return res.data.data 13 | } 14 | 15 | function Dashboard() { 16 | return ( 17 |
18 | 19 |
20 | ) 21 | } 22 | 23 | export async function getServerSideProps(ctx) { 24 | 25 | const session = await getSession(ctx.req) 26 | 27 | if (!session) { 28 | return { 29 | redirect: { 30 | destination: '/api/auth/signin', 31 | permanent: false, 32 | } 33 | } 34 | } 35 | 36 | const projectService = new ProjectService(ctx.req) 37 | 38 | const defaultProject = await projectService.getFirstProject(session.uid, { 39 | select: { 40 | id: true 41 | } 42 | }) 43 | 44 | if (!defaultProject) { 45 | return { 46 | redirect: { 47 | destination: `/getting-start`, 48 | permanent: false 49 | } 50 | } 51 | } else { 52 | // redirect to project dashboard 53 | return { 54 | redirect: { 55 | destination: `/dashboard/project/${defaultProject.id}`, 56 | permanent: false 57 | } 58 | } 59 | } 60 | } 61 | 62 | export default Dashboard 63 | -------------------------------------------------------------------------------- /pages/error.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@mantine/core' 2 | 3 | export enum ErrorCode { 4 | INVALID_TOKEN = 'INVALID_TOKEN' 5 | } 6 | 7 | function ErrorPage({ 8 | errorCode 9 | }: { 10 | errorCode: ErrorCode | null 11 | }) { 12 | 13 | const info = (() => { 14 | switch (errorCode) { 15 | case ErrorCode.INVALID_TOKEN: 16 | return Invalid Token 17 | default: 18 | return Something went wrong 19 | } 20 | })() 21 | 22 | return ( 23 | <> 24 | {info} 25 | 26 | ) 27 | } 28 | 29 | export async function getServerSideProps(ctx) { 30 | return { 31 | props: { 32 | errorCode: ctx.query.code || null 33 | } 34 | } 35 | } 36 | 37 | export default ErrorPage -------------------------------------------------------------------------------- /pages/forbidden.tsx: -------------------------------------------------------------------------------- 1 | function ForbiddenPage() { 2 | return ( 3 | <> 4 | Forbidden 5 | 6 | ) 7 | } 8 | 9 | export async function getServerSideProps(ctx) { 10 | return { 11 | props: { 12 | 13 | } 14 | } 15 | } 16 | 17 | export default ForbiddenPage -------------------------------------------------------------------------------- /pages/getting-start.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, Stack, Title, Text, Button, TextInput, Image } from "@mantine/core" 2 | import { notifications } from "@mantine/notifications" 3 | import router from "next/router" 4 | import React from "react" 5 | import { useMutation } from "react-query" 6 | import { Head } from "../components/Head" 7 | import { ProjectService } from "../service/project.service" 8 | import { apiClient } from "../utils.client" 9 | import { getSession } from "../utils.server" 10 | 11 | export const createProject = async (body: { title: string }) => { 12 | const res = await apiClient.post("/projects", { 13 | title: body.title, 14 | }) 15 | return res.data 16 | } 17 | 18 | function GettingStart() { 19 | const createProjectMutation = useMutation(createProject) 20 | const titleInputRef = React.useRef(null) 21 | 22 | 23 | async function onClickCreateProject() { 24 | if (!titleInputRef.current.value) { 25 | return 26 | } 27 | 28 | await createProjectMutation.mutate( 29 | { 30 | title: titleInputRef.current.value, 31 | }, 32 | { 33 | onSuccess(data) { 34 | notifications.show({ 35 | title: "Project created", 36 | message: "Redirecting to project dashboard", 37 | color: 'green' 38 | }) 39 | router.push(`/dashboard/project/${data.data.id}`, null, { 40 | shallow: true, 41 | }) 42 | }, 43 | onError(data: any) { 44 | const { 45 | error: message, 46 | status: statusCode 47 | } = data.response.data 48 | 49 | notifications.show({ 50 | title: "Error", 51 | message, 52 | color: 'yellow' 53 | }) 54 | } 55 | } 56 | ) 57 | } 58 | 59 | return ( 60 | <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Let's create a new site 70 | 71 | 72 | Give your site a name, and you can start using Cusdis. 73 | 74 | 75 | 76 | 77 | 78 | Site name 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | ) 90 | } 91 | 92 | export async function getServerSideProps(ctx) { 93 | 94 | const session = await getSession(ctx.req) 95 | 96 | if (!session) { 97 | return { 98 | redirect: { 99 | destination: '/api/auth/signin', 100 | permanent: false 101 | } 102 | } 103 | } 104 | 105 | // const projectService = new ProjectService(ctx.req) 106 | 107 | // const defaultProject = await projectService.getFirstProject(session.uid, { 108 | // select: { 109 | // id: true 110 | // } 111 | // }) 112 | 113 | // if (defaultProject) { 114 | // // redirect to project dashboard 115 | // return { 116 | // redirect: { 117 | // destination: `/dashboard/project/${defaultProject.id}`, 118 | // permanent: false 119 | // } 120 | // } 121 | // } 122 | 123 | return { 124 | props: { 125 | 126 | } 127 | } 128 | } 129 | 130 | export default GettingStart -------------------------------------------------------------------------------- /pages/open/approve.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Stack, Title, Text, Divider, Textarea, Box, Button, Anchor } from "@mantine/core" 2 | import { notifications } from "@mantine/notifications" 3 | import { Comment, Page, Project } from "@prisma/client" 4 | import { useRouter } from "next/router" 5 | import React from "react" 6 | import { useMutation } from "react-query" 7 | import { Head } from "../../components/Head" 8 | import { CommentService } from "../../service/comment.service" 9 | import { SecretKey, TokenService } from "../../service/token.service" 10 | import { apiClient } from "../../utils.client" 11 | import { prisma } from "../../utils.server" 12 | import { ErrorCode } from "../error" 13 | 14 | const approveComment = async ({ token }) => { 15 | const res = await apiClient.post(`/open/approve?token=${token}`) 16 | return res.data 17 | } 18 | 19 | const appendReply = async ({ replyContent, token }) => { 20 | const res = await apiClient.post(`/open/approve?token=${token}`, { 21 | replyContent 22 | }) 23 | return res.data 24 | } 25 | 26 | function ApprovePage(props: { 27 | comment: Comment & { 28 | page: Page & { 29 | project: Project 30 | } 31 | } 32 | }) { 33 | 34 | const router = useRouter() 35 | 36 | const [replyContent, setReplyContent] = React.useState('') 37 | 38 | const appendReplyMutation = useMutation(appendReply, { 39 | onSuccess() { 40 | notifications.show({ 41 | title: 'Success', 42 | message: 'Reply appended', 43 | color: 'green' 44 | }) 45 | setReplyContent('') 46 | }, 47 | onError(data: any) { 48 | const { 49 | error: message, 50 | status: statusCode 51 | } = data.response.data 52 | 53 | notifications.show({ 54 | title: "Error", 55 | message, 56 | color: 'yellow' 57 | }) 58 | } 59 | }) 60 | const approveCommentMutation = useMutation(approveComment, { 61 | onSuccess() { 62 | notifications.show({ 63 | title: 'Success', 64 | message: 'Reply appended', 65 | color: 'green' 66 | }) 67 | 68 | location.reload() 69 | }, 70 | onError(data: any) { 71 | const { 72 | error: message, 73 | status: statusCode 74 | } = data.response.data 75 | 76 | notifications.show({ 77 | title: "Error", 78 | message, 79 | color: 'yellow' 80 | }) 81 | } 82 | }) 83 | 84 | return ( 85 | <> 86 | 87 | 88 | 89 | 90 | Cusdis 91 | 92 | 93 | 94 | New comment on site {props.comment.page.project.title}, page {props.comment.page.title || props.comment.page.slug} 95 | From: {props.comment.by_nickname} ({props.comment.by_email || 'Email not provided'}) 96 | ({ 97 | whiteSpace: 'pre-wrap', 98 | backgroundColor: theme.colors.gray[0], 99 | padding: theme.spacing.md 100 | })} component='pre' w="full" size="sm">{props.comment.content} 101 | 102 | 103 | 104 | { 105 | props.comment.approved ? : 112 | } 113 | 114 | 115 | 116 | 117 | 118 | 119 | * Appending reply to a comment will automatically approve the comment 120 | 121 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ) 135 | } 136 | 137 | function redirectError(code: ErrorCode) { 138 | return { 139 | redirect: { 140 | destination: `/error?code=${code}`, 141 | permanent: false 142 | } 143 | } 144 | } 145 | 146 | export async function getServerSideProps(ctx) { 147 | 148 | const tokenService = new TokenService() 149 | const commentService = new CommentService(ctx.req) 150 | 151 | const { token } = ctx.query 152 | 153 | if (!token) { 154 | return redirectError(ErrorCode.INVALID_TOKEN) 155 | } 156 | 157 | let commentId 158 | 159 | try { 160 | commentId = tokenService.validate(token, SecretKey.ApproveComment).commentId 161 | } catch (e) { 162 | return redirectError(ErrorCode.INVALID_TOKEN) 163 | } 164 | 165 | const comment = await prisma.comment.findUnique({ 166 | where: { 167 | id: commentId 168 | }, 169 | select: { 170 | by_nickname: true, 171 | by_email: true, 172 | content: true, 173 | approved: true, 174 | page: { 175 | select: { 176 | title: true, 177 | slug: true, 178 | url: true, 179 | project: { 180 | select: { 181 | title: true 182 | } 183 | } 184 | } 185 | } 186 | } 187 | }) 188 | 189 | return { 190 | props: { 191 | comment 192 | } 193 | } 194 | } 195 | 196 | export default ApprovePage -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: { 4 | purge: ['widget/**/*.svelte', 'widget/theme.css'], 5 | darkMode: 'class', 6 | variants: { 7 | extend: { 8 | outline: ['dark'], 9 | borderWidth: ['dark'], 10 | borderColor: ['dark'] 11 | }, 12 | }, 13 | }, 14 | autoprefixer: {}, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /prisma/mysql/migrations/20211121064912_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `accounts` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `compound_id` VARCHAR(191) NOT NULL, 5 | `user_id` VARCHAR(191) NOT NULL, 6 | `provider_type` VARCHAR(191) NOT NULL, 7 | `provider_id` VARCHAR(191) NOT NULL, 8 | `provider_account_id` VARCHAR(191) NOT NULL, 9 | `refresh_token` VARCHAR(191), 10 | `access_token` VARCHAR(191), 11 | `access_token_expires` DATETIME(3), 12 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 13 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 14 | 15 | UNIQUE INDEX `accounts.compound_id_unique`(`compound_id`), 16 | INDEX `providerAccountId`(`provider_account_id`), 17 | INDEX `providerId`(`provider_id`), 18 | INDEX `userId`(`user_id`), 19 | PRIMARY KEY (`id`) 20 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 21 | 22 | -- CreateTable 23 | CREATE TABLE `sessions` ( 24 | `id` INTEGER NOT NULL AUTO_INCREMENT, 25 | `user_id` VARCHAR(191) NOT NULL, 26 | `expires` DATETIME(3) NOT NULL, 27 | `session_token` VARCHAR(191) NOT NULL, 28 | `access_token` VARCHAR(191) NOT NULL, 29 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 30 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 31 | 32 | UNIQUE INDEX `sessions.session_token_unique`(`session_token`), 33 | UNIQUE INDEX `sessions.access_token_unique`(`access_token`), 34 | PRIMARY KEY (`id`) 35 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 36 | 37 | -- CreateTable 38 | CREATE TABLE `users` ( 39 | `id` VARCHAR(191) NOT NULL, 40 | `name` VARCHAR(191), 41 | `email` VARCHAR(191), 42 | `email_verified` DATETIME(3), 43 | `image` VARCHAR(191), 44 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 45 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 46 | `enable_new_comment_notification` BOOLEAN DEFAULT true, 47 | `notification_email` VARCHAR(191), 48 | 49 | UNIQUE INDEX `users.email_unique`(`email`), 50 | PRIMARY KEY (`id`) 51 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 52 | 53 | -- CreateTable 54 | CREATE TABLE `verification_requests` ( 55 | `id` INTEGER NOT NULL AUTO_INCREMENT, 56 | `identifier` VARCHAR(191) NOT NULL, 57 | `token` VARCHAR(191) NOT NULL, 58 | `expires` DATETIME(3) NOT NULL, 59 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 60 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 61 | 62 | UNIQUE INDEX `verification_requests.token_unique`(`token`), 63 | PRIMARY KEY (`id`) 64 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 65 | 66 | -- CreateTable 67 | CREATE TABLE `projects` ( 68 | `id` VARCHAR(191) NOT NULL, 69 | `title` VARCHAR(191) NOT NULL, 70 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 71 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 72 | `deleted_at` DATETIME(3), 73 | `ownerId` VARCHAR(191) NOT NULL, 74 | `token` VARCHAR(191), 75 | `fetch_latest_comments_at` DATETIME(3), 76 | `enable_notification` BOOLEAN DEFAULT true, 77 | `notification_email` VARCHAR(191), 78 | `webhook` VARCHAR(191), 79 | `enableWebhook` BOOLEAN, 80 | 81 | PRIMARY KEY (`id`) 82 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 83 | 84 | -- CreateTable 85 | CREATE TABLE `pages` ( 86 | `id` VARCHAR(191) NOT NULL, 87 | `slug` VARCHAR(191) NOT NULL, 88 | `url` VARCHAR(191), 89 | `title` VARCHAR(191), 90 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 91 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 92 | `projectId` VARCHAR(191) NOT NULL, 93 | 94 | INDEX `projectId`(`projectId`), 95 | PRIMARY KEY (`id`) 96 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 97 | 98 | -- CreateTable 99 | CREATE TABLE `comments` ( 100 | `id` VARCHAR(191) NOT NULL, 101 | `pageId` VARCHAR(191) NOT NULL, 102 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 103 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 104 | `deletedAt` DATETIME(3), 105 | `moderatorId` VARCHAR(191), 106 | `by_email` VARCHAR(191), 107 | `by_nickname` VARCHAR(191) NOT NULL, 108 | `content` TEXT NOT NULL, 109 | `approved` BOOLEAN NOT NULL DEFAULT false, 110 | `parentId` VARCHAR(191), 111 | 112 | PRIMARY KEY (`id`) 113 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 114 | 115 | -- AddForeignKey 116 | ALTER TABLE `projects` ADD FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 117 | 118 | -- AddForeignKey 119 | ALTER TABLE `pages` ADD FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 120 | 121 | -- AddForeignKey 122 | ALTER TABLE `comments` ADD FOREIGN KEY (`pageId`) REFERENCES `pages`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 123 | 124 | -- AddForeignKey 125 | ALTER TABLE `comments` ADD FOREIGN KEY (`moderatorId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE `comments` ADD FOREIGN KEY (`parentId`) REFERENCES `comments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 129 | -------------------------------------------------------------------------------- /prisma/mysql/migrations/20211124155649_add_display_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE `comments` DROP FOREIGN KEY `comments_ibfk_2`; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE `comments` DROP FOREIGN KEY `comments_ibfk_1`; 6 | 7 | -- DropForeignKey 8 | ALTER TABLE `comments` DROP FOREIGN KEY `comments_ibfk_3`; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE `pages` DROP FOREIGN KEY `pages_ibfk_1`; 12 | 13 | -- DropForeignKey 14 | ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`; 15 | 16 | -- AlterTable 17 | ALTER TABLE `users` ADD COLUMN `displayName` VARCHAR(191) NULL; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE `projects` ADD CONSTRAINT `projects_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE `pages` ADD CONSTRAINT `pages_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE `comments` ADD CONSTRAINT `comments_pageId_fkey` FOREIGN KEY (`pageId`) REFERENCES `pages`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 27 | 28 | -- AddForeignKey 29 | ALTER TABLE `comments` ADD CONSTRAINT `comments_moderatorId_fkey` FOREIGN KEY (`moderatorId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 30 | 31 | -- AddForeignKey 32 | ALTER TABLE `comments` ADD CONSTRAINT `comments_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `comments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 33 | 34 | -- RenameIndex 35 | ALTER TABLE `accounts` RENAME INDEX `accounts.compound_id_unique` TO `accounts_compound_id_key`; 36 | 37 | -- RenameIndex 38 | ALTER TABLE `sessions` RENAME INDEX `sessions.access_token_unique` TO `sessions_access_token_key`; 39 | 40 | -- RenameIndex 41 | ALTER TABLE `sessions` RENAME INDEX `sessions.session_token_unique` TO `sessions_session_token_key`; 42 | 43 | -- RenameIndex 44 | ALTER TABLE `users` RENAME INDEX `users.email_unique` TO `users_email_key`; 45 | 46 | -- RenameIndex 47 | ALTER TABLE `verification_requests` RENAME INDEX `verification_requests.token_unique` TO `verification_requests_token_key`; 48 | -------------------------------------------------------------------------------- /prisma/mysql/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/mysql/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "mysql" 6 | url = env("DB_URL") 7 | shadowDatabaseUrl = env("SHADOW_DB_URL") 8 | } 9 | 10 | generator client { 11 | provider = "prisma-client-js" 12 | previewFeatures = ["referentialActions"] 13 | } 14 | 15 | // next-auth BEGIN 16 | 17 | model Account { 18 | id Int @id @default(autoincrement()) 19 | compoundId String @unique @map(name: "compound_id") 20 | userId String @map(name: "user_id") 21 | providerType String @map(name: "provider_type") 22 | providerId String @map(name: "provider_id") 23 | providerAccountId String @map(name: "provider_account_id") 24 | refreshToken String? @map(name: "refresh_token") 25 | accessToken String? @map(name: "access_token") 26 | accessTokenExpires DateTime? @map(name: "access_token_expires") 27 | createdAt DateTime @default(now()) @map(name: "created_at") 28 | updatedAt DateTime @default(now()) @map(name: "updated_at") 29 | 30 | 31 | @@index([providerAccountId], name: "providerAccountId") 32 | @@index([providerId], name: "providerId") 33 | @@index([userId], name: "userId") 34 | @@map(name: "accounts") 35 | } 36 | 37 | model Session { 38 | id Int @id @default(autoincrement()) 39 | userId String @map(name: "user_id") 40 | expires DateTime 41 | sessionToken String @unique @map(name: "session_token") 42 | accessToken String @unique @map(name: "access_token") 43 | createdAt DateTime @default(now()) @map(name: "created_at") 44 | updatedAt DateTime @default(now()) @map(name: "updated_at") 45 | 46 | @@map(name: "sessions") 47 | } 48 | 49 | model User { 50 | id String @id @default(uuid()) 51 | name String? 52 | displayName String? 53 | email String? @unique 54 | emailVerified DateTime? @map(name: "email_verified") 55 | image String? 56 | createdAt DateTime @default(now()) @map(name: "created_at") 57 | updatedAt DateTime @default(now()) @map(name: "updated_at") 58 | 59 | projects Project[] 60 | 61 | Comment Comment[] 62 | 63 | enableNewCommentNotification Boolean? @default(true) @map(name: "enable_new_comment_notification") 64 | notificationEmail String? @map(name: "notification_email") 65 | 66 | @@map(name: "users") 67 | } 68 | 69 | model VerificationRequest { 70 | id Int @id @default(autoincrement()) 71 | identifier String 72 | token String @unique 73 | expires DateTime 74 | createdAt DateTime @default(now()) @map(name: "created_at") 75 | updatedAt DateTime @default(now()) @map(name: "updated_at") 76 | 77 | @@map(name: "verification_requests") 78 | } 79 | 80 | // next-auth END 81 | 82 | model Project { 83 | id String @id @default(uuid()) 84 | title String 85 | createdAt DateTime @default(now()) @map(name: "created_at") 86 | updatedAt DateTime @default(now()) @map(name: "updated_at") 87 | deletedAt DateTime? @map(name: "deleted_at") 88 | 89 | ownerId String 90 | owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) 91 | 92 | pages Page[] 93 | 94 | token String? 95 | fetchLatestCommentsAt DateTime? @map(name: "fetch_latest_comments_at") 96 | 97 | enableNotification Boolean? @default(true) @map(name: "enable_notification") 98 | notificationEmail String? @map(name: "notification_email") 99 | 100 | webhook String? 101 | enableWebhook Boolean? 102 | 103 | @@map(name: "projects") 104 | } 105 | 106 | model Page { 107 | id String @id @default(uuid()) 108 | 109 | slug String 110 | url String? 111 | title String? 112 | 113 | createdAt DateTime @default(now()) @map(name: "created_at") 114 | updatedAt DateTime @default(now()) @map(name: "updated_at") 115 | 116 | projectId String 117 | project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) 118 | 119 | comments Comment[] 120 | 121 | 122 | @@index([projectId], name: "projectId") 123 | @@map("pages") 124 | } 125 | 126 | model Comment { 127 | id String @id @default(uuid()) 128 | 129 | pageId String 130 | page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) 131 | 132 | createdAt DateTime @default(now()) @map(name: "created_at") 133 | updatedAt DateTime @default(now()) @map(name: "updated_at") 134 | deletedAt DateTime? 135 | 136 | moderatorId String? 137 | moderator User? @relation(fields: [moderatorId], references: [id]) 138 | by_email String? 139 | by_nickname String 140 | content String @db.Text 141 | 142 | approved Boolean @default(false) 143 | 144 | parentId String? 145 | parent Comment? @relation("replies", fields: [parentId], references: [id]) 146 | replies Comment[] @relation("replies") 147 | 148 | @@map("comments") 149 | } 150 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20210417102821_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "accounts" ( 3 | "id" SERIAL NOT NULL, 4 | "compound_id" TEXT NOT NULL, 5 | "user_id" TEXT NOT NULL, 6 | "provider_type" TEXT NOT NULL, 7 | "provider_id" TEXT NOT NULL, 8 | "provider_account_id" TEXT NOT NULL, 9 | "refresh_token" TEXT, 10 | "access_token" TEXT, 11 | "access_token_expires" TIMESTAMP(3), 12 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | 15 | PRIMARY KEY ("id") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "sessions" ( 20 | "id" SERIAL NOT NULL, 21 | "user_id" TEXT NOT NULL, 22 | "expires" TIMESTAMP(3) NOT NULL, 23 | "session_token" TEXT NOT NULL, 24 | "access_token" TEXT NOT NULL, 25 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 27 | 28 | PRIMARY KEY ("id") 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "users" ( 33 | "id" TEXT NOT NULL, 34 | "name" TEXT, 35 | "email" TEXT, 36 | "email_verified" TIMESTAMP(3), 37 | "image" TEXT, 38 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 39 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 | 41 | PRIMARY KEY ("id") 42 | ); 43 | 44 | -- CreateTable 45 | CREATE TABLE "verification_requests" ( 46 | "id" SERIAL NOT NULL, 47 | "identifier" TEXT NOT NULL, 48 | "token" TEXT NOT NULL, 49 | "expires" TIMESTAMP(3) NOT NULL, 50 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 51 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 52 | 53 | PRIMARY KEY ("id") 54 | ); 55 | 56 | -- CreateTable 57 | CREATE TABLE "projects" ( 58 | "id" TEXT NOT NULL, 59 | "title" TEXT NOT NULL, 60 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 61 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 62 | "ownerId" TEXT NOT NULL, 63 | 64 | PRIMARY KEY ("id") 65 | ); 66 | 67 | -- CreateTable 68 | CREATE TABLE "pages" ( 69 | "id" TEXT NOT NULL, 70 | "slug" TEXT NOT NULL, 71 | "url" TEXT, 72 | "title" TEXT, 73 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 74 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 75 | "projectId" TEXT NOT NULL, 76 | 77 | PRIMARY KEY ("id") 78 | ); 79 | 80 | -- CreateTable 81 | CREATE TABLE "comments" ( 82 | "id" TEXT NOT NULL, 83 | "pageId" TEXT NOT NULL, 84 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 85 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 86 | "deletedAt" TIMESTAMP(3), 87 | "moderatorId" TEXT, 88 | "by_email" TEXT, 89 | "by_nickname" TEXT NOT NULL, 90 | "content" TEXT NOT NULL, 91 | "approved" BOOLEAN NOT NULL DEFAULT false, 92 | "parentId" TEXT, 93 | 94 | PRIMARY KEY ("id") 95 | ); 96 | 97 | -- CreateIndex 98 | CREATE UNIQUE INDEX "accounts.compound_id_unique" ON "accounts"("compound_id"); 99 | 100 | -- CreateIndex 101 | CREATE INDEX "providerAccountId" ON "accounts"("provider_account_id"); 102 | 103 | -- CreateIndex 104 | CREATE INDEX "providerId" ON "accounts"("provider_id"); 105 | 106 | -- CreateIndex 107 | CREATE INDEX "userId" ON "accounts"("user_id"); 108 | 109 | -- CreateIndex 110 | CREATE UNIQUE INDEX "sessions.session_token_unique" ON "sessions"("session_token"); 111 | 112 | -- CreateIndex 113 | CREATE UNIQUE INDEX "sessions.access_token_unique" ON "sessions"("access_token"); 114 | 115 | -- CreateIndex 116 | CREATE UNIQUE INDEX "users.email_unique" ON "users"("email"); 117 | 118 | -- CreateIndex 119 | CREATE UNIQUE INDEX "verification_requests.token_unique" ON "verification_requests"("token"); 120 | 121 | -- CreateIndex 122 | CREATE INDEX "projectId" ON "pages"("projectId"); 123 | 124 | -- AddForeignKey 125 | ALTER TABLE "projects" ADD FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE "pages" ADD FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; 129 | 130 | -- AddForeignKey 131 | ALTER TABLE "comments" ADD FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; 132 | 133 | -- AddForeignKey 134 | ALTER TABLE "comments" ADD FOREIGN KEY ("moderatorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; 135 | 136 | -- AddForeignKey 137 | ALTER TABLE "comments" ADD FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE SET NULL ON UPDATE CASCADE; 138 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20210422105059_add_project_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "token" TEXT, 3 | ADD COLUMN "fetch_latest_comments_at" TIMESTAMP(3); 4 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20210423071344_notification/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "enable_notification" BOOLEAN DEFAULT true; 3 | 4 | -- AlterTable 5 | ALTER TABLE "users" ADD COLUMN "enable_new_comment_notification" BOOLEAN DEFAULT true, 6 | ADD COLUMN "notification_email" TEXT; 7 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20210426145530_project_webhook/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "webhook" TEXT, 3 | ADD COLUMN "enableWebhook" BOOLEAN; 4 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20210427123424_project_deleted_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3); 3 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20211124155429_add_display_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "comments" DROP CONSTRAINT "comments_pageId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "pages" DROP CONSTRAINT "pages_projectId_fkey"; 6 | 7 | -- DropForeignKey 8 | ALTER TABLE "projects" DROP CONSTRAINT "projects_ownerId_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "users" ADD COLUMN "displayName" TEXT; 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "projects" ADD CONSTRAINT "projects_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "pages" ADD CONSTRAINT "pages_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "comments" ADD CONSTRAINT "comments_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 21 | 22 | -- RenameIndex 23 | ALTER INDEX "accounts.compound_id_unique" RENAME TO "accounts_compound_id_key"; 24 | 25 | -- RenameIndex 26 | ALTER INDEX "sessions.access_token_unique" RENAME TO "sessions_access_token_key"; 27 | 28 | -- RenameIndex 29 | ALTER INDEX "sessions.session_token_unique" RENAME TO "sessions_session_token_key"; 30 | 31 | -- RenameIndex 32 | ALTER INDEX "users.email_unique" RENAME TO "users_email_key"; 33 | 34 | -- RenameIndex 35 | ALTER INDEX "verification_requests.token_unique" RENAME TO "verification_requests_token_key"; 36 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/20230713082802_subscription/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Subscription" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "lemon_subscription_id" TEXT, 6 | "order_id" TEXT NOT NULL, 7 | "product_id" TEXT, 8 | "variant_id" TEXT, 9 | "customer_id" TEXT, 10 | "status" TEXT NOT NULL, 11 | "endsAt" TIMESTAMP(3), 12 | "update_payment_method_url" TEXT, 13 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | 15 | CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "usages" ( 20 | "id" TEXT NOT NULL, 21 | "userId" TEXT NOT NULL, 22 | "label" TEXT NOT NULL, 23 | "count" INTEGER NOT NULL DEFAULT 0, 24 | "updatedAt" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "usages_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); 31 | 32 | -- CreateIndex 33 | CREATE UNIQUE INDEX "usages_userId_label_key" ON "usages"("userId", "label"); 34 | 35 | -- AddForeignKey 36 | ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 37 | 38 | -- AddForeignKey 39 | ALTER TABLE "usages" ADD CONSTRAINT "usages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 40 | -------------------------------------------------------------------------------- /prisma/pgsql/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/pgsql/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DB_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | // next-auth BEGIN 14 | 15 | model Account { 16 | id Int @id @default(autoincrement()) 17 | compoundId String @unique @map(name: "compound_id") 18 | userId String @map(name: "user_id") 19 | providerType String @map(name: "provider_type") 20 | providerId String @map(name: "provider_id") 21 | providerAccountId String @map(name: "provider_account_id") 22 | refreshToken String? @map(name: "refresh_token") 23 | accessToken String? @map(name: "access_token") 24 | accessTokenExpires DateTime? @map(name: "access_token_expires") 25 | createdAt DateTime @default(now()) @map(name: "created_at") 26 | updatedAt DateTime @default(now()) @map(name: "updated_at") 27 | 28 | @@index([providerAccountId], name: "providerAccountId") 29 | @@index([providerId], name: "providerId") 30 | @@index([userId], name: "userId") 31 | @@map(name: "accounts") 32 | } 33 | 34 | model Session { 35 | id Int @id @default(autoincrement()) 36 | userId String @map(name: "user_id") 37 | expires DateTime 38 | sessionToken String @unique @map(name: "session_token") 39 | accessToken String @unique @map(name: "access_token") 40 | createdAt DateTime @default(now()) @map(name: "created_at") 41 | updatedAt DateTime @default(now()) @map(name: "updated_at") 42 | 43 | @@map(name: "sessions") 44 | } 45 | 46 | model User { 47 | id String @id @default(uuid()) 48 | name String? 49 | displayName String? 50 | email String? @unique 51 | emailVerified DateTime? @map(name: "email_verified") 52 | image String? 53 | createdAt DateTime @default(now()) @map(name: "created_at") 54 | updatedAt DateTime @default(now()) @map(name: "updated_at") 55 | 56 | projects Project[] 57 | 58 | Comment Comment[] 59 | 60 | subscription Subscription? 61 | 62 | enableNewCommentNotification Boolean? @default(true) @map(name: "enable_new_comment_notification") 63 | notificationEmail String? @map(name: "notification_email") 64 | 65 | usage Usage[] 66 | 67 | @@map(name: "users") 68 | } 69 | 70 | model Subscription { 71 | id String @id @default(uuid()) 72 | 73 | userId String @unique 74 | user User @relation(fields: [userId], references: [id]) 75 | 76 | lemonSubscriptionId String? @map(name: "lemon_subscription_id") 77 | orderId String @map(name: "order_id") 78 | productId String? @map(name: "product_id") 79 | variantId String? @map(name: "variant_id") 80 | customerId String? @map(name: "customer_id") 81 | status String 82 | endsAt DateTime? @map(name: "endsAt") 83 | 84 | updatePaymentMethodUrl String? @map(name: "update_payment_method_url") 85 | 86 | createdAt DateTime @default(now()) @map(name: "created_at") 87 | } 88 | 89 | model VerificationRequest { 90 | id Int @id @default(autoincrement()) 91 | identifier String 92 | token String @unique 93 | expires DateTime 94 | createdAt DateTime @default(now()) @map(name: "created_at") 95 | updatedAt DateTime @default(now()) @map(name: "updated_at") 96 | 97 | @@map(name: "verification_requests") 98 | } 99 | 100 | // next-auth END 101 | 102 | model Project { 103 | id String @id @default(uuid()) 104 | title String 105 | createdAt DateTime @default(now()) @map(name: "created_at") 106 | updatedAt DateTime @default(now()) @map(name: "updated_at") 107 | deletedAt DateTime? @map(name: "deleted_at") 108 | 109 | ownerId String 110 | owner User @relation(fields: [ownerId], references: [id]) 111 | 112 | pages Page[] 113 | 114 | token String? 115 | fetchLatestCommentsAt DateTime? @map(name: "fetch_latest_comments_at") 116 | 117 | enableNotification Boolean? @default(true) @map(name: "enable_notification") 118 | 119 | webhook String? 120 | enableWebhook Boolean? 121 | 122 | @@map(name: "projects") 123 | } 124 | 125 | model Page { 126 | id String @id @default(uuid()) 127 | 128 | slug String 129 | url String? 130 | title String? 131 | 132 | createdAt DateTime @default(now()) @map(name: "created_at") 133 | updatedAt DateTime @default(now()) @map(name: "updated_at") 134 | 135 | projectId String 136 | project Project @relation(fields: [projectId], references: [id]) 137 | 138 | comments Comment[] 139 | 140 | @@index([projectId], name: "projectId") 141 | @@map("pages") 142 | } 143 | 144 | model Comment { 145 | id String @id @default(uuid()) 146 | 147 | pageId String 148 | page Page @relation(fields: [pageId], references: [id]) 149 | 150 | createdAt DateTime @default(now()) @map(name: "created_at") 151 | updatedAt DateTime @default(now()) @map(name: "updated_at") 152 | deletedAt DateTime? 153 | 154 | moderatorId String? 155 | moderator User? @relation(fields: [moderatorId], references: [id]) 156 | by_email String? 157 | by_nickname String 158 | content String 159 | 160 | approved Boolean @default(false) 161 | 162 | parentId String? 163 | parent Comment? @relation("replies", fields: [parentId], references: [id]) 164 | replies Comment[] @relation("replies") 165 | 166 | @@map("comments") 167 | } 168 | 169 | model Usage { 170 | id String @id @default(uuid()) 171 | 172 | userId String 173 | user User @relation(fields: [userId], references: [id]) 174 | 175 | label String 176 | count Int @default(0) 177 | 178 | updatedAt DateTime @updatedAt 179 | 180 | @@unique([userId, label]) 181 | 182 | @@map("usages") 183 | } -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210419153654_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "accounts" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "compound_id" TEXT NOT NULL, 5 | "user_id" TEXT NOT NULL, 6 | "provider_type" TEXT NOT NULL, 7 | "provider_id" TEXT NOT NULL, 8 | "provider_account_id" TEXT NOT NULL, 9 | "refresh_token" TEXT, 10 | "access_token" TEXT, 11 | "access_token_expires" DATETIME, 12 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "sessions" ( 18 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 19 | "user_id" TEXT NOT NULL, 20 | "expires" DATETIME NOT NULL, 21 | "session_token" TEXT NOT NULL, 22 | "access_token" TEXT NOT NULL, 23 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "users" ( 29 | "id" TEXT NOT NULL PRIMARY KEY, 30 | "name" TEXT, 31 | "email" TEXT, 32 | "email_verified" DATETIME, 33 | "image" TEXT, 34 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 36 | ); 37 | 38 | -- CreateTable 39 | CREATE TABLE "verification_requests" ( 40 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 41 | "identifier" TEXT NOT NULL, 42 | "token" TEXT NOT NULL, 43 | "expires" DATETIME NOT NULL, 44 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 45 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "projects" ( 50 | "id" TEXT NOT NULL PRIMARY KEY, 51 | "title" TEXT NOT NULL, 52 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 53 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 54 | "ownerId" TEXT NOT NULL, 55 | FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE 56 | ); 57 | 58 | -- CreateTable 59 | CREATE TABLE "pages" ( 60 | "id" TEXT NOT NULL PRIMARY KEY, 61 | "slug" TEXT NOT NULL, 62 | "url" TEXT, 63 | "title" TEXT, 64 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 65 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 66 | "projectId" TEXT NOT NULL, 67 | FOREIGN KEY ("projectId") REFERENCES "projects" ("id") ON DELETE CASCADE ON UPDATE CASCADE 68 | ); 69 | 70 | -- CreateTable 71 | CREATE TABLE "comments" ( 72 | "id" TEXT NOT NULL PRIMARY KEY, 73 | "pageId" TEXT NOT NULL, 74 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 75 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 76 | "deletedAt" DATETIME, 77 | "moderatorId" TEXT, 78 | "by_email" TEXT, 79 | "by_nickname" TEXT NOT NULL, 80 | "content" TEXT NOT NULL, 81 | "approved" BOOLEAN NOT NULL DEFAULT false, 82 | "parentId" TEXT, 83 | FOREIGN KEY ("pageId") REFERENCES "pages" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 84 | FOREIGN KEY ("moderatorId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 85 | FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE SET NULL ON UPDATE CASCADE 86 | ); 87 | 88 | -- CreateIndex 89 | CREATE UNIQUE INDEX "accounts.compound_id_unique" ON "accounts"("compound_id"); 90 | 91 | -- CreateIndex 92 | CREATE INDEX "providerAccountId" ON "accounts"("provider_account_id"); 93 | 94 | -- CreateIndex 95 | CREATE INDEX "providerId" ON "accounts"("provider_id"); 96 | 97 | -- CreateIndex 98 | CREATE INDEX "userId" ON "accounts"("user_id"); 99 | 100 | -- CreateIndex 101 | CREATE UNIQUE INDEX "sessions.session_token_unique" ON "sessions"("session_token"); 102 | 103 | -- CreateIndex 104 | CREATE UNIQUE INDEX "sessions.access_token_unique" ON "sessions"("access_token"); 105 | 106 | -- CreateIndex 107 | CREATE UNIQUE INDEX "users.email_unique" ON "users"("email"); 108 | 109 | -- CreateIndex 110 | CREATE UNIQUE INDEX "verification_requests.token_unique" ON "verification_requests"("token"); 111 | 112 | -- CreateIndex 113 | CREATE INDEX "projectId" ON "pages"("projectId"); 114 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210422061617_add_project_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "token" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210422101358_add_fetch_latest_comments_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "fetch_latest_comments_at" DATETIME; 3 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210422171715_notification_info/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "enable_new_comment_notification" BOOLEAN DEFAULT true; 3 | ALTER TABLE "users" ADD COLUMN "notification_email" TEXT; 4 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210423063317_project_notification_settings/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "enable_notification" BOOLEAN DEFAULT true; 3 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210426135406_add_project_webhook/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "webhook" TEXT; 3 | ALTER TABLE "projects" ADD COLUMN "enableWebhook" BOOLEAN; 4 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20210427114259_add_project_deleted_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "projects" ADD COLUMN "deleted_at" DATETIME; 3 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20211124142411_add_display_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "displayName" TEXT; 3 | 4 | -- RedefineTables 5 | PRAGMA foreign_keys=OFF; 6 | CREATE TABLE "new_pages" ( 7 | "id" TEXT NOT NULL PRIMARY KEY, 8 | "slug" TEXT NOT NULL, 9 | "url" TEXT, 10 | "title" TEXT, 11 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "projectId" TEXT NOT NULL, 14 | CONSTRAINT "pages_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 15 | ); 16 | INSERT INTO "new_pages" ("created_at", "id", "projectId", "slug", "title", "updated_at", "url") SELECT "created_at", "id", "projectId", "slug", "title", "updated_at", "url" FROM "pages"; 17 | DROP TABLE "pages"; 18 | ALTER TABLE "new_pages" RENAME TO "pages"; 19 | CREATE INDEX "projectId" ON "pages"("projectId"); 20 | CREATE TABLE "new_projects" ( 21 | "id" TEXT NOT NULL PRIMARY KEY, 22 | "title" TEXT NOT NULL, 23 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "deleted_at" DATETIME, 26 | "ownerId" TEXT NOT NULL, 27 | "token" TEXT, 28 | "fetch_latest_comments_at" DATETIME, 29 | "enable_notification" BOOLEAN DEFAULT true, 30 | "webhook" TEXT, 31 | "enableWebhook" BOOLEAN, 32 | CONSTRAINT "projects_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 33 | ); 34 | INSERT INTO "new_projects" ("created_at", "deleted_at", "enableWebhook", "enable_notification", "fetch_latest_comments_at", "id", "ownerId", "title", "token", "updated_at", "webhook") SELECT "created_at", "deleted_at", "enableWebhook", "enable_notification", "fetch_latest_comments_at", "id", "ownerId", "title", "token", "updated_at", "webhook" FROM "projects"; 35 | DROP TABLE "projects"; 36 | ALTER TABLE "new_projects" RENAME TO "projects"; 37 | CREATE TABLE "new_comments" ( 38 | "id" TEXT NOT NULL PRIMARY KEY, 39 | "pageId" TEXT NOT NULL, 40 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 41 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 42 | "deletedAt" DATETIME, 43 | "moderatorId" TEXT, 44 | "by_email" TEXT, 45 | "by_nickname" TEXT NOT NULL, 46 | "content" TEXT NOT NULL, 47 | "approved" BOOLEAN NOT NULL DEFAULT false, 48 | "parentId" TEXT, 49 | CONSTRAINT "comments_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "pages" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 50 | CONSTRAINT "comments_moderatorId_fkey" FOREIGN KEY ("moderatorId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 51 | CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE SET NULL ON UPDATE CASCADE 52 | ); 53 | INSERT INTO "new_comments" ("approved", "by_email", "by_nickname", "content", "created_at", "deletedAt", "id", "moderatorId", "pageId", "parentId", "updated_at") SELECT "approved", "by_email", "by_nickname", "content", "created_at", "deletedAt", "id", "moderatorId", "pageId", "parentId", "updated_at" FROM "comments"; 54 | DROP TABLE "comments"; 55 | ALTER TABLE "new_comments" RENAME TO "comments"; 56 | PRAGMA foreign_key_check; 57 | PRAGMA foreign_keys=ON; 58 | 59 | -- RedefineIndex 60 | DROP INDEX "accounts.compound_id_unique"; 61 | CREATE UNIQUE INDEX "accounts_compound_id_key" ON "accounts"("compound_id"); 62 | 63 | -- RedefineIndex 64 | DROP INDEX "sessions.access_token_unique"; 65 | CREATE UNIQUE INDEX "sessions_access_token_key" ON "sessions"("access_token"); 66 | 67 | -- RedefineIndex 68 | DROP INDEX "sessions.session_token_unique"; 69 | CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); 70 | 71 | -- RedefineIndex 72 | DROP INDEX "users.email_unique"; 73 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 74 | 75 | -- RedefineIndex 76 | DROP INDEX "verification_requests.token_unique"; 77 | CREATE UNIQUE INDEX "verification_requests_token_key" ON "verification_requests"("token"); 78 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20230713042856_subscription/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Subscription" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "order_id" TEXT NOT NULL, 6 | "product_id" TEXT, 7 | "variant_id" TEXT, 8 | "customer_id" TEXT, 9 | "status" TEXT NOT NULL, 10 | "endsAt" DATETIME, 11 | "update_payment_method_url" TEXT, 12 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 14 | ); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); 18 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20230713055831_subscription/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Subscription" ADD COLUMN "lemon_subscription_id" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/20230713070441_usage/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "usages" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "label" TEXT NOT NULL, 6 | "count" INTEGER NOT NULL DEFAULT 0, 7 | "updatedAt" DATETIME NOT NULL, 8 | CONSTRAINT "usages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "usages_userId_label_key" ON "usages"("userId", "label"); 13 | -------------------------------------------------------------------------------- /prisma/sqlite/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /public/discussion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/discussion.png -------------------------------------------------------------------------------- /public/doc/README.md: -------------------------------------------------------------------------------- 1 | # Cusdis 2 | 3 | Open-source, lightweight (~5kb gzipped), privacy-friendly alternative to Disqus. 4 | 5 | 6 | ## Features 7 | 8 | - Universal embed code 9 | - You can embed Cusdis on every website. 10 | - Light-weight sdk 11 | - The SDK that embed to your website is only 5kb (gzipped). Compared to Disqus (which is 24kb gzipped), it's very light-weight. 12 | - Email notification 13 | - One-click import data from Disqus 14 | - Moderation dashboard 15 | - Since we don't require user sign in to comment, all comments are NOT displayed by default, until the moderator approve it. We provide a moderation dashboard for it. 16 | 17 | There are two ways to use Cusdis: 18 | 19 | ## Self host 20 | 21 | _Pros: You own your data_ 22 | 23 | You can install Cusdis on your own server, just follow this [installation guide](/self-host/vercel.md) 24 | 25 | ## Hosted service 26 | 27 | _Pros: Easy to use_ 28 | 29 | You can also use our [hosted service](https://cusdis.com/dashboard). We host our service on [Vercel](https://vercel.com), the data is stored on a PostgreSQL database. 30 | 31 | ## Compared to Disqus 32 | 33 | Cusdis is not designed for a FULLY alternative to Disqus, it's aim to implement a minimist embed comment tool for small sites (like your static blog). 34 | 35 | Below are the pros and cons of Cusdis: 36 | 37 | ### Pros 38 | 39 | - Cusdis is open-source and self-hostable, you own your data. 40 | - The SDK is lightweight (~5kb gzipped) 41 | - Cusdis doesn't required commenter to sign in. We don't use cookies at all. 42 | 43 | ### Cons 44 | 45 | - Cusdis is on early development stage 46 | - You have to manually moderate comments which are not display by default until you approve it, since we dont't have a spam filter. 47 | - Disqus is a company, we aren't. 48 | -------------------------------------------------------------------------------- /public/doc/_sidebar.md: -------------------------------------------------------------------------------- 1 | - **Introduction** 2 | - [About](/) 3 | - [Awesome Cusdis](awesome.md) 4 | - **Self-host Guide** 5 | - [Docker](/self-host/docker.md) 6 | - [Railway](/self-host/railway.md) 7 | - [Vercel](/self-host/vercel.md) 8 | - [Manually Install](/self-host/manual.md) 9 | - **Integration** 10 | - [JS SDK Reference](/advanced/sdk.md) 11 | - [React](https://github.com/Cusdis/sdk/tree/master/packages/react-cusdis) 12 | - [Vue](https://github.com/2nthony/vue-cusdis) 13 | - [Docsify](/integration/docsify.md) 14 | - [Jekyll](/integration/jekyll.md) 15 | - [Hugo](https://discourse.gohugo.io/t/free-and-open-source-comments-for-hugo/32940) 16 | - [Hexo](https://blog.cusdis.com/integate-cusdis-in-hexo) 17 | - [Mkdocs](/integration/mkdocs.md) 18 | - [Publii](/integration/publii.md) 19 | - **Features** 20 | - [Moderation](/features/moderation.md) 21 | - [Email Notification](/features/notification.md) 22 | - **Advanced** 23 | - [Webhook](/advanced/webhook.md) 24 | - [Show comment count](/advanced/show-comment-count.md) 25 | - [i18n](/advanced/i18n.md) 26 | - [**Contributing Guide**](/contributing.md) 27 | - [**FAQ**](/faq.md) 28 | -------------------------------------------------------------------------------- /public/doc/advanced/i18n.md: -------------------------------------------------------------------------------- 1 | # i18n 2 | 3 | Cusdis comment widget has international support. But for keeping the SDK lightweight, only English is included. 4 | 5 | ## Usage 6 | 7 | Before the sdk script, add locale specific script: `https://cusdis.com/js/widget/lang/{LANG_CODE}.js`, for example: 8 | 9 | ```diff 10 |
17 | + 18 | 19 | ``` 20 | 21 | > Make sure the locale specific script is loaded before the sdk. 22 | 23 | > Should change the script host to your own server if you are using self-host Cusdis. (e.g. `https://your-domain.com/js/widget/lang/zh-cn.js` ) 24 | 25 | ## Current support language 26 | 27 | - zh-cn 28 | - zh-tw 29 | - ja 30 | - es 31 | - tr 32 | - pt-BR 33 | - oc 34 | - fr 35 | - id 36 | - ca 37 | - fi 38 | - ar 39 | 40 | ## Contributing more languages 41 | 42 | You are very welcome to contribute your language! Just create a file in `widget/lang/{LANG_CODE}.js` with: 43 | 44 | ```js 45 | window.CUSDIS_LOCALE = { 46 | //... 47 | } 48 | ``` 49 | 50 | You can find all available keys in https://github.com/djyde/cusdis/blob/master/widget/lang/en.js. 51 | 52 | Feel free to create a PR! 53 | -------------------------------------------------------------------------------- /public/doc/advanced/sdk.md: -------------------------------------------------------------------------------- 1 | # JS SDK 2 | 3 | Understand how the JS SDK works help you integrate Cusdis to an existed system. 4 | 5 | To embed the comment widget to your web page, you need to put **the element and JS SDK** on the page, at the position where you want to embed to: 6 | 7 | ```html 8 |
15 | 16 | ``` 17 | 18 | > If you are using self-hosted Cusdis, remember changing the `data-host` and the host in ` 12 | 13 | ``` 14 | 15 | This script will collect all `data-cusdis-count-page-id` in current page and fetch the comments count. Then replace the count number to the element. 16 | 17 | !> Don't forget to change `https://cusdis.com` to your own domain if you are using self-host version. 18 | 19 | ?> If there are more than one element with `data-cusdis-count-page-id`, the script will batch the query in one. 20 | 21 | ## API 22 | 23 | This UMD script expose `CUSDIS_COUNT` on `window` object. 24 | 25 | ### window.CUSDIS_COUNT.initial() 26 | 27 | Manually update the count in the page. 28 | -------------------------------------------------------------------------------- /public/doc/advanced/webhook.md: -------------------------------------------------------------------------------- 1 | # Webhook 2 | 3 | In addition to get new comment notification from Email, we also provide Webhook. 4 | 5 | ## Enable 6 | 7 | To enable webhook for project, in `Project` -> `Settings`, save your webhook url and turn on the switch button. 8 | 9 | ![](../images/enable_webhook.png ':size=500') 10 | 11 | ## Reference 12 | 13 | ### New comment 14 | 15 | When new comment comes in, Cusdis will make a `POST` request to your webhook, with below data: 16 | 17 | ```js 18 | { 19 | "type": "new_comment", 20 | "data": { 21 | "by_nickname": "xxx", 22 | "by_email": "xxx", 23 | "content": "xxx", 24 | "page_id": "xxx", 25 | "page_title": "xxx", // page title, maybe NULL 26 | "project_title": "haha", // project title 27 | "approve_link": "" // use this link to approve this comment without login 28 | } 29 | } 30 | ``` 31 | 32 | ## Official Telegram bot 33 | 34 | We also provide an official Telegram bot to send notification to you, with the power of Webhook: 35 | 36 | 1. Open and start bot https://t.me/CusdisBot 37 | 2. send `/gethook` command 38 | 3. Copy the URL result and paste in Cusdis project's webhook settings 39 | -------------------------------------------------------------------------------- /public/doc/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Cusdis 2 | 3 | Thanks for taking time to contribute! 4 | 5 | This guide help you know everything about how to run Cusdis on your local machine and start developing. 6 | 7 | ## Start dev server 8 | 9 | Firstly, create a `.env` file: 10 | 11 | ```shell 12 | DB_URL=file:./db.sqlite 13 | USERNAME=admin 14 | PASSWORD=password 15 | JWT_SECRET=ofcourseistillloveyou 16 | ``` 17 | 18 | ```bash 19 | # install dependencies 20 | $ yarn 21 | 22 | # start dev server 23 | $ yarn dev 24 | ``` 25 | 26 | Now open http://localhost:3000 and signin with `admin` and `password` 27 | 28 | ### Using PostgreSQL 29 | 30 | `yarn dev` is using SQLite by default. If you want to develop with PostgreSQL, first change `DB_URL` in `.env` to your db connection url: 31 | 32 | ```shell 33 | # .env 34 | DB_URL=postgres://xxx 35 | ... 36 | ``` 37 | 38 | Then use `yarn dev:pg` to start the dev server. 39 | 40 | ## Developing widget 41 | 42 | ```bash 43 | $ yarn widget 44 | ``` 45 | 46 | The widget demo will run on http://localhost:3001 47 | 48 | Change the attributes of the widget in `widget/index.html` (Don't commit this file if you only modify something for testing). 49 | 50 | ## Modify schema 51 | 52 | Database schema is defined in `prisma/$DB_TYPE/schema.prisma`. 53 | 54 | ### Generate database migrations 55 | 56 | In general, you don't need to generate migration when contribute a new feature. Create a PR and the core team member will do this for you. 57 | 58 | -------------------------------------------------------------------------------- /public/doc/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## What if I delete a project? 4 | 5 | 1. You won't see the project in dashboard 6 | 2. Comment widget using this project's id won't be displayed in your website -------------------------------------------------------------------------------- /public/doc/features/moderation.md: -------------------------------------------------------------------------------- 1 | # Moderation 2 | 3 | Since we don't require user sign in to comment, all comments are NOT displayed by default, until the moderator approve it. 4 | 5 | Cusdis provides a moderate dashboard, let you manage all the comments. 6 | 7 | ![image](https://user-images.githubusercontent.com/55474996/235554013-466a245a-39b2-4225-ba18-a91edcf2bfab.png) 8 | -------------------------------------------------------------------------------- /public/doc/features/notification.md: -------------------------------------------------------------------------------- 1 | # Notification 2 | 3 | ## Hosted Service 4 | 5 | Our [hosted service](https://cusdis.com/dashboard) comes with Email notification: 6 | 7 | ![](../images/email.png ':size=600') 8 | 9 | As you can see in the mail content, you can even approve the comment without login. 10 | 11 | ### Disable notification by project 12 | 13 | You can disable notification for specific project in `Websites` -> `Project` -> `Settings`: 14 | 15 | ![](../images/disable-notification-in-project.png ':size=400') 16 | 17 | ### Notification Preferences 18 | 19 | You can change notification preferences (such as changing notification email address) in `User` -> `Settings`: 20 | 21 | ![](../images/advance-notification-settings.png ':size=400') 22 | 23 | ## Self-host 24 | 25 | To enable Email notification in self-host Cusdis, you need to set SMTP configuration in environment variables: 26 | 27 | - `SMTP_HOST` **required** SMTP host 28 | - `SMTP_USER` **required** SMTP username 29 | - `SMTP_PASSWORD` **required** SMTP password 30 | - `SMTP_SENDER` **required** sender email address 31 | - `SMTP_PORT` default: 587 SMTP port 32 | - `SMTP_SECURE` default: `true` enable SMTP secure 33 | 34 | > Remember to set `HOST` to your own domain name, in order to get the correct approve link and unsubscribe link in the Email content 35 | 36 | ### SMTP Configuration Examples 37 | 38 | #### Gmail 39 | 40 | First, visit [Google Account Security](https://myaccount.google.com/security) and make sure you have enabled the Two-factor Authentication. 41 | Then, go to [application passwords](https://myaccount.google.com/apppasswords) and create a new password for Cusdis. The configurations would be as following: 42 | 43 | ``` 44 | SMTP_HOST=smtp.gmail.com 45 | SMTP_PORT=465 46 | SMTP_SECURE=true 47 | SMTP_USER=your gmail email 48 | SMTP_PASSWORD= 49 | SMTP_SENDER=your gmail email 50 | ``` 51 | 52 | > The sender email MUST be the same as login user, but you can give it a display name by `John Doe `, the same applies for other SMTP services. 53 | 54 | #### QQ mail 55 | 56 | Follow [the help page](http://service.mail.qq.com/cgi-bin/help?subtype=1&&id=28&&no=1001256) to generate an authorization code, also make sure you have enabled the IMAP/SMTP service. The configurations would be as following: 57 | 58 | ``` 59 | SMTP_HOST=smtp.qq.com 60 | SMTP_PORT=465 61 | SMTP_SECURE=true 62 | SMTP_USER=your qq mail 63 | SMTP_PASSWORD= 64 | SMTP_SENDER=your qq mail 65 | ``` 66 | 67 | #### 163 mail 68 | 69 | Similarly, you need an authorization code to use SMTP service, follow [this page](https://help.mail.163.com/faqDetail.do?code=d7a5dc8471cd0c0e8b4b8f4f8e49998b374173cfe9171305fa1ce630d7f67ac2cda80145a1742516) to create one. The configurations would be as following: 70 | 71 | ``` 72 | SMTP_HOST=smtp.163.com 73 | SMTP_PORT=465 74 | SMTP_SECURE=true 75 | SMTP_USER=your 163 mail 76 | SMTP_PASSWORD= 77 | SMTP_SENDER=your 163 mail 78 | ``` 79 | -------------------------------------------------------------------------------- /public/doc/images/advance-notification-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/advance-notification-settings.png -------------------------------------------------------------------------------- /public/doc/images/disable-notification-in-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/disable-notification-in-project.png -------------------------------------------------------------------------------- /public/doc/images/email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/email.png -------------------------------------------------------------------------------- /public/doc/images/enable_webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/enable_webhook.png -------------------------------------------------------------------------------- /public/doc/images/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/notification.png -------------------------------------------------------------------------------- /public/doc/images/pull-based-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/pull-based-notification.png -------------------------------------------------------------------------------- /public/doc/images/redeploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/redeploy.png -------------------------------------------------------------------------------- /public/doc/images/webhook-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/webhook-notification.png -------------------------------------------------------------------------------- /public/doc/images/y3FkAY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/doc/images/y3FkAY.png -------------------------------------------------------------------------------- /public/doc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Cusdis Documentation 9 | 10 | 11 | 12 | 13 | 17 |
18 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/doc/integration/docsify.md: -------------------------------------------------------------------------------- 1 | # Integrate Cusdis into Docsify 2 | 3 | [Docsify](https://docsify.js.org) is a powerful document site generator, which also powers this Cusdis document. Cusdis has a built-in Docsify plugin. 4 | 5 | ## Usage 6 | 7 | ```html 8 | 16 | 17 | 18 | 19 | ``` 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/doc/integration/jekyll.md: -------------------------------------------------------------------------------- 1 | # Integrate Cusdis into Jekyll 2 | 3 | [Jekyll](https://jekyllrb.com/) is a blog-aware static site generator in Ruby. 4 | 5 | ## Usage 6 | 7 | ```html 8 | {%- if page.id -%} 9 | 11 |
18 | 19 | {%- endif -%} 20 | ``` 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/doc/integration/mkdocs.md: -------------------------------------------------------------------------------- 1 | # Integrate Cusdis into Mkdocs 2 | 3 | [MkDocs](https://www.mkdocs.org/) is a **fast**, **simple** and **convenient** static site generator geared towards building project documentation. Documentation source files are written in Markdown, and configured with a single YAML configuration file. 4 | 5 | [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) is the most commonly used theme for [MkDocs](https://www.mkdocs.org/). 6 | 7 | Once you have [Python](https://www.python.org/) installed on your system, as well as [pip](https://pip.readthedocs.io/en/stable/installing/), you can easily install mkdocs with material-mkdocs and start building your own site. For more detail, follow the documentation of [Getting started](https://squidfunk.github.io/mkdocs-material/getting-started/) 8 | 9 | ## Usage 10 | 11 | Here's the tutorial for integrating Cusdis into [Material for MkDocs] following its [comment system configuratin](https://squidfunk.github.io/mkdocs-material/setup/adding-a-comment-system/). As for other [themes](https://github.com/mkdocs/mkdocs/wiki/MkDocs-Themes), you can achieve it in a similar way! 12 | 13 | ### Configure the `mkdocs.yml` 14 | 15 | We need add more kv pairs to `mkdocs.yml` with [`extra`](https://www.mkdocs.org/user-guide/configuration/#extra) setting: 16 | 17 | ```yaml 18 | extra: 19 | disqus: 20 | cusdis: 21 | host: 22 | app_id: 23 | lang: 24 | ``` 25 | 26 | The `lang` setting aims to support [cusdis i18n](../advanced/i18n.md?id=current-support-language). 27 | 28 | ### Rewrite the template 29 | 30 | We need first extend the theme and [override the `disqus block`](https://squidfunk.github.io/mkdocs-material/customization/#extending-the-theme) to support Cusdis comment system. 31 | 32 | Inorder to override, we can replace it with a file of the same `disqus.html` name and locate in the `overrides directory`: 33 | 34 | ```txt 35 | . 36 | ├─ overrides/ 37 | │ └─ partials/ 38 | | └─ partials/ 39 | │ └─ disqus.html 40 | └─ mkdocs.yml 41 | ``` 42 | 43 | Add the following line to `disqus.html`: 44 | 45 | ```html 46 | {% set cusdis = config.extra.cusdis %} 47 | {% if page and page.meta and page.meta.cusdis is string %} 48 | {% set cusdis = page.meta.cusdis %} 49 | {% endif %} 50 | {% if not page.is_homepage %} 51 |
52 |
55 |
56 |
57 | 89 | 97 | 98 | {% endif %} 99 | ``` 100 | -------------------------------------------------------------------------------- /public/doc/self-host/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | ```bash 4 | docker run \ 5 | -d \ 6 | -e USERNAME=admin \ 7 | -e PASSWORD=password \ 8 | -e JWT_SECRET=ofcourseistillloveyou \ 9 | -e DB_URL=file:/data/db.sqlite \ 10 | -e NEXTAUTH_URL=http://IP_ADDRESS_OR_DOMAIN \ 11 | -p 3000:3000 \ 12 | -v /data:/data \ 13 | djyde/cusdis 14 | ``` 15 | 16 | > Remember to change the `http://IP_ADDRESS_OR_DOMAIN` to your machine host or domain 17 | 18 | Then visit `http://IP_ADDRESS_OR_DOMAIN` 19 | 20 | ## Env 21 | 22 | - `USERNAME` (required) username to login 23 | - `PASSWORD` (required) password to login 24 | - `DB_URL` (required) where to store your data 25 | - If you use SQLite, must have a `file:` prefix, like `file:/data/db.sqlite` 26 | - If you use pgsql, set it to your pgsql connection url 27 | - If you use mysql, set it to your mysql connection url 28 | - `NEXTAUTH_URL` (required) your machine host (IP address or domain like `https://foo.com`) 29 | - `HOST` your machine host (IP address or domain like `https://foo.com`), default will be `https://cusdis.com`. It affects the redirect address of the approval link. 30 | - `JWT_SECRET` jwt secret 31 | - `DB_TYPE` 32 | - `sqlite` (default) 33 | - `pgsql` 34 | - `mysql` 35 | 36 | ### PostgreSQL (optional) 37 | 38 | You can connect Cusdis to an exist pgsql instead of SQLite: 39 | 40 | ```bash 41 | docker run \ 42 | -d \ 43 | -e USERNAME=djyde \ 44 | -e PASSWORD=password \ 45 | -e JWT_SECRET=ofcourseistillloveyou \ 46 | -e DB_TYPE=pgsql 47 | -e DB_URL=YOUR_PGSQL_URL \ 48 | -e NEXTAUTH_URL=http://IP_ADDRESS_OR_DOMAIN \ 49 | -p 3000:3000 \ 50 | djyde/cusdis 51 | ``` 52 | 53 | Or you can use `docker compose` to use a new pgsql. 54 | 55 | Create a `docker-compose.yaml`: 56 | 57 | ```yml 58 | version: "3.9" 59 | services: 60 | cusdis: 61 | image: "djyde/cusdis" 62 | ports: 63 | - "3000:3000" 64 | environment: 65 | - USERNAME=admin 66 | - PASSWORD=password 67 | - JWT_SECRET=ofcourseistillloveyou 68 | - NEXTAUTH_URL=http://IP_ADDRESS_OR_DOMAIN 69 | - DB_TYPE=pgsql 70 | - DB_URL=postgresql://cusdis:password@pgsql:5432/cusdis 71 | pgsql: 72 | image: "postgres:13" 73 | volumes: 74 | - "./data:/var/lib/postgresql/data" 75 | environment: 76 | - POSTGRES_USER=cusdis 77 | - POSTGRES_DB=cusdis 78 | - POSTGRES_PASSWORD=password 79 | ``` 80 | 81 | > Remember to change the `http://IP_ADDRESS_OR_DOMAIN` to your machine host or domain 82 | 83 | Then run `docker-compose up` 84 | 85 | -------------------------------------------------------------------------------- /public/doc/self-host/manual.md: -------------------------------------------------------------------------------- 1 | # Manual Install 2 | 3 | Make sure you have Node.js and yarn installed on your system, and clone the repository 4 | 5 | ```bash 6 | $ git clone https://github.com/djyde/cusdis.git 7 | $ cd cusdis 8 | $ yarn install 9 | ``` 10 | Put a `.env` file under the project root with the environment variable settings, here is an example: 11 | 12 | ``` 13 | USERNAME=admin 14 | PASSWORD=password 15 | JWT_SECRET=ofcourseistillloveyou 16 | NEXTAUTH_URL=http://IP_ADDRESS_OR_DOMAIN 17 | HOST=http://IP_ADDRESS_OR_DOMAIN 18 | DB_TYPE=sqlite 19 | DB_URL=file:./data.db 20 | ``` 21 | 22 | Then build the application and run: 23 | 24 | ```bash 25 | $ yarn run build:without-migrate 26 | $ yarn run start:with-migrate 27 | ``` 28 | 29 | Now the application should be running on `3000` port, you can visit it via `http://localhost:3000`. You may need Nginx to serve the application, here is an example of Nginx config: 30 | 31 | ```nginx 32 | ... 33 | location / { 34 | proxy_pass http://127.0.0.1:3000; 35 | proxy_pass_header Authorization; 36 | proxy_pass_header WWW-Authenticate; 37 | proxy_set_header Host $host; 38 | proxy_set_header X-Real-IP $remote_addr; 39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 40 | } 41 | ... 42 | ``` 43 | -------------------------------------------------------------------------------- /public/doc/self-host/railway.md: -------------------------------------------------------------------------------- 1 | # Railway 2 | 3 | Railway offers a one-click deploy for Cusdis which uses a PostgreSQL database that is automagically provisioned for you. 4 | 5 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Frailwayapp-starters%2Fcusdis&plugins=postgresql&envs=JWT_SECRET%2CUSERNAME%2CPASSWORD%2CNEXTAUTH_URL%2CPORT&USERNAMEDesc=Username+for+Cusdis+dashboard&PASSWORDDesc=Password+for+Cusdis+dashboard&NEXTAUTH_URLDesc=The+domain+for+your+Cusdis+app.+You+can+set+this+after+the+initial+deployment.&PORTDesc=The+default+PORT.+Do+not+change+this.&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000%2F&PORTDefault=3000) 6 | 7 | After the initial deployment succeeds, your project will get a domain that you can use as your `NEXTAUTH_URL` environment variable. Railway will then automatically re-deploy your project and you should then be able to use your Cusdis instance. 8 | 9 | ## Environment variables reference 10 | 11 | - **JWT_SECRET**: Secret key to sign JWT tokens. 12 | - **USERNAME**: Username for Cusdis dashboard 13 | - **PASSWORD**: Password for Cusdis dashboard 14 | - **NEXTAUTH_URL**: Initially set to `http://localhost:3000/` as a placeholder but should be updated after initial deployment. 15 | - **PORT**: Already set to `3000`. **Do not change this**. 16 | -------------------------------------------------------------------------------- /public/doc/self-host/vercel.md: -------------------------------------------------------------------------------- 1 | # Vercel 2 | 3 | Since Cusdis itself is built with Next.js, you can deploy your own Cusdis on Vercel in just one click! 4 | 5 | > Before deploying on Vercel, make sure you had have a connectable PostgreSQL connection url (like `postgresql://johndoe:randompassword@localhost:5432/mydb`) 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fdjyde%2Fcusdis&env=USERNAME,PASSWORD,DB_URL,JWT_SECRET&envDescription=Environment%20variables%20reference&envLink=https%3A%2F%2Fcusdis.com%2Fdoc) 8 | 9 | After the initial deploying success, get your production deployment domain (like `https://foo.vercel.app`), then set `NEXTAUTH_URL` environment variable to this domain: 10 | 11 | ![](../images/y3FkAY.png ':size=800') 12 | 13 | Then redeploy the application: 14 | 15 | ![](../images/redeploy.png ':size=400') 16 | 17 | 18 | > Remember when you change the domain, you need to change `NEXTAUTH_URL` too. 19 | 20 | ## Environment Variables reference 21 | 22 | - `USERNAME` username to sign in 23 | - `PASSWORD` password to sign in 24 | - `DB_URL` valid postgresql connection url (like `postgresql://johndoe:randompassword@localhost:5432/mydb`) 25 | - `JWT_SECRET` secret key to sign jwt token. Set whatever you want. -------------------------------------------------------------------------------- /public/images/artworks/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/artworks/logo-256.png -------------------------------------------------------------------------------- /public/images/artworks/logo-gray-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/artworks/logo-gray-256.png -------------------------------------------------------------------------------- /public/images/docsify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/images/email_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/email_notification.png -------------------------------------------------------------------------------- /public/images/hexo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /public/images/intro-approval.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-approval.png -------------------------------------------------------------------------------- /public/images/intro-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-bot.png -------------------------------------------------------------------------------- /public/images/intro-dashboard-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-dashboard-2.png -------------------------------------------------------------------------------- /public/images/intro-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-dashboard.png -------------------------------------------------------------------------------- /public/images/intro-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-email.png -------------------------------------------------------------------------------- /public/images/intro-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/intro-widget.png -------------------------------------------------------------------------------- /public/images/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/landing.png -------------------------------------------------------------------------------- /public/images/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/og.png -------------------------------------------------------------------------------- /public/images/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/react.png -------------------------------------------------------------------------------- /public/images/svelte.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/telegram_bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/telegram_bot.png -------------------------------------------------------------------------------- /public/images/vanilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/vanilla.png -------------------------------------------------------------------------------- /public/images/vue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/images/vue.png -------------------------------------------------------------------------------- /public/js/cusdis.docsify.js: -------------------------------------------------------------------------------- 1 | window.CUSDIS_PREVENT_INITIAL_RENDER = true 2 | 3 | function CusdisPlugin(hook, vm) { 4 | const config = vm.config.cusdis 5 | 6 | const { appId, host } = config 7 | 8 | const dom = Docsify.dom 9 | 10 | hook.init((_) => { 11 | const script = dom.create('script') 12 | script.async = true 13 | // script.src = `http://localhost:3000/js/cusdis.es.js`; 14 | script.src = `${host}/js/cusdis.es.js` 15 | script.setAttribute('data-timestamp', Number(new Date())) 16 | dom.appendTo(dom.body, script) 17 | }) 18 | 19 | function createCusdis() { 20 | const div = document.createElement('div') 21 | div.style.marginTop = '4rem' 22 | div.dataset.appId = appId 23 | div.dataset.pageId = vm.route.path 24 | div.dataset.pageTitle = vm.route.path 25 | div.dataset.host = host 26 | return div 27 | } 28 | 29 | hook.doneEach(() => { 30 | const cusdis = createCusdis() 31 | dom.find('#main').append(cusdis) 32 | // TODO: waiting for script loaded 33 | window.renderCusdis(cusdis) 34 | }) 35 | } 36 | 37 | window.$docsify.plugins = (window.$docsify.plugins || []).concat(CusdisPlugin) 38 | -------------------------------------------------------------------------------- /public/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djyde/cusdis/7bcf25611de75f52b337a9bb2e6b3f931822f56c/public/landing.png -------------------------------------------------------------------------------- /scripts/vite.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: "widget", 3 | server: { 4 | hmr: { 5 | host: 'localhost' 6 | }, 7 | port: 3001, 8 | }, 9 | plugins: [ 10 | require("rollup-plugin-svelte")({ 11 | emitCss: false 12 | }), 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /scripts/vite.count.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | module.exports = { 4 | root: 'widget', 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, '..', 'widget', 'count.js'), 8 | name: 'CUSDIS_COUNT', 9 | fileName: 'cusdis-count', 10 | formats: ['umd'], 11 | }, 12 | outDir: path.resolve(__dirname, '..', 'public', 'js'), 13 | }, 14 | plugins: [ 15 | require('rollup-plugin-svelte')({ 16 | emitCss: false, 17 | }), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /scripts/vite.iframe.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | module.exports = { 4 | root: 'widget', 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, '..', 'widget', 'iframe.js'), 8 | name: 'iframe', 9 | fileName: 'iframe', 10 | formats: ['umd'] 11 | }, 12 | outDir: path.resolve(__dirname, '..', 'public', 'js'), 13 | }, 14 | plugins: [ 15 | require('rollup-plugin-svelte')({ 16 | emitCss: false, 17 | }), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /scripts/vite.sdk.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | module.exports = { 4 | root: 'widget', 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, '..', 'widget', 'sdk.js'), 8 | name: 'cusdis', 9 | }, 10 | outDir: path.resolve(__dirname, '..', 'widget', 'dist'), 11 | }, 12 | plugins: [ 13 | require('rollup-plugin-svelte')({ 14 | emitCss: false, 15 | }), 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /scripts/vite.widget.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | module.exports = { 4 | root: 'widget', 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, '..', 'widget', 'index.js'), 8 | name: 'cusdis', 9 | }, 10 | outDir: path.resolve(__dirname, '..', 'public', 'js'), 11 | }, 12 | plugins: [ 13 | require('rollup-plugin-svelte')({ 14 | emitCss: false, 15 | }), 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /service/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@prisma/client' 2 | import { RequestScopeService } from '.' 3 | 4 | export class AuthService extends RequestScopeService { 5 | constructor(req, private res) { 6 | super(req) 7 | } 8 | 9 | async authGuard() { 10 | const session = await this.getSession() 11 | if (!session) { 12 | this.res.status(403).json({ 13 | message: 'Sign in required', 14 | }) 15 | return null 16 | } 17 | return session 18 | } 19 | 20 | async projectOwnerGuard(project: Pick) { 21 | const session = await this.authGuard() 22 | 23 | if (!session) { 24 | return null 25 | } 26 | 27 | if (project.ownerId !== session.uid) { 28 | this.res.status(403).json({ 29 | message: 'Permission denied', 30 | }) 31 | return null 32 | } else { 33 | return true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /service/data.service.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../utils.server' 2 | 3 | const parser = require('xml2json') 4 | import TurndownService from 'turndown' 5 | import { statService } from './stat.service' 6 | const turndownService = new TurndownService() 7 | 8 | export type DataSchema = { 9 | pages: Array<{ 10 | uniqueId: string 11 | pageId: string 12 | url?: string 13 | title?: string 14 | }> 15 | comments: Array<{ 16 | id: string 17 | content: string 18 | createdAt: string 19 | by_nickname: string 20 | by_email?: string 21 | pageUniqueId: string 22 | parentId: string 23 | }> 24 | } 25 | 26 | export class DataService { 27 | disqusAdapter(xmlData: string): DataSchema { 28 | const parsed = JSON.parse(parser.toJson(xmlData)).disqus 29 | const threads = (parsed.thread.filter( 30 | (_) => typeof _.id === 'string' && _.isDeleted === 'false', 31 | ) as Array<{ 32 | 'dsq:id': string 33 | id: string 34 | link: string 35 | title: string 36 | createdAt: string 37 | isDeleted: string 38 | }>).map((_) => { 39 | return { 40 | uniqueId: _['dsq:id'], 41 | pageId: _.id, 42 | url: _.link, 43 | title: _.title, 44 | } as DataSchema['pages'][0] 45 | }) 46 | 47 | const posts = (parsed.post as Array<{ 48 | 'dsq:id': string 49 | message: string 50 | createdAt: string 51 | isDeleted: string 52 | thread: { 53 | 'dsq:id': string 54 | } 55 | author: { 56 | name: string 57 | isAnonymous: string 58 | username: string 59 | } 60 | parent?: { 61 | 'dsq:id': string 62 | } 63 | }>) 64 | .map((_) => { 65 | return { 66 | pageUniqueId: _.thread['dsq:id'], 67 | by_nickname: _.author.name, 68 | content: turndownService.turndown(_.message), 69 | id: _['dsq:id'], 70 | createdAt: _.createdAt, 71 | pageId: _.thread['dsq:id'], 72 | parentId: _.parent?.['dsq:id'], 73 | } as DataSchema['comments'][0] 74 | }) 75 | .filter( 76 | (post) => 77 | threads.findIndex((_) => _.uniqueId === post.pageUniqueId) !== 78 | -1, 79 | ) 80 | 81 | return { 82 | pages: threads, 83 | comments: posts, 84 | } 85 | } 86 | 87 | async import(projectId: string, schema: DataSchema) { 88 | const pages = await prisma.$transaction( 89 | schema.pages.map((thread) => { 90 | return prisma.page.upsert({ 91 | where: { 92 | id: thread.uniqueId, 93 | }, 94 | create: { 95 | id: thread.uniqueId, 96 | projectId, 97 | slug: thread.pageId, 98 | url: thread.url, 99 | title: thread.title, 100 | }, 101 | update: {}, 102 | }) 103 | }), 104 | ) 105 | 106 | const upsertedPosts = await prisma.$transaction( 107 | schema.comments.map((post) => { 108 | return prisma.comment.upsert({ 109 | where: { 110 | id: post.id, 111 | }, 112 | create: { 113 | id: post.id, 114 | content: post.content, 115 | createdAt: post.createdAt, 116 | by_nickname: post.by_nickname, 117 | pageId: post.pageUniqueId, 118 | parentId: post.parentId, 119 | }, 120 | update: {}, 121 | }) 122 | }), 123 | ) 124 | 125 | return { 126 | threads: pages, 127 | posts: upsertedPosts 128 | } 129 | } 130 | 131 | async importFromDisqus(projectId: string, xmlData: string) { 132 | 133 | const result = await this.import(projectId, this.disqusAdapter(xmlData)) 134 | statService.capture('import_disqus') 135 | 136 | return result 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /service/email.service.ts: -------------------------------------------------------------------------------- 1 | import { resolvedConfig } from '../utils.server' 2 | import * as nodemailer from 'nodemailer' 3 | import sgMail from '@sendgrid/mail' 4 | import { statService } from './stat.service' 5 | 6 | export class EmailService { 7 | isSMTPEnable() { 8 | return ( 9 | resolvedConfig.smtp.auth.user !== undefined && 10 | resolvedConfig.smtp.auth.pass !== undefined && 11 | resolvedConfig.smtp.host !== undefined && 12 | resolvedConfig.smtp.senderAddress !== undefined 13 | ) 14 | } 15 | 16 | isThirdpartyEnable() { 17 | return resolvedConfig.sendgrid.apiKey 18 | } 19 | 20 | get sender() { 21 | return resolvedConfig.smtp.senderAddress 22 | } 23 | 24 | async send(msg: { to: string; from: string; subject: string; html: string }) { 25 | if (this.isSMTPEnable()) { 26 | const transporter = nodemailer.createTransport({ 27 | host: resolvedConfig.smtp.host, 28 | port: resolvedConfig.smtp.port, 29 | secure: resolvedConfig.smtp.secure, 30 | auth: resolvedConfig.smtp.auth, 31 | }) 32 | await transporter.sendMail(msg) 33 | } else if (this.isThirdpartyEnable()) { 34 | sgMail.setApiKey(resolvedConfig.sendgrid.apiKey) 35 | await sgMail.send(msg) 36 | statService.capture('notification_email') 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /service/hook.service.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Page, Project } from "@prisma/client"; 2 | import { RequestScopeService } from "."; 3 | import { NotificationService } from "./notification.service"; 4 | import { WebhookService } from "./webhook.service"; 5 | 6 | export class HookService extends RequestScopeService { 7 | 8 | notificationService = new NotificationService(this.req) 9 | webhookService = new WebhookService(this.req) 10 | 11 | async addComment(comment: Comment, projectId: string) { 12 | this.notificationService.addComment(comment, projectId) 13 | this.webhookService.addComment(comment, projectId) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /service/index.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "../utils.server" 2 | 3 | export type UserSession = { 4 | user: { 5 | name: string, 6 | email: string 7 | }, 8 | uid: string 9 | } 10 | 11 | export abstract class RequestScopeService { 12 | constructor(protected req) {} 13 | 14 | protected async getSession() { 15 | return await getSession(this.req) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /service/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@prisma/client' 2 | import { RequestScopeService } from '.' 3 | import { getSession, prisma, resolvedConfig } from '../utils.server' 4 | import { UserService } from './user.service' 5 | import { markdown } from './comment.service' 6 | import { TokenService } from './token.service' 7 | import { EmailService } from './email.service' 8 | import { makeNewCommentEmailTemplate } from '../templates/new_comment' 9 | 10 | export class NotificationService extends RequestScopeService { 11 | userService = new UserService(this.req) 12 | tokenService = new TokenService() 13 | emailService = new EmailService() 14 | 15 | // notify when new comment added 16 | async addComment(comment: Comment, projectId: string) { 17 | // don't notify if comment is created by moderator 18 | if (comment.moderatorId) { 19 | return 20 | } 21 | 22 | // check if user enable notify 23 | const project = await prisma.project.findUnique({ 24 | where: { 25 | id: projectId, 26 | }, 27 | select: { 28 | enableNotification: true, 29 | owner: { 30 | select: { 31 | id: true, 32 | email: true, 33 | enableNewCommentNotification: true, 34 | notificationEmail: true, 35 | }, 36 | }, 37 | }, 38 | }) 39 | 40 | // don't notify if disable in project settings 41 | if (!project.enableNotification) { 42 | return 43 | } 44 | 45 | const fullComment = await prisma.comment.findUnique({ 46 | where: { 47 | id: comment.id, 48 | }, 49 | select: { 50 | page: { 51 | select: { 52 | title: true, 53 | slug: true, 54 | project: { 55 | select: { 56 | title: true, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }) 63 | 64 | const notificationEmail = 65 | project.owner.notificationEmail || project.owner.email 66 | 67 | if (project.owner.enableNewCommentNotification) { 68 | let unsubscribeToken = this.tokenService.genUnsubscribeNewCommentToken( 69 | project.owner.id, 70 | ) 71 | 72 | const approveToken = await this.tokenService.genApproveToken(comment.id) 73 | 74 | const msg = { 75 | to: notificationEmail, // Change to your recipient 76 | from: resolvedConfig.smtp.senderAddress, 77 | subject: `New comment on "${fullComment.page.project.title}"`, 78 | html: makeNewCommentEmailTemplate({ 79 | page_slug: fullComment.page.title || fullComment.page.slug, 80 | by_nickname: comment.by_nickname, 81 | approve_link: `${resolvedConfig.host}/open/approve?token=${approveToken}`, 82 | unsubscribe_link: `${resolvedConfig.host}/api/open/unsubscribe?token=${unsubscribeToken}`, 83 | content: markdown.render(comment.content), 84 | notification_preferences_link: `${resolvedConfig.host}/user`, 85 | }), 86 | } 87 | 88 | try { 89 | this.emailService.send(msg) 90 | } catch (e) { 91 | // TODO: 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /service/page.service.ts: -------------------------------------------------------------------------------- 1 | import { RequestScopeService } from "."; 2 | import { prisma } from "../utils.server"; 3 | 4 | export class PageService extends RequestScopeService { 5 | async upsertPage( 6 | slug: string, 7 | projectId: string, 8 | options?: { 9 | pageUrl?: string; 10 | pageTitle?: string; 11 | } 12 | ) { 13 | //TODO: should use unique index 14 | const exist = await prisma.page.findFirst({ 15 | where: { 16 | projectId, 17 | slug, 18 | }, 19 | }); 20 | 21 | if (!exist) { 22 | return await prisma.page.create({ 23 | data: { 24 | title: options?.pageTitle, 25 | url: options?.pageUrl, 26 | slug, 27 | projectId, 28 | }, 29 | }); 30 | } else { 31 | await prisma.page.updateMany({ 32 | where: { 33 | projectId, 34 | slug, 35 | }, 36 | data: { 37 | title: options?.pageTitle, 38 | url: options?.pageUrl, 39 | }, 40 | }) 41 | return exist; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /service/project.service.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, Project, User } from '@prisma/client' 2 | import { nanoid } from 'nanoid' 3 | import { RequestScopeService } from '.' 4 | import { prisma } from '../utils.server' 5 | import { statService } from './stat.service' 6 | 7 | export class ProjectService extends RequestScopeService { 8 | async create(title: string) { 9 | const session = await this.getSession() 10 | const created = await prisma.project.create({ 11 | data: { 12 | title, 13 | owner: { 14 | connect: { 15 | id: session.uid, 16 | }, 17 | }, 18 | }, 19 | }) 20 | 21 | statService.capture('project_create') 22 | 23 | return created 24 | } 25 | 26 | async get( 27 | projectId: string, 28 | options?: { 29 | select?: Prisma.ProjectSelect 30 | }, 31 | ) { 32 | const project = await prisma.project.findUnique({ 33 | where: { 34 | id: projectId, 35 | }, 36 | select: options?.select, 37 | }) 38 | 39 | return project 40 | } 41 | 42 | // list all projects 43 | async list() { 44 | const session = await this.getSession() 45 | const projects = await prisma.project.findMany({ 46 | where: { 47 | deletedAt: null, 48 | ownerId: session.uid, 49 | }, 50 | select: { 51 | id: true, 52 | title: true, 53 | } 54 | }) 55 | 56 | return projects 57 | } 58 | 59 | // (re)generate token 60 | async regenerateToken(projectId: string) { 61 | const id = nanoid(24) 62 | await prisma.project.update({ 63 | where: { 64 | id: projectId, 65 | }, 66 | data: { 67 | token: id, 68 | }, 69 | }) 70 | 71 | return id 72 | } 73 | 74 | async getFirstProject(ownerId: string, options?: { 75 | select?: Prisma.ProjectSelect 76 | }) { 77 | const project = await prisma.project.findFirst({ 78 | where: { 79 | ownerId, 80 | deletedAt: null 81 | }, 82 | orderBy: { 83 | createdAt: 'asc' 84 | }, 85 | select: options?.select 86 | }) 87 | 88 | return project as Project 89 | } 90 | 91 | async fetchLatestComment( 92 | projectId: string, 93 | options?: { 94 | from?: Date 95 | take?: number, 96 | markAsRead?: boolean 97 | }, 98 | ) { 99 | const now = new Date() 100 | const results = await prisma.comment.findMany({ 101 | orderBy: { 102 | createdAt: 'desc', 103 | }, 104 | take: options?.take || 20, 105 | where: { 106 | deletedAt: { 107 | equals: null 108 | }, 109 | approved: false, 110 | moderatorId: { 111 | equals: null 112 | }, 113 | page: { 114 | projectId, 115 | }, 116 | createdAt: { 117 | gte: options?.from ? options?.from : undefined, 118 | }, 119 | }, 120 | select: { 121 | by_email: true, 122 | by_nickname: true, 123 | content: true, 124 | createdAt: true, 125 | }, 126 | }) 127 | 128 | if (options?.markAsRead) { 129 | await prisma.project.update({ 130 | where: { 131 | id: projectId 132 | }, 133 | data: { 134 | fetchLatestCommentsAt: now 135 | } 136 | }) 137 | } 138 | 139 | return results 140 | } 141 | 142 | async delete(projectId: string) { 143 | await prisma.project.update({ 144 | where: { 145 | id: projectId 146 | }, 147 | data:{ 148 | deletedAt: new Date() 149 | } 150 | }) 151 | 152 | statService.capture('project_delete') 153 | } 154 | 155 | async isDeleted(projectId: string) { 156 | const project = await prisma.project.findUnique({ 157 | where: { 158 | id: projectId 159 | }, 160 | select: { 161 | deletedAt: true 162 | } 163 | }) 164 | 165 | if (project && !project.deletedAt) { 166 | return false 167 | } 168 | 169 | return true 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /service/stat.service.ts: -------------------------------------------------------------------------------- 1 | import { resolvedConfig, sentry } from '../utils.server' 2 | import { MiniCapture } from 'mini-capture' 3 | export class StatService { 4 | private client = resolvedConfig.minicapture.apiKey 5 | ? new MiniCapture(resolvedConfig.minicapture.apiKey) 6 | : null 7 | 8 | async capture( 9 | event: string, 10 | options?: { 11 | identity?: string 12 | properties: any 13 | }, 14 | ) { 15 | // if (this.client) { 16 | // try { 17 | // this.client.capture(event, { 18 | // identity: options?.identity, 19 | // properties: options?.properties || {}, 20 | // }) 21 | // } catch (e) { 22 | // console.error(e) 23 | // // TODO: log error 24 | // } 25 | // } else { 26 | // return null 27 | // } 28 | } 29 | 30 | start( 31 | op: string, 32 | name: string, 33 | options?: { 34 | description?: string 35 | tags?: Record 36 | }, 37 | ) { 38 | if (sentry) { 39 | const transaction = sentry.startTransaction({ 40 | op, 41 | name, 42 | tags: options?.tags, 43 | description: options?.description, 44 | }) 45 | return { 46 | end() { 47 | transaction.finish() 48 | }, 49 | } 50 | } else { 51 | return { 52 | end() {}, 53 | } 54 | } 55 | } 56 | } 57 | 58 | export const statService = new StatService() 59 | -------------------------------------------------------------------------------- /service/subscription.service.ts: -------------------------------------------------------------------------------- 1 | import { UsageLabel, usageLimitation } from "../config.common" 2 | import { prisma, resolvedConfig } from "../utils.server" 3 | 4 | export class SubscriptionService { 5 | async update(body) { 6 | const { 7 | order_id, 8 | product_id, 9 | variant_id, 10 | customer_id, 11 | status, 12 | ends_at, 13 | urls: { 14 | update_payment_method 15 | } 16 | } = body.data.attributes 17 | 18 | const lemonSubscriptionId = body.data.id 19 | 20 | const { 21 | user_id 22 | } = body.meta.custom_data 23 | 24 | await prisma.subscription.upsert({ 25 | where: { 26 | userId: user_id 27 | }, 28 | create: { 29 | userId: user_id, 30 | orderId: `${order_id}`, 31 | productId: `${product_id}`, 32 | variantId: `${variant_id}`, 33 | customerId: `${customer_id}`, 34 | endsAt: ends_at, 35 | lemonSubscriptionId, 36 | status, 37 | updatePaymentMethodUrl: update_payment_method 38 | }, 39 | update: { 40 | userId: user_id, 41 | orderId: `${order_id}`, 42 | productId: `${product_id}`, 43 | variantId: `${variant_id}`, 44 | customerId: `${customer_id}`, 45 | lemonSubscriptionId, 46 | endsAt: ends_at, 47 | status, 48 | updatePaymentMethodUrl: update_payment_method 49 | } 50 | }) 51 | } 52 | 53 | async isActivated(userId: string) { 54 | const subscription = await prisma.subscription.findUnique({ 55 | where: { 56 | userId 57 | }, 58 | }) 59 | 60 | if (!subscription) { 61 | return false 62 | } 63 | 64 | let isActived = subscription?.status === 'active' || subscription?.status === 'cancelled' 65 | 66 | return isActived 67 | } 68 | 69 | async getStatus(userId: string) { 70 | const subscription = await prisma.subscription.findUnique({ 71 | where: { 72 | userId 73 | }, 74 | }) 75 | 76 | return { 77 | isActived: await this.isActivated(userId), 78 | status: subscription?.status || '', 79 | endAt: subscription?.endsAt?.toISOString() || '', 80 | updatePaymentMethodUrl: subscription?.updatePaymentMethodUrl || '' 81 | } 82 | } 83 | 84 | async createProjectValidate(userId: string) { 85 | if (!resolvedConfig.checkout.enabled) { 86 | return true 87 | } 88 | 89 | const [projectCount] = await prisma.$transaction([ 90 | prisma.project.count({ 91 | where: { 92 | ownerId: userId, 93 | deletedAt: { 94 | equals: null 95 | } 96 | } 97 | }), 98 | ]) 99 | 100 | if (projectCount < usageLimitation['create_site']) { 101 | return true 102 | } 103 | 104 | if (await this.isActivated(userId)) { 105 | return true 106 | } 107 | 108 | return false 109 | } 110 | 111 | async approveCommentValidate(userId: string) { 112 | if (!resolvedConfig.checkout.enabled) { 113 | return true 114 | } 115 | 116 | const [usage] = await prisma.$transaction([ 117 | prisma.usage.findUnique({ 118 | where: { 119 | userId_label: { 120 | userId, 121 | label: UsageLabel.ApproveComment 122 | } 123 | } 124 | }), 125 | ]) 126 | 127 | if (await this.isActivated(userId)) { 128 | return true 129 | } 130 | 131 | if (!usage) { 132 | return true 133 | } 134 | 135 | if (usage.count <= usageLimitation[UsageLabel.ApproveComment]) { 136 | return true 137 | } 138 | 139 | return false 140 | } 141 | 142 | async quickApproveValidate(userId: string) { 143 | if (!resolvedConfig.checkout.enabled) { 144 | return true 145 | } 146 | 147 | const [usage] = await prisma.$transaction([ 148 | prisma.usage.findUnique({ 149 | where: { 150 | userId_label: { 151 | userId, 152 | label: UsageLabel.QuickApprove 153 | } 154 | } 155 | }), 156 | ]) 157 | 158 | // if (await this.isActivated(userId)) { 159 | // return true 160 | // } 161 | 162 | if (!usage) { 163 | return true 164 | } 165 | 166 | if (usage.count <= usageLimitation[UsageLabel.QuickApprove]) { 167 | return true 168 | } 169 | 170 | return false 171 | } 172 | } -------------------------------------------------------------------------------- /service/token.service.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client' 2 | import jwt from 'jsonwebtoken' 3 | import { UserSession } from '.' 4 | import { prisma, resolvedConfig } from '../utils.server' 5 | 6 | export enum UnSubscribeType { 7 | NEW_COMMENT = 'NEW_COMMENT', 8 | } 9 | 10 | export enum SecretKey { 11 | ApproveComment = 'approve_comment', 12 | Unsubscribe = 'unsubscribe', 13 | AcceptNotify = 'accept_notify' 14 | } 15 | 16 | export module TokenBody { 17 | export type AcceptNotifyToken = { 18 | commentId: string 19 | } 20 | 21 | export type ApproveComment = { 22 | commentId: string, 23 | owner: User 24 | } 25 | 26 | export type UnsubscribeNewComment = { 27 | userId: string, 28 | type: UnSubscribeType 29 | } 30 | } 31 | 32 | export class TokenService { 33 | validate(token: string, secretKey: string) { 34 | const result = jwt.verify(token, `${resolvedConfig.jwtSecret}-${secretKey}`) 35 | return result 36 | } 37 | 38 | sign(secretKey: SecretKey, body, expiresIn: string) { 39 | return jwt.sign(body, `${resolvedConfig.jwtSecret}-${secretKey}`, { 40 | expiresIn, 41 | }) as string 42 | } 43 | 44 | async genApproveToken(commentId: string) { 45 | 46 | const comment = await prisma.comment.findUnique({ 47 | where: { 48 | id: commentId 49 | }, 50 | select: { 51 | page: { 52 | select: { 53 | project: { 54 | select: { 55 | owner: true 56 | } 57 | } 58 | } 59 | } 60 | } 61 | }) 62 | 63 | return this.sign( 64 | SecretKey.ApproveComment, 65 | { 66 | commentId, 67 | owner: comment.page.project.owner, 68 | } as TokenBody.ApproveComment, 69 | '3 days', 70 | ) 71 | } 72 | 73 | genUnsubscribeNewCommentToken(userId: string) { 74 | return this.sign( 75 | SecretKey.Unsubscribe, 76 | { 77 | userId, 78 | type: UnSubscribeType.NEW_COMMENT, 79 | } as TokenBody.UnsubscribeNewComment, 80 | '1y', 81 | ) 82 | } 83 | 84 | genAcceptNotifyToken(commentId: string) { 85 | return this.sign( 86 | SecretKey.AcceptNotify, 87 | { 88 | commentId 89 | } as TokenBody.AcceptNotifyToken, 90 | '1 day' 91 | ) 92 | } 93 | 94 | validateAcceptNotifyToken(token: string) { 95 | return this.validate(token, SecretKey.AcceptNotify) as TokenBody.AcceptNotifyToken 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /service/usage.service.ts: -------------------------------------------------------------------------------- 1 | import { RequestScopeService } from "."; 2 | import { UsageLabel } from "../config.common"; 3 | import { prisma } from "../utils.server"; 4 | 5 | export class UsageService extends RequestScopeService { 6 | async incr(label: UsageLabel) { 7 | const session = await this.getSession() 8 | 9 | await prisma.usage.upsert({ 10 | where: { 11 | userId_label: { 12 | userId: session.uid, 13 | label, 14 | } 15 | }, 16 | create: { 17 | userId: session.uid, 18 | label, 19 | count: 1 20 | }, 21 | update: { 22 | count: { 23 | increment: 1 24 | } 25 | } 26 | }) 27 | } 28 | } -------------------------------------------------------------------------------- /service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { RequestScopeService } from "."; 3 | import { prisma } from "../utils.server"; 4 | 5 | export class UserService extends RequestScopeService { 6 | async update(userId: string, options: { 7 | displayName?: string, 8 | notificationEmail?: string, 9 | enableNewCommentNotification?: boolean 10 | }) { 11 | await prisma.user.update({ 12 | where: { 13 | id: userId 14 | }, 15 | data: { 16 | displayName: options.displayName, 17 | notificationEmail: options.notificationEmail, 18 | enableNewCommentNotification: options.enableNewCommentNotification 19 | } 20 | }) 21 | } 22 | } -------------------------------------------------------------------------------- /service/viewData.service.ts: -------------------------------------------------------------------------------- 1 | // A service for rendering page 2 | 3 | import { RequestScopeService, UserSession } from "."; 4 | import { UsageLabel } from "../config.common"; 5 | import { prisma, resolvedConfig } from "../utils.server"; 6 | import { ProjectService } from "./project.service"; 7 | import { SubscriptionService } from './subscription.service' 8 | 9 | export class ViewDataService extends RequestScopeService { 10 | private projectService = new ProjectService(this.req) 11 | private subscriptionService = new SubscriptionService() 12 | 13 | async fetchMainLayoutData() { 14 | const session = await this.getSession() 15 | 16 | const userInfo = await prisma.user.findUnique({ 17 | where: { 18 | id: session.uid 19 | }, 20 | select: { 21 | notificationEmail: true, 22 | enableNewCommentNotification: true, 23 | name: true, 24 | email: true, 25 | displayName: true 26 | } 27 | }) 28 | 29 | const [projectCount, approveCommentUsage, quickApproveUsage] = await prisma.$transaction([ 30 | prisma.project.count({ 31 | where: { 32 | ownerId: session.uid, 33 | deletedAt: { 34 | equals: null 35 | } 36 | } 37 | }), 38 | prisma.usage.findUnique({ 39 | where: { 40 | userId_label: { 41 | userId: session.uid, 42 | label: UsageLabel.ApproveComment 43 | } 44 | }, 45 | select: { 46 | count: true 47 | } 48 | }), 49 | prisma.usage.findUnique({ 50 | where: { 51 | userId_label: { 52 | userId: session.uid, 53 | label: UsageLabel.QuickApprove 54 | } 55 | }, 56 | select: { 57 | count: true 58 | } 59 | }) 60 | ]) 61 | 62 | return { 63 | session, 64 | projects: await this.projectService.list(), 65 | subscription: await this.subscriptionService.getStatus(session.uid), 66 | usage: { 67 | projectCount, 68 | approveCommentUsage: approveCommentUsage?.count ?? 0, 69 | quickApproveUsage: quickApproveUsage?.count ?? 0 70 | }, 71 | config: { 72 | isHosted: resolvedConfig.isHosted, 73 | checkout: resolvedConfig.checkout, 74 | }, 75 | userInfo 76 | } 77 | } 78 | } 79 | 80 | export type MainLayoutData = Awaited> 81 | -------------------------------------------------------------------------------- /service/webhook.service.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@prisma/client' 2 | import axios from 'axios' 3 | import { RequestScopeService } from '.' 4 | import { prisma, resolvedConfig, sentry } from '../utils.server' 5 | import { statService } from './stat.service' 6 | import { TokenService } from './token.service' 7 | 8 | export enum HookType { 9 | NewComment = 'new_comment', 10 | } 11 | 12 | export type HookBody = { 13 | type: HookType 14 | data: T 15 | } 16 | 17 | export type NewCommentHookData = { 18 | by_nickname: string 19 | by_email: string 20 | project_title: string 21 | page_id: string 22 | page_title: string 23 | content: string 24 | } 25 | 26 | export class WebhookService extends RequestScopeService { 27 | tokenService = new TokenService() 28 | 29 | async addComment(comment: Comment, projectId: string) { 30 | const project = await prisma.project.findUnique({ 31 | where: { 32 | id: projectId, 33 | }, 34 | }) 35 | 36 | if (project.enableWebhook && !comment.moderatorId && project.webhook) { 37 | 38 | const fullComment = await prisma.comment.findUnique({ 39 | where: { 40 | id: comment.id, 41 | }, 42 | select: { 43 | page: { 44 | select: { 45 | title: true, 46 | slug: true, 47 | project: { 48 | select: { 49 | title: true 50 | } 51 | } 52 | }, 53 | }, 54 | }, 55 | }) 56 | 57 | const approveToken = await this.tokenService.genApproveToken(comment.id) 58 | const approveLink = `${resolvedConfig.host}/open/approve?token=${approveToken}` 59 | 60 | statService.capture('webhook_trigger', { 61 | properties: { 62 | from: 'add_comment', 63 | }, 64 | }) 65 | 66 | try { 67 | axios.post(project.webhook, { 68 | type: HookType.NewComment, 69 | data: { 70 | by_nickname: comment.by_nickname, 71 | by_email: comment.by_email, 72 | content: comment.content, 73 | page_id: fullComment.page.slug, 74 | page_title: fullComment.page.title, 75 | project_title: fullComment.page.project.title, 76 | approve_link: approveLink, 77 | }, 78 | } as HookBody) 79 | } catch (e) { 80 | 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html { 2 | 3 | } 4 | 5 | .font { 6 | font-family: 'Montserrat', sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "jsx": "preserve", 22 | "incremental": true 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "widget" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /utils.client.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const apiClient = axios.create({ 4 | baseURL: "/api", 5 | headers: { 6 | 'x-timezone-offset': -new Date().getTimezoneOffset() 7 | } 8 | }); 9 | 10 | export const VERSION = '1.4.0' 11 | -------------------------------------------------------------------------------- /utils.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { UserSession } from './service' 3 | import { getSession as nextAuthGetSession } from 'next-auth/client' 4 | import * as Sentry from '@sentry/node' 5 | import { NextApiRequest, NextApiResponse } from 'next' 6 | import nc from 'next-connect' 7 | import Boom from '@hapi/boom' 8 | 9 | type EnvVariable = string | undefined 10 | export const resolvedConfig = { 11 | useLocalAuth: process.env.USERNAME && process.env.PASSWORD, 12 | useGithub: process.env.GITHUB_ID && process.env.GITHUB_SECRET, 13 | useGitlab: process.env.GITLAB_ID && process.env.GITLAB_SECRET, 14 | jwtSecret: process.env.JWT_SECRET, 15 | isHosted: process.env.IS_HOSTED === 'true', 16 | host: process.env.HOST || 'https://cusdis.com', 17 | checkout: { 18 | enabled: process.env.CHECKOUT_URL ? true : false, 19 | url: process.env.CHECKOUT_URL as string, 20 | lemonSecret: process.env.LEMON_SECRET as string, 21 | lemonApiKey: process.env.LEMON_API_KEY as string, 22 | }, 23 | umami: { 24 | id: process.env.UMAMI_ID as EnvVariable, 25 | src: process.env.UMAMI_SRC as EnvVariable, 26 | }, 27 | google: { 28 | id: process.env.GOOGLE_ID as EnvVariable, 29 | secret: process.env.GOOGLE_SECRET as EnvVariable, 30 | }, 31 | smtp: { 32 | host: process.env.SMTP_HOST as EnvVariable, 33 | port: Number((process.env.SMTP_PORT as EnvVariable) || '587'), 34 | secure: Boolean((process.env.SMTP_SECURE as EnvVariable) || 'true'), 35 | auth: { 36 | user: process.env.SMTP_USER as EnvVariable, 37 | pass: process.env.SMTP_PASSWORD as EnvVariable, 38 | }, 39 | senderAddress: 40 | (process.env.SMTP_SENDER as EnvVariable) || 41 | 'Cusdis Notification', 42 | }, 43 | sendgrid: { 44 | apiKey: process.env.SENDGRID_API_KEY as EnvVariable, 45 | }, 46 | sentry: { 47 | dsn: process.env.SENTRY_DSN as EnvVariable, 48 | }, 49 | minicapture: { 50 | apiKey: process.env.MINICAPTURE_API_KEY as EnvVariable, 51 | }, 52 | } 53 | 54 | export const singleton = async (id: string, fn: () => Promise) => { 55 | if (process.env.NODE_ENV === 'production') { 56 | return await fn() 57 | } else { 58 | if (!global[id]) { 59 | global[id] = await fn() 60 | } 61 | return global[id] as T 62 | } 63 | } 64 | 65 | export const singletonSync = (id: string, fn: () => T) => { 66 | if (process.env.NODE_ENV === 'production') { 67 | return fn() 68 | } else { 69 | if (!global[id]) { 70 | global[id] = fn() 71 | } 72 | return global[id] as T 73 | } 74 | } 75 | 76 | export const prisma = singletonSync('prisma', () => { 77 | return new PrismaClient() 78 | }) 79 | 80 | export const sentry = singletonSync('sentry', () => { 81 | if (resolvedConfig.sentry.dsn) { 82 | Sentry.init({ 83 | dsn: resolvedConfig.sentry.dsn, 84 | tracesSampleRate: 1.0, 85 | }) 86 | return Sentry 87 | } 88 | }) 89 | 90 | export function initMiddleware(middleware) { 91 | return (req, res) => 92 | new Promise((resolve, reject) => { 93 | middleware(req, res, (result) => { 94 | if (result instanceof Error) { 95 | return reject(result) 96 | } 97 | return resolve(result) 98 | }) 99 | }) 100 | } 101 | 102 | export const HTTPException = Boom 103 | export const apiHandler = () => { 104 | return nc({ 105 | onError(e, req, res, next) { 106 | if (Boom.isBoom(e)) { 107 | res.status(e.output.payload.statusCode) 108 | res.json({ 109 | error: e.output.payload.error, 110 | message: e.output.payload.message, 111 | }) 112 | } else { 113 | res.status(500) 114 | res.json({ 115 | message: 'Unexpected error', 116 | }) 117 | console.error(e) 118 | // unexcepted error 119 | } 120 | }, 121 | }) 122 | } 123 | 124 | export const getSession = async (req) => { 125 | return (await nextAuthGetSession({ req })) as UserSession 126 | } 127 | -------------------------------------------------------------------------------- /widget/README.md: -------------------------------------------------------------------------------- 1 | https://cusdis.com/doc -------------------------------------------------------------------------------- /widget/Widget.svelte: -------------------------------------------------------------------------------- 1 | 93 | 94 | {#if !error} 95 |
96 | {#if message} 97 |
98 | {message} 99 |
100 | {/if} 101 | 102 | 103 | 104 |
105 | 106 |
107 | {#if loadingComments} 108 |
109 | {t('loading')}... 110 |
111 | {:else} 112 | {#each commentsResult.data as comment (comment.id)} 113 | 114 | {/each} 115 | {#if commentsResult.pageCount > 1} 116 |
117 | {#each Array(commentsResult.pageCount) as _, index} 118 | 123 | {/each} 124 |
125 | {/if} 126 | {/if} 127 |
128 | 129 |
130 | 131 |
132 | {t('powered_by')} 133 |
134 |
135 | {/if} 136 | -------------------------------------------------------------------------------- /widget/components/Comment.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
21 |
22 |
23 | {comment.moderator && comment.moderator.displayName ? comment.moderator.displayName : comment.by_nickname} 24 |
25 | 26 | {#if comment.moderatorId} 27 |
28 | {t('mod_badge')} 29 |
30 | {/if} 31 |
32 | 33 |
34 | {comment.parsedCreatedAt} 35 |
36 | 37 |
38 | {@html comment.parsedContent} 39 |
40 | 41 | {#if comment.replies.data.length > 0} 42 | {#each comment.replies.data as child (child.id)} 43 | 44 | {/each} 45 | {/if} 46 | 47 |
48 | 55 |
56 | 57 | 58 | {#if showReplyForm} 59 |
60 | { 63 | showReplyForm = false 64 | }} 65 | /> 66 |
67 | {/if} 68 | 69 | 70 |
71 | -------------------------------------------------------------------------------- /widget/components/Reply.svelte: -------------------------------------------------------------------------------- 1 | 59 | 60 |
61 |
62 |
63 | 64 | 71 |
72 |
73 | 74 | 81 |
82 |
83 | 84 |
85 | 86 |