Skip to content

Commit 04a1bfc

Browse files
committed
feat(snippet): add custom path support for snippets
- Introduced a new `customPath` field in the Snippet model, allowing snippets to be accessed via custom URLs. - Implemented validation to ensure unique custom paths and prevent consecutive slashes. - Enhanced SnippetService with methods to handle custom path retrieval, caching, and deletion. - Added SnippetRouteController to manage requests for snippets based on custom paths, including throttling and authentication checks. These changes improve the flexibility and accessibility of snippets in the application. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 3e791d6 commit 04a1bfc

File tree

10 files changed

+336
-8
lines changed

10 files changed

+336
-8
lines changed

.github/workflows/ci.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,25 @@ on:
1111
name: CI
1212

1313
jobs:
14+
quality:
15+
name: Lint & Typecheck
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v6
20+
- name: Setup Node.js and pnpm
21+
uses: ./.github/actions/setup-node
22+
with:
23+
node-version: '22.x'
24+
- name: Run Lint
25+
run: npm run lint
26+
- name: Run Typecheck
27+
run: npm run typecheck
28+
1429
docker:
1530
name: Docker build
1631
runs-on: ubuntu-latest
32+
needs: quality
1733
steps:
1834
- name: Checkout
1935
uses: actions/checkout@v6
@@ -55,6 +71,7 @@ jobs:
5571
core:
5672
name: Build (Core)
5773
runs-on: ubuntu-latest
74+
needs: quality
5875
env:
5976
REDISMS_DISABLE_POSTINSTALL: 1
6077
MONGOMS_DISABLE_POSTINSTALL: 1
@@ -83,6 +100,7 @@ jobs:
83100
name: Test
84101
timeout-minutes: 10
85102
runs-on: ubuntu-latest
103+
needs: quality
86104
services:
87105
mongodb:
88106
image: mongo
@@ -103,8 +121,6 @@ jobs:
103121
run: |
104122
sudo apt-get update
105123
sudo apt-get install -y redis-server
106-
- name: Run Lint
107-
run: npm run lint
108124
- name: Run Tests
109125
run: npm run test
110126
env:

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,30 @@ env:
1414
MONGOMS_DISABLE_POSTINSTALL: 1
1515

1616
jobs:
17+
quality:
18+
name: Lint & Typecheck
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v6
23+
with:
24+
fetch-depth: 0
25+
fetch-tags: true
26+
27+
- name: Setup Node.js and pnpm
28+
uses: ./.github/actions/setup-node
29+
with:
30+
node-version: '22.x'
31+
32+
- name: Run Lint
33+
run: pnpm run lint
34+
- name: Run Typecheck
35+
run: pnpm run typecheck
36+
1737
build:
1838
name: Core
1939
runs-on: ubuntu-latest
40+
needs: quality
2041
services:
2142
redis:
2243
image: redis
@@ -66,6 +87,7 @@ jobs:
6687
docker:
6788
name: Docker (${{ matrix.platform }})
6889
runs-on: ${{ matrix.runner }}
90+
needs: quality
6991
strategy:
7092
matrix:
7193
include:

apps/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"start:cluster": "cross-env NODE_ENV=development nest start --watch -- --cluster --cluster_workers 2",
3131
"start:prod": "cross-env NODE_ENV=production node dist/src/main",
3232
"lint": "eslint src/**/*.ts --fix",
33+
"typecheck": "tsc -p tsconfig.json --noEmit",
3334
"prod": "cross-env NODE_ENV=production pm2-runtime start ecosystem.config.cjs",
3435
"prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.cjs",
3536
"prod:stop": "pm2 stop ecosystem.config.cjs",

apps/core/src/modules/draft/draft.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BizException } from '~/common/exceptions/biz.exception'
33
import { ErrorCodeEnum } from '~/constants/error-code.constant'
44
import { FileReferenceType } from '~/modules/file/file-reference.model'
55
import { FileReferenceService } from '~/modules/file/file-reference.service'
6+
import { ContentFormat } from '~/shared/types/content-format.type'
67
import { InjectModel } from '~/transformers/model.transformer'
78
import { dbTransforms } from '~/utils/db-transform.util'
89
import DiffMatchPatch from 'diff-match-patch'
@@ -97,6 +98,7 @@ export class DraftService {
9798
draft.typeSpecificData,
9899
draft.updated || draft.created || new Date(),
99100
draft.history,
101+
draft.contentFormat,
100102
)
101103

