@bbki.ng/backend 0.3.5 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # backend
2
2
 
3
+ ## 0.3.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 21296f0: update backend
8
+
9
+ ## 0.3.7
10
+
11
+ ### Patch Changes
12
+
13
+ - 796a37a: remove unused code
14
+
15
+ ## 0.3.6
16
+
17
+ ### Patch Changes
18
+
19
+ - 625ea77: remove mdx articles
20
+
3
21
  ## 0.3.5
4
22
 
5
23
  ### Patch Changes
@@ -0,0 +1,13 @@
1
+ -- posts table for blog articles
2
+ CREATE TABLE IF NOT EXISTS posts (
3
+ id TEXT PRIMARY KEY,
4
+ title TEXT NOT NULL UNIQUE,
5
+ content TEXT NOT NULL,
6
+ author TEXT DEFAULT 'bbki.ng',
7
+ created_at TEXT NOT NULL,
8
+ updated_at TEXT NOT NULL
9
+ );
10
+
11
+ -- indexes for common queries
12
+ CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
13
+ CREATE INDEX IF NOT EXISTS idx_posts_title ON posts(title);
@@ -0,0 +1,15 @@
1
+ -- Seed test data for local development
2
+ INSERT INTO posts (id, title, content, author, created_at, updated_at) VALUES
3
+ ('test-post-001', 'Hello World', 'This is a test post for local development. Welcome to bbki.ng!', 'bbki.ng', datetime('now'), datetime('now')),
4
+ ('test-post-002', 'Second Post', 'Another test post to verify the posts list is working correctly.', 'bbki.ng', datetime('now', '-1 day'), datetime('now', '-1 day')),
5
+ ('test-post-003', 'Testing Markdown', '# Markdown Test
6
+
7
+ This post tests **bold** and *italic* text.
8
+
9
+ ## Code Block
10
+ ```javascript
11
+ const hello = "world";
12
+ console.log(hello);
13
+ ```
14
+
15
+ > A blockquote for testing.', 'bbki.ng', datetime('now', '-2 days'), datetime('now', '-2 days'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/backend",
3
- "version": "0.3.5",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@simplewebauthn/server": "13.2.2",
@@ -20,7 +20,7 @@
20
20
  "prettier": "^3.2.0",
21
21
  "typescript": "^5.3.0",
22
22
  "wrangler": "^4.58.0",
23
- "@bbki.ng/config": "1.0.3"
23
+ "@bbki.ng/config": "1.0.6"
24
24
  },
25
25
  "prettier": "@bbki.ng/config/prettier",
