@kne/fastify-live-components-site 0.1.0

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.
@@ -0,0 +1,43 @@
1
+ module.exports = ({ DataTypes }) => {
2
+ return {
3
+ model: {
4
+ name: {
5
+ type: DataTypes.STRING,
6
+ allowNull: false,
7
+ comment: '站点名称'
8
+ },
9
+ shorten: {
10
+ type: DataTypes.STRING,
11
+ comment: '公开访问短码'
12
+ },
13
+ status: {
14
+ type: DataTypes.STRING,
15
+ allowNull: false,
16
+ defaultValue: 'open',
17
+ comment: '状态: open 开启, closed 关闭'
18
+ },
19
+ defaultPermission: {
20
+ type: DataTypes.STRING,
21
+ allowNull: false,
22
+ defaultValue: 'rw',
23
+ comment: '新建节点默认权限: r | rw'
24
+ },
25
+ options: {
26
+ type: DataTypes.JSON,
27
+ defaultValue: {},
28
+ comment: '扩展配置'
29
+ }
30
+ },
31
+ options: {
32
+ comment: 'Live 组件站点',
33
+ indexes: [
34
+ {
35
+ fields: ['shorten'],
36
+ unique: true,
37
+ where: { deleted_at: null }
38
+ },
39
+ { fields: ['status'] }
40
+ ]
41
+ }
42
+ };
43
+ };
@@ -0,0 +1,194 @@
1
+ const fp = require('fastify-plugin');
2
+ const { NotFound, BadRequest, Unauthorized, Forbidden } = require('http-errors');
3
+ const crypto = require('node:crypto');
4
+
5
+ module.exports = fp(async (fastify, options) => {
6
+ const { models } = fastify[options.name];
7
+ const { Op } = fastify.sequelize.Sequelize;
8
+
9
+ const toId = value => (value == null || value === '' ? null : String(value));
10
+
11
+ const getSiteId = authenticatePayload => {
12
+ const siteId = toId(authenticatePayload?.siteId);
13
+ if (!siteId) {
14
+ throw new BadRequest('siteId 不能为空');
15
+ }
16
+ return siteId;
17
+ };
18
+
19
+ const formatShare = (share, contentBase) => {
20
+ const plain = typeof share.get === 'function' ? share.get({ plain: true }) : { ...share };
21
+ const code = String(plain.shorten || '').toUpperCase();
22
+ const base = String(contentBase || options.prefix || '').replace(/\/$/, '');
23
+ return {
24
+ id: String(plain.id),
25
+ nodeId: String(plain.nodeId),
26
+ shorten: code,
27
+ expires: plain.expires || null,
28
+ // 不带站点 shorten:{prefix}/content/{contentShorten}
29
+ url: base ? `${base}/content/${code}` : null,
30
+ createdAt: plain.createdAt || null
31
+ };
32
+ };
33
+
34
+ const getContentBase = () => {
35
+ if (typeof options.getContentHost === 'function') {
36
+ return options.getContentHost();
37
+ }
38
+ return options.prefix;
39
+ };
40
+
41
+ const create = async (authenticatePayload, { id, expiresIn } = {}) => {
42
+ const siteId = getSiteId(authenticatePayload);
43
+ const nodeId = toId(id);
44
+ if (!nodeId) {
45
+ throw new BadRequest('id 不能为空');
46
+ }
47
+
48
+ const node = await models.node.findOne({ where: { id: nodeId, siteId } });
49
+ if (!node) {
50
+ throw new NotFound('节点不存在');
51
+ }
52
+ if (node.type !== 'file') {
53
+ throw new BadRequest('Not a file');
54
+ }
55
+
56
+ let expires = null;
57
+ if (expiresIn != null && expiresIn !== '' && Number(expiresIn) > 0) {
58
+ expires = new Date(Date.now() + Number(expiresIn) * 1000);
59
+ }
60
+
61
+ const shareId = crypto.randomBytes(8).toString('hex');
62
+ const target = JSON.stringify({
63
+ type: options.contentTargetType,
64
+ siteId,
65
+ nodeId,
66
+ shareId
67
+ });
68
+ const code = await options.getShortenServices().sign(target, expires);
69
+
70
+ const share = await models.contentShare.create({
71
+ siteId,
72
+ nodeId,
73
+ shorten: code,
74
+ expires
75
+ });
76
+
77
+ return formatShare(share, getContentBase());
78
+ };
79
+
80
+ const list = async (authenticatePayload, { id } = {}) => {
81
+ const siteId = getSiteId(authenticatePayload);
82
+ const nodeId = toId(id);
83
+ if (!nodeId) {
84
+ throw new BadRequest('id 不能为空');
85
+ }
86
+
87
+ const now = new Date();
88
+ const rows = await models.contentShare.findAll({
89
+ where: { siteId, nodeId },
90
+ order: [['createdAt', 'DESC']]
91
+ });
92
+
93
+ const contentBase = getContentBase();
94
+ const active = [];
95
+ for (const row of rows) {
96
+ if (row.expires && row.expires <= now) {
97
+ try {
98
+ await options.getShortenServices().remove(row.shorten);
99
+ } catch (e) {
100
+ // ignore
101
+ }
102
+ await row.destroy();
103
+ continue;
104
+ }
105
+ active.push(formatShare(row, contentBase));
106
+ }
107
+ return active;
108
+ };
109
+
110
+ const remove = async (authenticatePayload, { shorten } = {}) => {
111
+ const siteId = getSiteId(authenticatePayload);
112
+ const code = String(shorten || '')
113
+ .trim()
114
+ .toUpperCase();
115
+ if (!code) {
116
+ throw new BadRequest('shorten 不能为空');
117
+ }
118
+
119
+ const share = await models.contentShare.findOne({
120
+ where: { siteId, shorten: code }
121
+ });
122
+ if (!share) {
123
+ throw new NotFound('分享不存在');
124
+ }
125
+
126
+ try {
127
+ await options.getShortenServices().remove(code);
128
+ } catch (e) {
129
+ // ignore
130
+ }
131
+ await share.destroy();
132
+ return { shorten: code };
133
+ };
134
+
135
+ /** 通过内容短码读取已保存 content(公开访问,不依赖站点 shorten) */
136
+ const resolveByCode = async ({ contentShorten } = {}) => {
137
+ const code = String(contentShorten || '')
138
+ .trim()
139
+ .toUpperCase();
140
+ if (!code) {
141
+ throw new BadRequest('content shorten 不能为空');
142
+ }
143
+
144
+ let payload;
145
+ try {
146
+ const target = await options.getShortenServices().decode(code);
147
+ payload = typeof target === 'string' ? JSON.parse(target) : target;
148
+ } catch (e) {
149
+ throw new Unauthorized('content shorten is invalid or expired');
150
+ }
151
+
152
+ if (!payload || payload.type !== options.contentTargetType || !payload.siteId || !payload.nodeId) {
153
+ throw new Unauthorized('content shorten is invalid');
154
+ }
155
+
156
+ const siteId = String(payload.siteId);
157
+ const site = await models.site.findByPk(siteId);
158
+ if (!site) {
159
+ throw new Unauthorized('content shorten is invalid');
160
+ }
161
+ if (site.status !== 'open') {
162
+ throw new Forbidden('站点已关闭');
163
+ }
164
+
165
+ const share = await models.contentShare.findOne({
166
+ where: {
167
+ siteId,
168
+ shorten: code,
169
+ nodeId: toId(payload.nodeId),
170
+ [Op.or]: [{ expires: null }, { expires: { [Op.gt]: new Date() } }]
171
+ }
172
+ });
173
+ if (!share) {
174
+ throw new Unauthorized('content shorten is invalid or expired');
175
+ }
176
+
177
+ return fastify[options.name].services.node.getContent({ siteId }, { id: payload.nodeId });
178
+ };
179
+
180
+ /** @deprecated 保留兼容:带站点鉴权上下文时也可解析 */
181
+ const resolveContent = async (authenticatePayload, { contentShorten } = {}) => {
182
+ return resolveByCode({ contentShorten });
183
+ };
184
+
185
+ Object.assign(fastify[options.name].services, {
186
+ contentShare: {
187
+ create,
188
+ list,
189
+ remove,
190
+ resolveByCode,
191
+ resolveContent
192
+ }
193
+ });
194
+ });
@@ -0,0 +1,278 @@
1
+ const fp = require('fastify-plugin');
2
+ const { NotFound, BadRequest, Forbidden } = require('http-errors');
3
+
4
+ module.exports = fp(async (fastify, options) => {
5
+ const { models } = fastify[options.name];
6
+ const { Op } = fastify.sequelize.Sequelize;
7
+
8
+ const toId = value => (value == null || value === '' ? null : String(value));
9
+
10
+ const assertWritable = node => {
11
+ if (!node) {
12
+ throw new NotFound('节点不存在');
13
+ }
14
+ if (node.permission !== 'rw') {
15
+ throw new Forbidden('Read-only permission');
16
+ }
17
+ };
18
+
19
+ const getSiteId = authenticatePayload => {
20
+ const siteId = toId(authenticatePayload?.siteId);
21
+ if (!siteId) {
22
+ throw new BadRequest('siteId 不能为空');
23
+ }
24
+ return siteId;
25
+ };
26
+
27
+ const findNode = async (siteId, id) => {
28
+ const node = await models.node.findOne({
29
+ where: { id: toId(id), siteId }
30
+ });
31
+ if (!node) {
32
+ throw new NotFound('节点不存在');
33
+ }
34
+ return node;
35
+ };
36
+
37
+ const assertUniqueSiblingName = async (siteId, parentId, name, excludeId) => {
38
+ const where = {
39
+ siteId,
40
+ name,
41
+ parentId: parentId || null
42
+ };
43
+ if (excludeId) {
44
+ where.id = { [Op.ne]: toId(excludeId) };
45
+ }
46
+ // SQLite 中 null 比较需特殊处理
47
+ const count = await models.node.count({
48
+ where: parentId
49
+ ? where
50
+ : {
51
+ siteId,
52
+ name,
53
+ parentId: { [Op.is]: null },
54
+ ...(excludeId ? { id: { [Op.ne]: toId(excludeId) } } : {})
55
+ }
56
+ });
57
+ if (count > 0) {
58
+ throw new BadRequest('DUPLICATE_NAME');
59
+ }
60
+ };
61
+
62
+ const toTreeNode = (node, childrenMap) => {
63
+ const id = String(node.id);
64
+ const item = {
65
+ id,
66
+ name: node.name,
67
+ type: node.type,
68
+ permission: node.permission || 'rw'
69
+ };
70
+ if (node.type === 'directory') {
71
+ item.children = (childrenMap.get(id) || []).map(child => toTreeNode(child, childrenMap));
72
+ }
73
+ return item;
74
+ };
75
+
76
+ const getFolderTree = async authenticatePayload => {
77
+ const siteId = getSiteId(authenticatePayload);
78
+ const nodes = await models.node.findAll({
79
+ where: { siteId },
80
+ order: [
81
+ ['type', 'DESC'],
82
+ ['name', 'ASC']
83
+ ]
84
+ });
85
+
86
+ const childrenMap = new Map();
87
+ const roots = [];
88
+
89
+ nodes.forEach(node => {
90
+ const parentId = toId(node.parentId);
91
+ if (!parentId) {
92
+ roots.push(node);
93
+ return;
94
+ }
95
+ if (!childrenMap.has(parentId)) {
96
+ childrenMap.set(parentId, []);
97
+ }
98
+ childrenMap.get(parentId).push(node);
99
+ });
100
+
101
+ return roots.map(node => toTreeNode(node, childrenMap));
102
+ };
103
+
104
+ const get = async (authenticatePayload, { id }) => {
105
+ const siteId = getSiteId(authenticatePayload);
106
+ const node = await findNode(siteId, id);
107
+ if (node.type !== 'file') {
108
+ throw new BadRequest('Not a file');
109
+ }
110
+ return {
111
+ id: String(node.id),
112
+ name: node.name,
113
+ permission: node.permission || 'rw',
114
+ content: node.content ?? ''
115
+ };
116
+ };
117
+
118
+ /** 仅返回已保存的 content 字符串(供 text/plain 直出) */
119
+ const getContent = async (authenticatePayload, { id }) => {
120
+ const file = await get(authenticatePayload, { id });
121
+ return file.content ?? '';
122
+ };
123
+
124
+ const resolveParent = async (siteId, parentId) => {
125
+ if (!parentId) {
126
+ return null;
127
+ }
128
+ const parent = await findNode(siteId, parentId);
129
+ assertWritable(parent);
130
+ if (parent.type !== 'directory') {
131
+ throw new BadRequest('Parent is not a directory');
132
+ }
133
+ return parent;
134
+ };
135
+
136
+ const createFolder = async (authenticatePayload, { parentId, name }) => {
137
+ const siteId = getSiteId(authenticatePayload);
138
+ const trimmedName = String(name || '').trim();
139
+ if (!trimmedName) {
140
+ throw new BadRequest('Name is required');
141
+ }
142
+
143
+ const normalizedParentId = toId(parentId);
144
+ await resolveParent(siteId, normalizedParentId);
145
+ await assertUniqueSiblingName(siteId, normalizedParentId, trimmedName);
146
+
147
+ const permission = authenticatePayload?.site?.defaultPermission === 'r' ? 'r' : 'rw';
148
+ const folder = await models.node.create({
149
+ siteId,
150
+ parentId: normalizedParentId,
151
+ name: trimmedName,
152
+ type: 'directory',
153
+ permission,
154
+ content: null
155
+ });
156
+
157
+ return {
158
+ id: String(folder.id),
159
+ name: folder.name,
160
+ type: 'directory',
161
+ permission: folder.permission,
162
+ children: []
163
+ };
164
+ };
165
+
166
+ const create = async (authenticatePayload, { parentId, name, content = '' }) => {
167
+ const siteId = getSiteId(authenticatePayload);
168
+ const trimmedName = String(name || '').trim();
169
+ if (!trimmedName) {
170
+ throw new BadRequest('Name is required');
171
+ }
172
+
173
+ const normalizedParentId = toId(parentId);
174
+ await resolveParent(siteId, normalizedParentId);
175
+ await assertUniqueSiblingName(siteId, normalizedParentId, trimmedName);
176
+
177
+ const permission = authenticatePayload?.site?.defaultPermission === 'r' ? 'r' : 'rw';
178
+ const file = await models.node.create({
179
+ siteId,
180
+ parentId: normalizedParentId,
181
+ name: trimmedName,
182
+ type: 'file',
183
+ permission,
184
+ content: content == null ? '' : String(content)
185
+ });
186
+
187
+ return {
188
+ id: String(file.id),
189
+ name: file.name,
190
+ type: 'file',
191
+ permission: file.permission
192
+ };
193
+ };
194
+
195
+ const save = async (authenticatePayload, { id, content }) => {
196
+ const siteId = getSiteId(authenticatePayload);
197
+ const node = await findNode(siteId, id);
198
+ assertWritable(node);
199
+ if (node.type !== 'file') {
200
+ throw new BadRequest('Not a file');
201
+ }
202
+ node.content = content == null ? '' : String(content);
203
+ await node.save();
204
+ return {
205
+ id: String(node.id),
206
+ content: node.content
207
+ };
208
+ };
209
+
210
+ const rename = async (authenticatePayload, { id, name }) => {
211
+ const siteId = getSiteId(authenticatePayload);
212
+ const trimmedName = String(name || '').trim();
213
+ if (!trimmedName) {
214
+ throw new BadRequest('Name is required');
215
+ }
216
+ const node = await findNode(siteId, id);
217
+ assertWritable(node);
218
+ const parentId = toId(node.parentId);
219
+ await assertUniqueSiblingName(siteId, parentId, trimmedName, node.id);
220
+ node.name = trimmedName;
221
+ await node.save();
222
+ return {
223
+ id: String(node.id),
224
+ name: node.name,
225
+ type: node.type,
226
+ permission: node.permission
227
+ };
228
+ };
229
+
230
+ const remove = async (authenticatePayload, { ids }) => {
231
+ const siteId = getSiteId(authenticatePayload);
232
+ const idList = Array.isArray(ids) ? ids : [ids];
233
+ if (!idList.length) {
234
+ throw new BadRequest('ids is required');
235
+ }
236
+
237
+ for (const id of idList) {
238
+ const node = await findNode(siteId, id);
239
+ assertWritable(node);
240
+ if (node.type === 'directory') {
241
+ const childCount = await models.node.count({
242
+ where: { siteId, parentId: String(node.id) }
243
+ });
244
+ if (childCount > 0) {
245
+ throw new BadRequest('Cannot remove non-empty folder');
246
+ }
247
+ } else {
248
+ const shares = await models.contentShare.findAll({
249
+ where: { siteId, nodeId: String(node.id) }
250
+ });
251
+ for (const share of shares) {
252
+ try {
253
+ await options.getShortenServices().remove(share.shorten);
254
+ } catch (e) {
255
+ // ignore
256
+ }
257
+ await share.destroy();
258
+ }
259
+ }
260
+ await node.destroy();
261
+ }
262
+
263
+ return { ids: idList.map(String) };
264
+ };
265
+
266
+ Object.assign(fastify[options.name].services, {
267
+ node: {
268
+ getFolderTree,
269
+ get,
270
+ getContent,
271
+ createFolder,
272
+ create,
273
+ save,
274
+ rename,
275
+ remove
276
+ }
277
+ });
278
+ });
@@ -0,0 +1,185 @@
1
+ const fp = require('fastify-plugin');
2
+ const omit = require('lodash/omit');
3
+ const { Unauthorized, Forbidden, NotFound, BadRequest } = require('http-errors');
4
+
5
+ const STATUS_OPEN = 'open';
6
+ const STATUS_CLOSED = 'closed';
7
+
8
+ const normalizeStatus = status => (status === STATUS_CLOSED ? STATUS_CLOSED : STATUS_OPEN);
9
+
10
+ module.exports = fp(async (fastify, options) => {
11
+ const { models } = fastify[options.name];
12
+ const { Op } = fastify.sequelize.Sequelize;
13
+
14
+ const formatSite = site => {
15
+ const plain = typeof site.get === 'function' ? site.get({ plain: true }) : { ...site };
16
+ plain.id = String(plain.id);
17
+ plain.host = plain.shorten ? options.getSiteHost(plain.shorten) : null;
18
+ return plain;
19
+ };
20
+
21
+ const detail = async (authenticatePayload, { id }) => {
22
+ const site = await models.site.findByPk(id);
23
+ if (!site) {
24
+ throw new NotFound('站点不存在');
25
+ }
26
+ return site;
27
+ };
28
+
29
+ const authenticateEntry = async request => {
30
+ const code = request.params?.shorten;
31
+ if (!code) {
32
+ throw new Unauthorized('shorten is required');
33
+ }
34
+
35
+ let payload;
36
+ try {
37
+ const target = await options.getShortenServices().decode(String(code).toUpperCase());
38
+ payload = typeof target === 'string' ? JSON.parse(target) : target;
39
+ } catch (e) {
40
+ throw new Unauthorized('shorten is invalid');
41
+ }
42
+
43
+ if (!payload || payload.type !== options.siteTargetType || !payload.siteId) {
44
+ throw new Unauthorized('shorten is invalid');
45
+ }
46
+
47
+ const site = await models.site.findByPk(payload.siteId);
48
+ if (!site) {
49
+ throw new Unauthorized('shorten is invalid');
50
+ }
51
+
52
+ if (site.status !== STATUS_OPEN) {
53
+ throw new Forbidden('站点已关闭');
54
+ }
55
+
56
+ const siteId = String(site.id);
57
+ request.siteInfo = site;
58
+ request.authenticatePayload = {
59
+ siteId,
60
+ site
61
+ };
62
+ };
63
+
64
+ const create = async (authenticatePayload, data = {}) => {
65
+ const name = String(data.name || '').trim();
66
+ if (!name) {
67
+ throw new BadRequest('名称不能为空');
68
+ }
69
+
70
+ const defaultPermission = data.defaultPermission === 'r' ? 'r' : 'rw';
71
+ const status = normalizeStatus(data.status);
72
+
73
+ const site = await models.site.create({
74
+ name,
75
+ status,
76
+ defaultPermission,
77
+ options: data.options && typeof data.options === 'object' ? data.options : {}
78
+ });
79
+
80
+ const target = JSON.stringify({
81
+ type: options.siteTargetType,
82
+ siteId: String(site.id)
83
+ });
84
+ const shorten = await options.getShortenServices().sign(target);
85
+ site.shorten = shorten;
86
+ await site.save();
87
+
88
+ return formatSite(site);
89
+ };
90
+
91
+ const save = async (authenticatePayload, { id, ...data }) => {
92
+ if (!id) {
93
+ throw new BadRequest('id 不能为空');
94
+ }
95
+ const site = await detail(authenticatePayload, { id });
96
+ const payload = omit(data, ['id', 'shorten', 'createdAt', 'updatedAt', 'deletedAt', 'host']);
97
+
98
+ if (payload.name != null) {
99
+ const name = String(payload.name).trim();
100
+ if (!name) {
101
+ throw new BadRequest('名称不能为空');
102
+ }
103
+ payload.name = name;
104
+ }
105
+
106
+ if (payload.status != null) {
107
+ payload.status = normalizeStatus(payload.status);
108
+ }
109
+
110
+ if (payload.defaultPermission != null) {
111
+ payload.defaultPermission = payload.defaultPermission === 'r' ? 'r' : 'rw';
112
+ }
113
+
114
+ await site.update(payload);
115
+ return site;
116
+ };
117
+
118
+ const remove = async (authenticatePayload, { id }) => {
119
+ if (!id) {
120
+ throw new BadRequest('id 不能为空');
121
+ }
122
+ const site = await detail(authenticatePayload, { id });
123
+ if (site.shorten) {
124
+ try {
125
+ await options.getShortenServices().remove(site.shorten);
126
+ } catch (e) {
127
+ // 短码可能已不存在,忽略
128
+ }
129
+ }
130
+ const shares = await models.contentShare.findAll({ where: { siteId: site.id } });
131
+ for (const share of shares) {
132
+ try {
133
+ await options.getShortenServices().remove(share.shorten);
134
+ } catch (e) {
135
+ // ignore
136
+ }
137
+ }
138
+ await models.contentShare.destroy({ where: { siteId: site.id } });
139
+ await models.node.destroy({ where: { siteId: site.id } });
140
+ await site.destroy();
141
+ };
142
+
143
+ const list = async (authenticatePayload, { filter = {}, perPage = 20, currentPage = 1 } = {}) => {
144
+ const where = {};
145
+ if (filter.status === STATUS_OPEN || filter.status === STATUS_CLOSED) {
146
+ where.status = filter.status;
147
+ }
148
+ if (filter.keyword) {
149
+ where.name = { [Op.like]: `%${filter.keyword}%` };
150
+ }
151
+
152
+ const limit = Number(perPage) || 20;
153
+ const page = Number(currentPage) || 1;
154
+ const { rows, count } = await models.site.findAndCountAll({
155
+ where,
156
+ offset: limit * (page - 1),
157
+ limit,
158
+ order: [['createdAt', 'DESC']]
159
+ });
160
+
161
+ return {
162
+ pageData: rows.map(formatSite),
163
+ totalCount: count
164
+ };
165
+ };
166
+
167
+ const getDetail = async (authenticatePayload, { id }) => {
168
+ const site = await detail(authenticatePayload, { id });
169
+ return formatSite(site);
170
+ };
171
+
172
+ Object.assign(fastify[options.name].services, {
173
+ site: {
174
+ authenticateEntry,
175
+ create,
176
+ save,
177
+ remove,
178
+ list,
179
+ detail: getDetail,
180
+ formatSite,
181
+ STATUS_OPEN,
182
+ STATUS_CLOSED
183
+ }
184
+ });
185
+ });