102104
draft.history.unshift(historyEntry)
@@ -283,6 +285,7 @@ export class DraftService {
283285
draft.typeSpecificData,
284286
draft.updated || new Date(),
285287
draft.history,
288+
draft.contentFormat,
286289
)
287290
draft.history.unshift(newHistoryEntry)
288291

@@ -353,6 +356,7 @@ export class DraftService {
353356
typeSpecificData: string | undefined,
354357
savedAt: Date,
355358
existingHistory: DraftHistoryModel[],
359+
contentFormat: ContentFormat = ContentFormat.Markdown,
356360
): DraftHistoryModel {
357361
const historyText = text ?? ''
358362

@@ -368,6 +372,7 @@ export class DraftService {
368372
version,
369373
title,
370374
text: historyText,
375+
contentFormat,
371376
typeSpecificData,
372377
savedAt,
373378
isFullSnapshot: true,
@@ -385,6 +390,7 @@ export class DraftService {
385390
return {
386391
version,
387392
title,
393+
contentFormat,
388394
typeSpecificData,
389395
savedAt,
390396
isFullSnapshot: false,
@@ -397,6 +403,7 @@ export class DraftService {
397403
version,
398404
title,
399405
text: patchText,
406+
contentFormat,
400407
typeSpecificData,
401408
savedAt,
402409
isFullSnapshot: false,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { All, Request, Response } from '@nestjs/common'
2+
import { Throttle } from '@nestjs/throttler'
3+
import { ApiController } from '~/common/decorators/api-controller.decorator'
4+
import { HTTPDecorators } from '~/common/decorators/http.decorator'
5+
import { IsAuthenticated } from '~/common/decorators/role.decorator'
6+
import { BizException } from '~/common/exceptions/biz.exception'
7+
import { ErrorCodeEnum } from '~/constants/error-code.constant'
8+
import type { FastifyReply, FastifyRequest } from 'fastify'
9+
import { createMockedContextResponse } from '../serverless/mock-response.util'
10+
import { ServerlessService } from '../serverless/serverless.service'
11+
import type { SnippetModel } from './snippet.model'
12+
import { SnippetService } from './snippet.service'
13+
14+
const MAX_PREFIX_DEPTH = 10
15+
16+
@ApiController('s')
17+
export class SnippetRouteController {
18+
constructor(
19+
private readonly snippetService: SnippetService,
20+
private readonly serverlessService: ServerlessService,
21+
) {}
22+
23+
@All('*')
24+
@Throttle({
25+
default: {
26+
limit: 100,
27+
ttl: 5000,
28+
},
29+
})
30+
@HTTPDecorators.Bypass
31+
async handleCustomPath(
32+
@IsAuthenticated() isAuthenticated: boolean,
33+
@Request() req: FastifyRequest,
34+
@Response() reply: FastifyReply,
35+
) {
36+
const rawPath = req.url
37+
const sPrefixIndex = rawPath.indexOf('/s')
38+
const subPath = rawPath.slice(Math.max(0, sPrefixIndex + 2))
39+
const path = subPath.replaceAll(/^\/+|\/+$/g, '')
40+
41+
const method = req.method.toUpperCase()
42+
43+
// 1. Exact match — data type snippet
44+
const dataSnippet = await this.snippetService.getSnippetByCustomPath(path)
45+
46+
if (dataSnippet) {
47+
if (dataSnippet.private && !isAuthenticated) {
48+
throw new BizException(ErrorCodeEnum.SnippetPrivate)
49+
}
50+
51+
// check cache
52+
let cached: string | null = null
53+
if (isAuthenticated) {
54+
cached =
55+
(
56+
await Promise.all(
57+
(['public', 'private'] as const).map((type) =>
58+
this.snippetService.getCachedSnippetByCustomPath(path, type),
59+
),
60+
)
61+
).find(Boolean) || null
62+
} else {
63+
cached = await this.snippetService.getCachedSnippetByCustomPath(
64+
path,
65+
'public',
66+
)
67+
}
68+
69+
if (cached) {
70+
const json = JSON.safeParse(cached)
71+
return reply.send(json || cached)
72+
}
73+
74+
const attached = await this.snippetService.attachSnippet(dataSnippet)
75+
await this.snippetService.cacheSnippetByCustomPath(
76+
path,
77+
!!attached.private,
78+
attached.data,
79+
)
80+
return reply.send(attached.data)
81+
}
82+
83+
// 2. Exact match — function type snippet
84+
const fnSnippet = await this.snippetService.getFunctionSnippetByCustomPath(
85+
path,
86+
method,
87+
)
88+
if (fnSnippet) {
89+
return this.executeFunction(fnSnippet, isAuthenticated, req, reply)
90+
}
91+
92+
// 3. Prefix match for function type (extra path info)
93+
const segments = path.split('/')
94+
const candidatePaths: string[] = []
95+
for (
96+
let i = segments.length - 1;
97+
i >= 1 && candidatePaths.length < MAX_PREFIX_DEPTH;
98+
i--
99+
) {
100+
candidatePaths.push(segments.slice(0, i).join('/'))
101+
}
102+
103+
if (candidatePaths.length > 0) {
104+
const prefixSnippet =
105+
await this.snippetService.getFunctionSnippetByCustomPathPrefix(
106+
candidatePaths,
107+
method,
108+
)
109+
if (prefixSnippet) {
110+
return this.executeFunction(prefixSnippet, isAuthenticated, req, reply)
111+
}
112+
}
113+
114+
throw new BizException(ErrorCodeEnum.SnippetNotFound)
115+
}
116+
117+
private async executeFunction(
118+
snippet: SnippetModel,
119+
isAuthenticated: boolean,
120+
req: FastifyRequest,
121+
reply: FastifyReply,
122+
) {
123+
if (!snippet.enable) {
124+
throw new BizException(
125+
ErrorCodeEnum.InvalidParameter,
126+
'serverless function is not enabled',
127+
)
128+
}
129+
130+
if (snippet.private && !isAuthenticated) {
131+
throw new BizException(ErrorCodeEnum.ServerlessNoPermission)
132+
}
133+
134+
const result =
135+
await this.serverlessService.injectContextIntoServerlessFunctionAndCall(
136+
snippet,
137+
{ req, res: createMockedContextResponse(reply), isAuthenticated },
138+
)
139+
140+
if (!reply.sent) {
141+
return reply.send(result)
142+
}
143+
}
144+
}

apps/core/src/modules/snippet/snippet.model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export { SnippetType }
2121
@plugin(aggregatePaginate)
2222
@index({ name: 1, reference: 1 })
2323
@index({ type: 1 })
24+
@index({ customPath: 1 }, { unique: true, sparse: true })
2425
export class SnippetModel extends BaseModel {
2526
@prop({
2627
type: () => String,
@@ -53,6 +54,9 @@ export class SnippetModel extends BaseModel {
5354
@prop()
5455
method?: string
5556

57+
@prop({ trim: true, sparse: true, unique: true })
58+
customPath?: string
59+
5660
@prop({
5761
select: false,
5862
get(val) {

apps/core/src/modules/snippet/snippet.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { forwardRef, Module } from '@nestjs/common'
22
import { ServerlessModule } from '../serverless/serverless.module'
3+
import { SnippetRouteController } from './snippet-route.controller'
34
import { SnippetController } from './snippet.controller'
45
import { SnippetService } from './snippet.service'
56

67
@Module({
7-
controllers: [SnippetController],
8+
controllers: [SnippetController, SnippetRouteController],
89
exports: [SnippetService],
910
providers: [SnippetService],
1011
imports: [forwardRef(() => ServerlessModule)],

apps/core/src/modules/snippet/snippet.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export const SnippetSchema = BaseSchema.extend({
2727
metatype: z.string().max(20).optional(),
2828
schema: z.string().optional(),
2929
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ALL']).optional(),
30+
customPath: z
31+
.string()
32+
.regex(/^[\w-](?:[\w\-/]*[\w-])?$/)
33+
.refine((val) => !val.includes('//'), {
34+
message: 'customPath must not contain consecutive slashes',
35+
})
36+
.pipe(z.string().max(200))
37+
.optional()
38+
.transform((val) => val?.replace(/^\/+|\/+$/g, '') || undefined),
3039

3140
/**
3241
* For `Function` snippet only.

0 commit comments

Comments
 (0)