26
26
  "scripts": {
@@ -0,0 +1,90 @@
1
+ import { Context } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+
4
+ interface AddPostRequest {
5
+ title: string;
6
+ content: string;
7
+ author?: string;
8
+ }
9
+
10
+ const MAX_TITLE_LENGTH = 200;
11
+ const MAX_CONTENT_LENGTH = 100000;
12
+
13
+ export const addPost = async (c: Context) => {
14
+ try {
15
+ // Parse and validate request body
16
+ const body = await c.req.json<AddPostRequest>();
17
+
18
+ if (!body.title || typeof body.title !== 'string') {
19
+ throw new HTTPException(400, { message: 'Title is required' });
20
+ }
21
+
22
+ if (!body.content || typeof body.content !== 'string') {
23
+ throw new HTTPException(400, { message: 'Content is required' });
24
+ }
25
+
26
+ const title = body.title.trim();
27
+ const content = body.content.trim();
28
+
29
+ if (title.length === 0) {
30
+ throw new HTTPException(400, { message: 'Title cannot be empty' });
31
+ }
32
+
33
+ if (title.length > MAX_TITLE_LENGTH) {
34
+ throw new HTTPException(413, { message: `Title exceeds ${MAX_TITLE_LENGTH} chars` });
35
+ }
36
+
37
+ if (content.length === 0) {
38
+ throw new HTTPException(400, { message: 'Content cannot be empty' });
39
+ }
40
+
41
+ if (content.length > MAX_CONTENT_LENGTH) {
42
+ throw new HTTPException(413, { message: `Content exceeds ${MAX_CONTENT_LENGTH} chars` });
43
+ }
44
+
45
+ const author = (body.author ?? 'bbki.ng').trim();
46
+
47
+ // Check database availability
48
+ if (!c.env?.DB) {
49
+ throw new HTTPException(503, { message: 'Database unavailable' });
50
+ }
51
+
52
+ // Insert post
53
+ const id = crypto.randomUUID();
54
+ const now = new Date().toISOString();
55
+
56
+ try {
57
+ await c.env.DB.prepare(
58
+ 'INSERT INTO posts (id, title, content, author, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
59
+ )
60
+ .bind(id, title, content, author, now, now)
61
+ .run();
62
+ } catch (dbError: any) {
63
+ if (dbError.message?.includes('UNIQUE')) {
64
+ throw new HTTPException(409, { message: 'Post with this title already exists' });
65
+ }
66
+ throw dbError;
67
+ }
68
+
69
+ return c.json(
70
+ {
71
+ status: 'success',
72
+ data: { id, title, content, author, createdAt: now, updatedAt: now },
73
+ },
74
+ 201
75
+ );
76
+ } catch (error) {
77
+ if (error instanceof HTTPException) {
78
+ return c.json({ status: 'error', message: error.message }, error.status);
79
+ }
80
+
81
+ console.error('Unexpected error:', error);
82
+ return c.json(
83
+ {
84
+ status: 'error',
85
+ message: 'Internal server error',
86
+ },
87
+ 500
88
+ );
89
+ }
90
+ };
@@ -0,0 +1,42 @@
1
+ import { Context } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+
4
+ export const getPost = async (c: Context) => {
5
+ try {
6
+ const title = c.req.param('title');
7
+
8
+ if (!title) {
9
+ throw new HTTPException(400, { message: 'Title is required' });
10
+ }
11
+
12
+ const result = await c.env.DB.prepare(
13
+ `SELECT id, title, content, author, created_at as createdAt, updated_at as updatedAt
14
+ FROM posts
15
+ WHERE title = ?`
16
+ )
17
+ .bind(title)
18
+ .first();
19
+
20
+ if (!result) {
21
+ throw new HTTPException(404, { message: 'Post not found' });
22
+ }
23
+
24
+ return c.json({
25
+ status: 'success',
26
+ data: result,
27
+ });
28
+ } catch (error) {
29
+ if (error instanceof HTTPException) {
30
+ return c.json({ status: 'error', message: error.message }, error.status);
31
+ }
32
+
33
+ console.error('Unexpected error:', error);
34
+ return c.json(
35
+ {
36
+ status: 'error',
37
+ message: 'Internal server error',
38
+ },
39
+ 500
40
+ );
41
+ }
42
+ };
@@ -0,0 +1,25 @@
1
+ import { Context } from 'hono';
2
+
3
+ export const listPosts = async (c: Context) => {
4
+ try {
5
+ const { results } = await c.env.DB.prepare(
6
+ `SELECT id, title, content, author, created_at as createdAt, updated_at as updatedAt
7
+ FROM posts
8
+ ORDER BY created_at DESC`
9
+ ).all();
10
+
11
+ return c.json({
12
+ status: 'success',
13
+ data: results,
14
+ });
15
+ } catch (error: any) {
16
+ return c.json(
17
+ {
18
+ status: 'error',
19
+ message: 'Failed to fetch posts',
20
+ error: error.message,
21
+ },
22
+ 500
23
+ );
24
+ }
25
+ };
@@ -0,0 +1,47 @@
1
+ import { Context } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+
4
+ export const removePost = async (c: Context) => {
5
+ try {
6
+ const id = c.req.param('id');
7
+
8
+ if (!id) {
9
+ throw new HTTPException(400, { message: 'Post ID is required' });
10
+ }
11
+
12
+ // Check database availability
13
+ if (!c.env?.DB) {
14
+ throw new HTTPException(503, { message: 'Database unavailable' });
15
+ }
16
+
17
+ // Check if post exists
18
+ const existingPost = await c.env.DB.prepare('SELECT id FROM posts WHERE id = ?')
19
+ .bind(id)
20
+ .first();
21
+
22
+ if (!existingPost) {
23
+ throw new HTTPException(404, { message: 'Post not found' });
24
+ }
25
+
26
+ // Delete post
27
+ await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(id).run();
28
+
29
+ return c.json({
30
+ status: 'success',
31
+ message: 'Post deleted successfully',
32
+ });
33
+ } catch (error) {
34
+ if (error instanceof HTTPException) {
35
+ return c.json({ status: 'error', message: error.message }, error.status);
36
+ }
37
+
38
+ console.error('Unexpected error:', error);
39
+ return c.json(
40
+ {
41
+ status: 'error',
42
+ message: 'Internal server error',
43
+ },
44
+ 500
45
+ );
46
+ }
47
+ };
@@ -0,0 +1,101 @@
1
+ import { Context } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+
4
+ interface UpdatePostRequest {
5
+ title: string;
6
+ content: string;
7
+ author?: string;
8
+ }
9
+
10
+ const MAX_TITLE_LENGTH = 200;
11
+ const MAX_CONTENT_LENGTH = 100000;
12
+
13
+ export const updatePost = async (c: Context) => {
14
+ try {
15
+ const id = c.req.param('id');
16
+
17
+ if (!id) {
18
+ throw new HTTPException(400, { message: 'Post ID is required' });
19
+ }
20
+
21
+ // Parse and validate request body
22
+ const body = await c.req.json<UpdatePostRequest>();
23
+
24
+ if (!body.title || typeof body.title !== 'string') {
25
+ throw new HTTPException(400, { message: 'Title is required' });
26
+ }
27
+
28
+ if (!body.content || typeof body.content !== 'string') {
29
+ throw new HTTPException(400, { message: 'Content is required' });
30
+ }
31
+
32
+ const title = body.title.trim();
33
+ const content = body.content.trim();
34
+
35
+ if (title.length === 0) {
36
+ throw new HTTPException(400, { message: 'Title cannot be empty' });
37
+ }
38
+
39
+ if (title.length > MAX_TITLE_LENGTH) {
40
+ throw new HTTPException(413, { message: `Title exceeds ${MAX_TITLE_LENGTH} chars` });
41
+ }
42
+
43
+ if (content.length === 0) {
44
+ throw new HTTPException(400, { message: 'Content cannot be empty' });
45
+ }
46
+
47
+ if (content.length > MAX_CONTENT_LENGTH) {
48
+ throw new HTTPException(413, { message: `Content exceeds ${MAX_CONTENT_LENGTH} chars` });
49
+ }
50
+
51
+ const author = (body.author ?? 'bbki.ng').trim();
52
+
53
+ // Check database availability
54
+ if (!c.env?.DB) {
55
+ throw new HTTPException(503, { message: 'Database unavailable' });
56
+ }
57
+
58
+ // Check if post exists
59
+ const existingPost = await c.env.DB.prepare('SELECT id FROM posts WHERE id = ?')
60
+ .bind(id)
61
+ .first();
62
+
63
+ if (!existingPost) {
64
+ throw new HTTPException(404, { message: 'Post not found' });
65
+ }
66
+
67
+ // Update post
68
+ const now = new Date().toISOString();
69
+
70
+ try {
71
+ await c.env.DB.prepare(
72
+ 'UPDATE posts SET title = ?, content = ?, author = ?, updated_at = ? WHERE id = ?'
73
+ )
74
+ .bind(title, content, author, now, id)
75
+ .run();
76
+ } catch (dbError: any) {
77
+ if (dbError.message?.includes('UNIQUE')) {
78
+ throw new HTTPException(409, { message: 'Post with this title already exists' });
79
+ }
80
+ throw dbError;
81
+ }
82
+
83
+ return c.json({
84
+ status: 'success',
85
+ data: { id, title, content, author, updatedAt: now },
86
+ });
87
+ } catch (error) {
88
+ if (error instanceof HTTPException) {
89
+ return c.json({ status: 'error', message: error.message }, error.status);
90
+ }
91
+
92
+ console.error('Unexpected error:', error);
93
+ return c.json(
94
+ {
95
+ status: 'error',
96
+ message: 'Internal server error',
97
+ },
98
+ 500
99
+ );
100
+ }
101
+ };
package/src/index.ts CHANGED
@@ -2,9 +2,11 @@ import app from './config/app.config';
2
2
 
3
3
  import { commentRouter } from './routes/comment.routes';
4
4
  import { streamingRouter } from './routes/streaming.routes';
5
+ import { postsRouter } from './routes/posts.routes';
5
6
 
6
7
  app.route('comment', commentRouter);
7
8
  app.route('streaming', streamingRouter);
9
+ app.route('posts', postsRouter);
8
10
 
9
11
  app.get('/', c => {
10
12
  return c.text('Hello Hono!');
@@ -0,0 +1,20 @@
1
+ import { Hono } from 'hono';
2
+ import { listPosts } from '../controllers/posts/list.controller';
3
+ import { getPost } from '../controllers/posts/get.controller';
4
+ import { addPost } from '../controllers/posts/add.controller';
5
+ import { updatePost } from '../controllers/posts/update.controller';
6
+ import { removePost } from '../controllers/posts/remove.controller';
7
+ import { requireAuth } from '../utils/auth';
8
+
9
+ const postsRouter = new Hono();
10
+
11
+ // Public routes
12
+ postsRouter.get('/', listPosts);
13
+ postsRouter.get('/:title', getPost);
14
+
15
+ // Protected routes (require API key)
16
+ postsRouter.post('/', requireAuth, addPost);
17
+ postsRouter.put('/:id', requireAuth, updatePost);
18
+ postsRouter.delete('/:id', requireAuth, removePost);
19
+
20
+ export { postsRouter };
@@ -9,7 +9,7 @@ export type Passkey = {
9
9
  userId: string;
10
10
  publicKey: string; // base64url
11
11
  counter: number;
12
- deviceType: "singleDevice" | "multiDevice";
12
+ deviceType: 'singleDevice' | 'multiDevice';
13
13
  backedUp: boolean;
14
14
  transports?: string[]; // 存为 JSON 字符串
15
15
  };
@@ -21,3 +21,12 @@ export type Streaming = {
21
21
  type?: string;
22
22
  createdAt: string;
23
23
  };
24
+
25
+ export type Post = {
26
+ id: string;
27
+ title: string;
28
+ content: string;
29
+ author: string;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ };
@@ -0,0 +1,40 @@
1
+ import { Context } from 'hono';
2
+ import { HTTPException } from 'hono/http-exception';
3
+
4
+ /**
5
+ * Timing-safe string comparison to prevent timing attacks
6
+ */
7
+ export const timingSafeEqual = (a: string, b: string): boolean => {
8
+ if (a.length !== b.length) return false;
9
+ let result = 0;
10
+ for (let i = 0; i < a.length; i++) {
11
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
12
+ }
13
+ return result === 0;
14
+ };
15
+
16
+ /**
17
+ * Validate API Key from request header
18
+ * Returns true if valid, false otherwise
19
+ */
20
+ export const validateApiKey = (c: Context): boolean => {
21
+ const apiKey = c.req.header('x-api-key');
22
+ const streamApiKey = c.env?.STREAM_API_KEY;
23
+
24
+ if (!apiKey || !streamApiKey) {
25
+ return false;
26
+ }
27
+
28
+ return timingSafeEqual(apiKey, streamApiKey);
29
+ };
30
+
31
+ /**
32
+ * Middleware to require API Key authentication
33
+ * Throws HTTPException 401 if invalid
34
+ */
35
+ export const requireAuth = async (c: Context, next: () => Promise<void>) => {
36
+ if (!validateApiKey(c)) {
37
+ throw new HTTPException(401, { message: 'Invalid API key' });
38
+ }
39
+ await next();
40
+ };