retold-content-system 1.0.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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/build-codejar-bundle.js +29 -0
- package/build-codemirror-bundle.js +29 -0
- package/codejar-entry.js +10 -0
- package/codemirror-entry.js +16 -0
- package/content/Dogs.txt.md +2 -0
- package/content/README.md +35 -0
- package/content/_sidebar.md +3 -0
- package/content/_topbar.md +1 -0
- package/content/cover.md +12 -0
- package/content/getting-started.md +73 -0
- package/css/content-system.css +42 -0
- package/css/github.css +118 -0
- package/docs/.nojekyll +0 -0
- package/docs/README.md +24 -0
- package/docs/_sidebar.md +16 -0
- package/docs/_topbar.md +6 -0
- package/docs/cli.md +119 -0
- package/docs/cover.md +16 -0
- package/docs/css/docuserve.css +73 -0
- package/docs/editor-guide.md +137 -0
- package/docs/getting-started.md +73 -0
- package/docs/index.html +39 -0
- package/docs/keyboard-shortcuts.md +40 -0
- package/docs/retold-catalog.json +81 -0
- package/docs/retold-keyword-index.json +19 -0
- package/docs/topics.md +83 -0
- package/html/codejar-bundle.js +16 -0
- package/html/codemirror-bundle.js +29982 -0
- package/html/edit.html +25 -0
- package/html/index.html +25 -0
- package/html/preview.html +19 -0
- package/package.json +70 -0
- package/server.js +43 -0
- package/source/Pict-Application-ContentEditor-Configuration.json +15 -0
- package/source/Pict-Application-ContentEditor.js +1361 -0
- package/source/Pict-Application-ContentReader-Configuration.json +15 -0
- package/source/Pict-Application-ContentReader.js +91 -0
- package/source/Pict-ContentSystem-Bundle.js +21 -0
- package/source/cli/ContentSystem-CLI-Program.js +15 -0
- package/source/cli/ContentSystem-CLI-Run.js +3 -0
- package/source/cli/ContentSystem-Server-Setup.js +405 -0
- package/source/cli/commands/ContentSystem-Command-Serve.js +104 -0
- package/source/providers/Pict-Provider-ContentEditor.js +198 -0
- package/source/views/PictView-Editor-CodeEditor.js +271 -0
- package/source/views/PictView-Editor-Layout.js +1194 -0
- package/source/views/PictView-Editor-MarkdownEditor.js +115 -0
- package/source/views/PictView-Editor-MarkdownReference.js +801 -0
- package/source/views/PictView-Editor-SettingsPanel.js +563 -0
- package/source/views/PictView-Editor-TopBar.js +366 -0
- package/source/views/PictView-Editor-Topics.js +1025 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Name": "Retold Content Reader",
|
|
3
|
+
"Hash": "ContentReader",
|
|
4
|
+
|
|
5
|
+
"MainViewportViewIdentifier": "Docuserve-Layout",
|
|
6
|
+
|
|
7
|
+
"AutoSolveAfterInitialize": true,
|
|
8
|
+
"AutoRenderMainViewportViewAfterInitialize": false,
|
|
9
|
+
"AutoRenderViewsAfterInitialize": false,
|
|
10
|
+
|
|
11
|
+
"pict_configuration":
|
|
12
|
+
{
|
|
13
|
+
"Product": "ContentReader-Pict-Application"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const libDocuserveApplication = require('pict-docuserve');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Content Reader Application
|
|
5
|
+
*
|
|
6
|
+
* Extends pict-docuserve to serve standalone markdown content.
|
|
7
|
+
* Overrides DocsBaseURL to point at the /content/ static route
|
|
8
|
+
* so all markdown is fetched from the server's content directory.
|
|
9
|
+
*/
|
|
10
|
+
class ContentReaderApplication extends libDocuserveApplication
|
|
11
|
+
{
|
|
12
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
13
|
+
{
|
|
14
|
+
super(pFable, pOptions, pServiceHash);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
onAfterInitializeAsync(fCallback)
|
|
18
|
+
{
|
|
19
|
+
// Point docuserve at the /content/ path served by Orator
|
|
20
|
+
this.pict.AppData.Docuserve =
|
|
21
|
+
{
|
|
22
|
+
CatalogLoaded: false,
|
|
23
|
+
Catalog: null,
|
|
24
|
+
CoverLoaded: false,
|
|
25
|
+
Cover: null,
|
|
26
|
+
SidebarLoaded: false,
|
|
27
|
+
SidebarGroups: [],
|
|
28
|
+
TopBarLoaded: false,
|
|
29
|
+
TopBar: null,
|
|
30
|
+
ErrorPageLoaded: false,
|
|
31
|
+
ErrorPageHTML: null,
|
|
32
|
+
KeywordIndexLoaded: false,
|
|
33
|
+
KeywordDocumentCount: 0,
|
|
34
|
+
CurrentGroup: '',
|
|
35
|
+
CurrentModule: '',
|
|
36
|
+
CurrentPath: '',
|
|
37
|
+
SidebarVisible: true,
|
|
38
|
+
// Serve content from the /content/ static route
|
|
39
|
+
DocsBaseURL: '/content/',
|
|
40
|
+
// No catalog in standalone mode
|
|
41
|
+
CatalogURL: '/content/retold-catalog.json'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Load the catalog (will gracefully fall back to standalone mode)
|
|
45
|
+
let tmpDocProvider = this.pict.providers['Docuserve-Documentation'];
|
|
46
|
+
tmpDocProvider.loadCatalog(() =>
|
|
47
|
+
{
|
|
48
|
+
// Set page title from cover or topbar
|
|
49
|
+
let tmpDocuserve = this.pict.AppData.Docuserve;
|
|
50
|
+
if (tmpDocuserve.CoverLoaded && tmpDocuserve.Cover && tmpDocuserve.Cover.Title)
|
|
51
|
+
{
|
|
52
|
+
document.title = tmpDocuserve.Cover.Title.replace(/<[^>]*>/g, '');
|
|
53
|
+
}
|
|
54
|
+
else if (tmpDocuserve.TopBarLoaded && tmpDocuserve.TopBar && tmpDocuserve.TopBar.Brand)
|
|
55
|
+
{
|
|
56
|
+
document.title = tmpDocuserve.TopBar.Brand.replace(/<[^>]*>/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Inject an "Edit" link into the topbar (shows in ExternalLinks area, top-right)
|
|
60
|
+
if (tmpDocuserve.TopBar)
|
|
61
|
+
{
|
|
62
|
+
if (!Array.isArray(tmpDocuserve.TopBar.ExternalLinks))
|
|
63
|
+
{
|
|
64
|
+
tmpDocuserve.TopBar.ExternalLinks = [];
|
|
65
|
+
}
|
|
66
|
+
tmpDocuserve.TopBar.ExternalLinks.push({ Text: 'Edit', Href: '/' });
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
{
|
|
70
|
+
tmpDocuserve.TopBarLoaded = true;
|
|
71
|
+
tmpDocuserve.TopBar =
|
|
72
|
+
{
|
|
73
|
+
Brand: 'Content System',
|
|
74
|
+
NavLinks: [],
|
|
75
|
+
ExternalLinks: [{ Text: 'Edit', Href: '/' }]
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Render the layout shell
|
|
80
|
+
this.pict.views['Docuserve-Layout'].render();
|
|
81
|
+
|
|
82
|
+
// Call the base PictApplication callback (skip docuserve's onAfterInitializeAsync
|
|
83
|
+
// since we already did the catalog loading ourselves)
|
|
84
|
+
return fCallback();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = ContentReaderApplication;
|
|
90
|
+
|
|
91
|
+
module.exports.default_configuration = require('./Pict-Application-ContentReader-Configuration.json');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined browser bundle for Retold Content System.
|
|
3
|
+
*
|
|
4
|
+
* Exports both the reader and editor applications as window globals
|
|
5
|
+
* so each HTML page can bootstrap the appropriate one.
|
|
6
|
+
*
|
|
7
|
+
* In index.html: Pict.safeLoadPictApplication(PictContentReader, 2)
|
|
8
|
+
* In edit.html: Pict.safeLoadPictApplication(PictContentEditor, 2)
|
|
9
|
+
*/
|
|
10
|
+
module.exports =
|
|
11
|
+
{
|
|
12
|
+
PictContentReader: require('./Pict-Application-ContentReader.js'),
|
|
13
|
+
PictContentEditor: require('./Pict-Application-ContentEditor.js')
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Also expose on window for direct access
|
|
17
|
+
if (typeof (window) !== 'undefined')
|
|
18
|
+
{
|
|
19
|
+
window.PictContentReader = module.exports.PictContentReader;
|
|
20
|
+
window.PictContentEditor = module.exports.PictContentEditor;
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const libCLIProgram = require('pict-service-commandlineutility');
|
|
2
|
+
|
|
3
|
+
let _PictCLIProgram = new libCLIProgram(
|
|
4
|
+
{
|
|
5
|
+
Product: 'retold-content-system',
|
|
6
|
+
Version: require('../../package.json').version,
|
|
7
|
+
|
|
8
|
+
Command: 'retold-content-system',
|
|
9
|
+
Description: 'Serve and edit markdown content folders with reader and editor apps powered by Pict.'
|
|
10
|
+
},
|
|
11
|
+
[
|
|
12
|
+
require('./commands/ContentSystem-Command-Serve.js')
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
module.exports = _PictCLIProgram;
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Content System -- Shared Orator Server Setup
|
|
3
|
+
*
|
|
4
|
+
* This module encapsulates all server initialization logic so it can be
|
|
5
|
+
* used by both the standalone server.js entry point and the CLI serve command.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} pOptions
|
|
8
|
+
* @param {string} pOptions.ContentPath - Absolute path to the markdown content folder
|
|
9
|
+
* @param {string} pOptions.UploadPath - Absolute path to the uploads folder
|
|
10
|
+
* @param {string} pOptions.DistPath - Absolute path to the built dist folder
|
|
11
|
+
* @param {number} pOptions.Port - HTTP port to listen on
|
|
12
|
+
* @param {Function} fCallback - Callback(pError, { Fable, Orator, Port })
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const libFs = require('fs');
|
|
16
|
+
const libPath = require('path');
|
|
17
|
+
|
|
18
|
+
const libFable = require('fable');
|
|
19
|
+
const libOrator = require('orator');
|
|
20
|
+
const libOratorServiceServerRestify = require('orator-serviceserver-restify');
|
|
21
|
+
const libFileBrowserService = require('pict-section-filebrowser').FileBrowserService;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize a file path -- prevent directory traversal attacks.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} pPath - The raw path from the client
|
|
27
|
+
* @returns {string|null} A safe relative path, or null if invalid
|
|
28
|
+
*/
|
|
29
|
+
function sanitizePath(pPath)
|
|
30
|
+
{
|
|
31
|
+
if (!pPath || typeof (pPath) !== 'string')
|
|
32
|
+
{
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Decode URI components
|
|
37
|
+
let tmpPath = decodeURIComponent(pPath);
|
|
38
|
+
|
|
39
|
+
// Remove leading slashes
|
|
40
|
+
tmpPath = tmpPath.replace(/^\/+/, '');
|
|
41
|
+
|
|
42
|
+
// Block directory traversal
|
|
43
|
+
if (tmpPath.includes('..'))
|
|
44
|
+
{
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Block absolute paths
|
|
49
|
+
if (libPath.isAbsolute(tmpPath))
|
|
50
|
+
{
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove dangerous characters
|
|
55
|
+
tmpPath = tmpPath.replace(/[<>"|?*]/g, '_');
|
|
56
|
+
|
|
57
|
+
return tmpPath || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize a filename -- strip path separators and dangerous characters.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} pName - The raw filename from the client
|
|
64
|
+
* @returns {string} A safe filename
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeFilename(pName)
|
|
67
|
+
{
|
|
68
|
+
if (!pName || typeof (pName) !== 'string')
|
|
69
|
+
{
|
|
70
|
+
return 'upload';
|
|
71
|
+
}
|
|
72
|
+
let tmpName = libPath.basename(pName);
|
|
73
|
+
tmpName = tmpName.replace(/[\/\\:*?"<>|]/g, '_');
|
|
74
|
+
if (tmpName.length > 200)
|
|
75
|
+
{
|
|
76
|
+
tmpName = tmpName.substring(0, 200);
|
|
77
|
+
}
|
|
78
|
+
return tmpName || 'upload';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Set up and start the Retold Content System Orator server.
|
|
83
|
+
*/
|
|
84
|
+
function setupContentSystemServer(pOptions, fCallback)
|
|
85
|
+
{
|
|
86
|
+
let tmpContentPath = pOptions.ContentPath;
|
|
87
|
+
let tmpUploadPath = pOptions.UploadPath;
|
|
88
|
+
let tmpDistFolder = pOptions.DistPath;
|
|
89
|
+
let tmpPort = pOptions.Port;
|
|
90
|
+
|
|
91
|
+
let tmpSettings =
|
|
92
|
+
{
|
|
93
|
+
Product: 'Retold-Content-System',
|
|
94
|
+
ProductVersion: require('../../package.json').version,
|
|
95
|
+
APIServerPort: tmpPort,
|
|
96
|
+
ContentPath: tmpContentPath,
|
|
97
|
+
UploadPath: tmpUploadPath
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let tmpFable = new libFable(tmpSettings);
|
|
101
|
+
|
|
102
|
+
// Ensure the uploads directory exists
|
|
103
|
+
if (!libFs.existsSync(tmpUploadPath))
|
|
104
|
+
{
|
|
105
|
+
libFs.mkdirSync(tmpUploadPath, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ensure the content directory exists
|
|
109
|
+
if (!libFs.existsSync(tmpContentPath))
|
|
110
|
+
{
|
|
111
|
+
libFs.mkdirSync(tmpContentPath, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
tmpFable.serviceManager.addServiceType('OratorServiceServer', libOratorServiceServerRestify);
|
|
115
|
+
tmpFable.serviceManager.instantiateServiceProvider('OratorServiceServer');
|
|
116
|
+
tmpFable.serviceManager.addServiceType('Orator', libOrator);
|
|
117
|
+
let tmpOrator = tmpFable.serviceManager.instantiateServiceProvider('Orator');
|
|
118
|
+
|
|
119
|
+
// Set up the FileBrowserService for the content directory
|
|
120
|
+
let tmpFileBrowser = new libFileBrowserService(tmpFable,
|
|
121
|
+
{
|
|
122
|
+
BasePath: tmpContentPath,
|
|
123
|
+
APIRoutePrefix: '/api/filebrowser',
|
|
124
|
+
ServeWebApp: false,
|
|
125
|
+
IncludeHiddenFiles: false
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
tmpOrator.initialize(
|
|
129
|
+
function ()
|
|
130
|
+
{
|
|
131
|
+
let tmpServiceServer = tmpOrator.serviceServer;
|
|
132
|
+
|
|
133
|
+
// Enable body parsing for POST/PUT requests
|
|
134
|
+
tmpServiceServer.server.use(tmpServiceServer.bodyParser());
|
|
135
|
+
|
|
136
|
+
// Connect the file browser API routes
|
|
137
|
+
tmpFileBrowser.connectRoutes();
|
|
138
|
+
|
|
139
|
+
// --- PUT /api/filebrowser/settings ---
|
|
140
|
+
// Toggle file browser options at runtime (e.g. hidden files)
|
|
141
|
+
tmpServiceServer.put('/api/filebrowser/settings',
|
|
142
|
+
(pRequest, pResponse, fNext) =>
|
|
143
|
+
{
|
|
144
|
+
try
|
|
145
|
+
{
|
|
146
|
+
if (pRequest.body && typeof (pRequest.body.IncludeHiddenFiles) === 'boolean')
|
|
147
|
+
{
|
|
148
|
+
tmpFileBrowser.options.IncludeHiddenFiles = pRequest.body.IncludeHiddenFiles;
|
|
149
|
+
}
|
|
150
|
+
pResponse.send({ Success: true });
|
|
151
|
+
}
|
|
152
|
+
catch (pError)
|
|
153
|
+
{
|
|
154
|
+
pResponse.send(500, { Error: pError.message });
|
|
155
|
+
}
|
|
156
|
+
return fNext();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- GET /api/content/read/* ---
|
|
160
|
+
// Read the raw markdown content of a file
|
|
161
|
+
tmpServiceServer.get('/api/content/read/*',
|
|
162
|
+
(pRequest, pResponse, fNext) =>
|
|
163
|
+
{
|
|
164
|
+
try
|
|
165
|
+
{
|
|
166
|
+
let tmpFilePath = sanitizePath(pRequest.params['*']);
|
|
167
|
+
|
|
168
|
+
if (!tmpFilePath)
|
|
169
|
+
{
|
|
170
|
+
pResponse.send(400, { Success: false, Error: 'Invalid file path.' });
|
|
171
|
+
return fNext();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let tmpFullPath = libPath.join(tmpContentPath, tmpFilePath);
|
|
175
|
+
|
|
176
|
+
// Ensure the resolved path is within the content directory
|
|
177
|
+
let tmpRealContent = libFs.realpathSync(tmpContentPath);
|
|
178
|
+
if (!tmpFullPath.startsWith(tmpRealContent))
|
|
179
|
+
{
|
|
180
|
+
pResponse.send(403, { Success: false, Error: 'Access denied.' });
|
|
181
|
+
return fNext();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!libFs.existsSync(tmpFullPath))
|
|
185
|
+
{
|
|
186
|
+
pResponse.send(404, { Success: false, Error: 'File not found.' });
|
|
187
|
+
return fNext();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let tmpContent = libFs.readFileSync(tmpFullPath, 'utf8');
|
|
191
|
+
pResponse.send({ Success: true, Path: tmpFilePath, Content: tmpContent });
|
|
192
|
+
}
|
|
193
|
+
catch (pError)
|
|
194
|
+
{
|
|
195
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
196
|
+
}
|
|
197
|
+
return fNext();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- PUT /api/content/save/* ---
|
|
201
|
+
// Save markdown content to a file (create or overwrite)
|
|
202
|
+
tmpServiceServer.put('/api/content/save/*',
|
|
203
|
+
(pRequest, pResponse, fNext) =>
|
|
204
|
+
{
|
|
205
|
+
try
|
|
206
|
+
{
|
|
207
|
+
let tmpFilePath = sanitizePath(pRequest.params['*']);
|
|
208
|
+
|
|
209
|
+
if (!tmpFilePath)
|
|
210
|
+
{
|
|
211
|
+
pResponse.send(400, { Success: false, Error: 'Invalid file path.' });
|
|
212
|
+
return fNext();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let tmpFullPath = libPath.join(tmpContentPath, tmpFilePath);
|
|
216
|
+
|
|
217
|
+
// Ensure the resolved path target is within the content directory
|
|
218
|
+
let tmpRealContent = libFs.realpathSync(tmpContentPath);
|
|
219
|
+
let tmpTargetDir = libPath.dirname(tmpFullPath);
|
|
220
|
+
// The file may not exist yet, but the directory should be within content
|
|
221
|
+
if (!tmpTargetDir.startsWith(tmpRealContent))
|
|
222
|
+
{
|
|
223
|
+
pResponse.send(403, { Success: false, Error: 'Access denied.' });
|
|
224
|
+
return fNext();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let tmpContent = '';
|
|
228
|
+
if (pRequest.body && typeof (pRequest.body) === 'object' && pRequest.body.Content !== undefined)
|
|
229
|
+
{
|
|
230
|
+
tmpContent = pRequest.body.Content;
|
|
231
|
+
}
|
|
232
|
+
else if (typeof (pRequest.body) === 'string')
|
|
233
|
+
{
|
|
234
|
+
tmpContent = pRequest.body;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ensure the target directory exists
|
|
238
|
+
let tmpDir = libPath.dirname(tmpFullPath);
|
|
239
|
+
if (!libFs.existsSync(tmpDir))
|
|
240
|
+
{
|
|
241
|
+
libFs.mkdirSync(tmpDir, { recursive: true });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
libFs.writeFileSync(tmpFullPath, tmpContent, 'utf8');
|
|
245
|
+
|
|
246
|
+
tmpFable.log.info(`Content saved: ${tmpFilePath} (${tmpContent.length} bytes)`);
|
|
247
|
+
pResponse.send({ Success: true, Path: tmpFilePath, Size: tmpContent.length });
|
|
248
|
+
}
|
|
249
|
+
catch (pError)
|
|
250
|
+
{
|
|
251
|
+
tmpFable.log.error(`Content save failed: ${pError.message}`);
|
|
252
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
253
|
+
}
|
|
254
|
+
return fNext();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// --- POST /api/content/upload-image ---
|
|
258
|
+
// Upload an image file into the content folder the user is browsing.
|
|
259
|
+
// The client sends the target folder via the x-upload-path header.
|
|
260
|
+
tmpServiceServer.post('/api/content/upload-image',
|
|
261
|
+
(pRequest, pResponse, fNext) =>
|
|
262
|
+
{
|
|
263
|
+
try
|
|
264
|
+
{
|
|
265
|
+
let tmpBody = pRequest.body;
|
|
266
|
+
|
|
267
|
+
if (!tmpBody)
|
|
268
|
+
{
|
|
269
|
+
pResponse.send(400, { Success: false, Error: 'No image data received.' });
|
|
270
|
+
return fNext();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let tmpOriginalName = sanitizeFilename(pRequest.headers['x-filename']);
|
|
274
|
+
let tmpContentType = pRequest.headers['content-type'] || 'application/octet-stream';
|
|
275
|
+
|
|
276
|
+
// Determine file extension from content-type if needed
|
|
277
|
+
let tmpExt = libPath.extname(tmpOriginalName);
|
|
278
|
+
if (!tmpExt)
|
|
279
|
+
{
|
|
280
|
+
let tmpMimeMap =
|
|
281
|
+
{
|
|
282
|
+
'image/png': '.png',
|
|
283
|
+
'image/jpeg': '.jpg',
|
|
284
|
+
'image/gif': '.gif',
|
|
285
|
+
'image/webp': '.webp',
|
|
286
|
+
'image/svg+xml': '.svg',
|
|
287
|
+
'image/bmp': '.bmp'
|
|
288
|
+
};
|
|
289
|
+
tmpExt = tmpMimeMap[tmpContentType] || '.bin';
|
|
290
|
+
tmpOriginalName += tmpExt;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Determine the target folder: use the x-upload-path header
|
|
294
|
+
// (the folder the user is currently browsing), falling back
|
|
295
|
+
// to the content root if none is provided.
|
|
296
|
+
let tmpUploadFolder = tmpContentPath;
|
|
297
|
+
let tmpRelativeFolder = '';
|
|
298
|
+
let tmpRawUploadPath = pRequest.headers['x-upload-path'];
|
|
299
|
+
if (tmpRawUploadPath)
|
|
300
|
+
{
|
|
301
|
+
let tmpSafePath = sanitizePath(tmpRawUploadPath);
|
|
302
|
+
if (tmpSafePath)
|
|
303
|
+
{
|
|
304
|
+
tmpRelativeFolder = tmpSafePath;
|
|
305
|
+
tmpUploadFolder = libPath.join(tmpContentPath, tmpSafePath);
|
|
306
|
+
|
|
307
|
+
// Ensure the resolved path is within the content directory
|
|
308
|
+
let tmpRealContent = libFs.realpathSync(tmpContentPath);
|
|
309
|
+
if (!libPath.resolve(tmpUploadFolder).startsWith(tmpRealContent))
|
|
310
|
+
{
|
|
311
|
+
pResponse.send(403, { Success: false, Error: 'Access denied.' });
|
|
312
|
+
return fNext();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Ensure the target directory exists
|
|
318
|
+
if (!libFs.existsSync(tmpUploadFolder))
|
|
319
|
+
{
|
|
320
|
+
libFs.mkdirSync(tmpUploadFolder, { recursive: true });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let tmpUniqueFilename = `${Date.now()}-${tmpOriginalName}`;
|
|
324
|
+
let tmpFilePath = libPath.join(tmpUploadFolder, tmpUniqueFilename);
|
|
325
|
+
|
|
326
|
+
let tmpBuffer = Buffer.isBuffer(tmpBody) ? tmpBody : Buffer.from(tmpBody);
|
|
327
|
+
libFs.writeFileSync(tmpFilePath, tmpBuffer);
|
|
328
|
+
|
|
329
|
+
// Build the URL: serve through the /content/ static route
|
|
330
|
+
let tmpRelativePath = tmpRelativeFolder
|
|
331
|
+
? (tmpRelativeFolder + '/' + tmpUniqueFilename)
|
|
332
|
+
: tmpUniqueFilename;
|
|
333
|
+
let tmpURL = `/content/${tmpRelativePath}`;
|
|
334
|
+
tmpFable.log.info(`Image uploaded: ${tmpURL} (${tmpBuffer.length} bytes)`);
|
|
335
|
+
|
|
336
|
+
pResponse.send(
|
|
337
|
+
{
|
|
338
|
+
Success: true,
|
|
339
|
+
URL: tmpURL,
|
|
340
|
+
RelativePath: tmpRelativePath,
|
|
341
|
+
Filename: tmpUniqueFilename,
|
|
342
|
+
Size: tmpBuffer.length
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (pError)
|
|
346
|
+
{
|
|
347
|
+
tmpFable.log.error(`Image upload failed: ${pError.message}`);
|
|
348
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
349
|
+
}
|
|
350
|
+
return fNext();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// --- GET /api/content/uploads ---
|
|
354
|
+
// List uploaded images
|
|
355
|
+
tmpServiceServer.get('/api/content/uploads',
|
|
356
|
+
(pRequest, pResponse, fNext) =>
|
|
357
|
+
{
|
|
358
|
+
try
|
|
359
|
+
{
|
|
360
|
+
let tmpFiles = libFs.readdirSync(tmpUploadPath);
|
|
361
|
+
let tmpFileList = tmpFiles.map(
|
|
362
|
+
(pFilename) =>
|
|
363
|
+
{
|
|
364
|
+
let tmpStat = libFs.statSync(libPath.join(tmpUploadPath, pFilename));
|
|
365
|
+
return (
|
|
366
|
+
{
|
|
367
|
+
Filename: pFilename,
|
|
368
|
+
URL: `/uploads/${pFilename}`,
|
|
369
|
+
Size: tmpStat.size,
|
|
370
|
+
Modified: tmpStat.mtime
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
pResponse.send({ Success: true, Files: tmpFileList });
|
|
374
|
+
}
|
|
375
|
+
catch (pError)
|
|
376
|
+
{
|
|
377
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
378
|
+
}
|
|
379
|
+
return fNext();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Serve uploaded images at /uploads/
|
|
383
|
+
tmpOrator.addStaticRoute(`${tmpUploadPath}/`, 'index.html', '/uploads/*', '/uploads/');
|
|
384
|
+
|
|
385
|
+
// Serve content markdown files at /content/ (for the reader)
|
|
386
|
+
tmpOrator.addStaticRoute(`${tmpContentPath}/`, 'index.html', '/content/*', '/content/');
|
|
387
|
+
|
|
388
|
+
// Serve the built application from dist/ (main static route)
|
|
389
|
+
tmpOrator.addStaticRoute(`${tmpDistFolder}/`, 'index.html');
|
|
390
|
+
|
|
391
|
+
// Start the server
|
|
392
|
+
tmpOrator.startService(
|
|
393
|
+
function ()
|
|
394
|
+
{
|
|
395
|
+
return fCallback(null,
|
|
396
|
+
{
|
|
397
|
+
Fable: tmpFable,
|
|
398
|
+
Orator: tmpOrator,
|
|
399
|
+
Port: tmpPort
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = setupContentSystemServer;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const libCommandLineCommand = require('pict-service-commandlineutility').ServiceCommandLineCommand;
|
|
2
|
+
|
|
3
|
+
const libFs = require('fs');
|
|
4
|
+
const libPath = require('path');
|
|
5
|
+
|
|
6
|
+
class ContentSystemCommandServe extends libCommandLineCommand
|
|
7
|
+
{
|
|
8
|
+
constructor(pFable, pManifest, pServiceHash)
|
|
9
|
+
{
|
|
10
|
+
super(pFable, pManifest, pServiceHash);
|
|
11
|
+
|
|
12
|
+
this.options.CommandKeyword = 'serve';
|
|
13
|
+
this.options.Description = 'Start the content system server for a content folder.';
|
|
14
|
+
|
|
15
|
+
this.options.CommandArguments.push(
|
|
16
|
+
{ Name: '[content-path]', Description: 'Path to the markdown content folder (defaults to current directory).' });
|
|
17
|
+
|
|
18
|
+
this.options.CommandOptions.push(
|
|
19
|
+
{ Name: '-p, --port [port]', Description: 'Port to serve on (defaults to random 7000-7999).', Default: '0' });
|
|
20
|
+
|
|
21
|
+
this.addCommand();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onRunAsync(fCallback)
|
|
25
|
+
{
|
|
26
|
+
let tmpContentPath = libPath.resolve(this.ArgumentString || process.cwd());
|
|
27
|
+
|
|
28
|
+
// If no explicit content-path was given and the resolved directory has a
|
|
29
|
+
// content/ subfolder, use that instead. This way running the CLI from
|
|
30
|
+
// a project root that has a content/ folder does the right thing.
|
|
31
|
+
if (!this.ArgumentString)
|
|
32
|
+
{
|
|
33
|
+
let tmpContentSubfolder = libPath.join(tmpContentPath, 'content');
|
|
34
|
+
if (libFs.existsSync(tmpContentSubfolder) && libFs.statSync(tmpContentSubfolder).isDirectory())
|
|
35
|
+
{
|
|
36
|
+
tmpContentPath = tmpContentSubfolder;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let tmpDistPath = libPath.resolve(__dirname, '..', '..', '..', 'dist');
|
|
41
|
+
let tmpUploadPath = libPath.join(tmpContentPath, 'uploads');
|
|
42
|
+
let tmpPortOption = parseInt(this.CommandOptions.port, 10);
|
|
43
|
+
let tmpPort = (tmpPortOption > 0) ? tmpPortOption : (7000 + Math.floor(Math.random() * 1000));
|
|
44
|
+
|
|
45
|
+
// Validate dist path (packaged with the module)
|
|
46
|
+
if (!libFs.existsSync(tmpDistPath))
|
|
47
|
+
{
|
|
48
|
+
this.log.error(`Built assets not found at ${tmpDistPath}. Run 'npm run build-all' in the retold-content-system package first.`);
|
|
49
|
+
return fCallback(new Error('dist folder not found'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ensure content directory exists
|
|
53
|
+
if (!libFs.existsSync(tmpContentPath))
|
|
54
|
+
{
|
|
55
|
+
libFs.mkdirSync(tmpContentPath, { recursive: true });
|
|
56
|
+
this.log.info(`Created content directory: ${tmpContentPath}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Ensure uploads directory exists
|
|
60
|
+
if (!libFs.existsSync(tmpUploadPath))
|
|
61
|
+
{
|
|
62
|
+
libFs.mkdirSync(tmpUploadPath, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let tmpSelf = this;
|
|
66
|
+
let tmpSetupServer = require('../ContentSystem-Server-Setup.js');
|
|
67
|
+
|
|
68
|
+
tmpSetupServer(
|
|
69
|
+
{
|
|
70
|
+
ContentPath: tmpContentPath,
|
|
71
|
+
UploadPath: tmpUploadPath,
|
|
72
|
+
DistPath: tmpDistPath,
|
|
73
|
+
Port: tmpPort
|
|
74
|
+
},
|
|
75
|
+
function (pError, pServerInfo)
|
|
76
|
+
{
|
|
77
|
+
if (pError)
|
|
78
|
+
{
|
|
79
|
+
tmpSelf.log.error(`Failed to start server: ${pError.message}`);
|
|
80
|
+
return fCallback(pError);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
tmpSelf.log.info('');
|
|
84
|
+
tmpSelf.log.info('==========================================================');
|
|
85
|
+
tmpSelf.log.info(` Retold Content System running on http://localhost:${pServerInfo.Port}`);
|
|
86
|
+
tmpSelf.log.info('==========================================================');
|
|
87
|
+
tmpSelf.log.info(` Content: ${tmpContentPath}`);
|
|
88
|
+
tmpSelf.log.info(` Uploads: ${tmpUploadPath}`);
|
|
89
|
+
tmpSelf.log.info(` Assets: ${tmpDistPath}`);
|
|
90
|
+
tmpSelf.log.info(` Reader: http://localhost:${pServerInfo.Port}/`);
|
|
91
|
+
tmpSelf.log.info(` Editor: http://localhost:${pServerInfo.Port}/edit.html`);
|
|
92
|
+
tmpSelf.log.info('==========================================================');
|
|
93
|
+
tmpSelf.log.info('');
|
|
94
|
+
tmpSelf.log.info(' Press Ctrl+C to stop.');
|
|
95
|
+
tmpSelf.log.info('');
|
|
96
|
+
|
|
97
|
+
// Intentionally do NOT call fCallback() here.
|
|
98
|
+
// The server should keep running -- the Orator listener
|
|
99
|
+
// keeps the event loop alive.
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = ContentSystemCommandServe;
|