diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
| commit | 4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch) | |
| tree | c296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/ajax.c | |
| download | relic-master.tar.gz | |
Diffstat (limited to 'src/ajax.c')
| -rw-r--r-- | src/ajax.c | 487 |
1 files changed, 487 insertions, 0 deletions
diff --git a/src/ajax.c b/src/ajax.c new file mode 100644 index 0000000..97dab94 --- /dev/null +++ b/src/ajax.c @@ -0,0 +1,487 @@ +/* +** Copyright (c) 2020 D. Richard Hipp +** +** This program is free software; you can redistribute it and/or +** modify it under the terms of the Simplified BSD License (also +** known as the "2-Clause License" or "FreeBSD License".) +** +** This program is distributed in the hope that it will be useful, +** but without any warranty; without even the implied warranty of +** merchantability or fitness for a particular purpose. +** +** Author contact information: +** drh@hwaci.com +** http://www.hwaci.com/drh/ +** +******************************************************************************* +** +** This file contains shared Ajax-related code for /fileedit, the wiki/forum +** editors, and friends. +*/ +#include "config.h" +#include "ajax.h" +#include <assert.h> +#include <stdarg.h> + +#if INTERFACE +/* enum ajax_render_preview_flags: */ +#define AJAX_PREVIEW_LINE_NUMBERS 1 +/* enum ajax_render_modes: */ +#define AJAX_RENDER_GUESS 0 /* Guess rendering mode based on mimetype. */ +/* GUESS must be 0. All others have unspecified values. */ +#define AJAX_RENDER_PLAIN_TEXT 1 /* Render as plain text. */ +#define AJAX_RENDER_HTML_IFRAME 2 /* Render as HTML inside an IFRAME. */ +#define AJAX_RENDER_HTML_INLINE 3 /* Render as HTML without an IFRAME. */ +#define AJAX_RENDER_WIKI 4 /* Render as wiki/markdown. */ +#endif + +/* +** Emits JS code which initializes the +** fossil.page.previewModes object to a map of AJAX_RENDER_xxx values +** and symbolic names for use by client-side scripts. +** +** If addScriptTag is true then the output is wrapped in a SCRIPT tag +** with the current nonce, else no SCRIPT tag is emitted. +** +** Requires that builtin_emit_script_fossil_bootstrap() has already been +** called in order to initialize the window.fossil.page object. +*/ +void ajax_emit_js_preview_modes(int addScriptTag){ + if(addScriptTag){ + style_script_begin(__FILE__,__LINE__); + } + CX("fossil.page.previewModes={" + "guess: %d, %d: 'guess', wiki: %d, %d: 'wiki'," + "htmlIframe: %d, %d: 'htmlIframe', " + "htmlInline: %d, %d: 'htmlInline', " + "text: %d, %d: 'text'" + "};\n", + AJAX_RENDER_GUESS, AJAX_RENDER_GUESS, + AJAX_RENDER_WIKI, AJAX_RENDER_WIKI, + AJAX_RENDER_HTML_IFRAME, AJAX_RENDER_HTML_IFRAME, + AJAX_RENDER_HTML_INLINE, AJAX_RENDER_HTML_INLINE, + AJAX_RENDER_PLAIN_TEXT, AJAX_RENDER_PLAIN_TEXT); + if(addScriptTag){ + style_script_end(); + } +} + +/* +** Returns a value from the ajax_render_modes enum, based on the +** given mimetype string (which may be NULL), defaulting to +** AJAX_RENDER_PLAIN_TEXT. + */ +int ajax_render_mode_for_mimetype(const char * zMimetype){ + int rc = AJAX_RENDER_PLAIN_TEXT; + if( zMimetype ){ + if( fossil_strcmp(zMimetype, "text/html")==0 ){ + rc = AJAX_RENDER_HTML_IFRAME; + }else if( fossil_strcmp(zMimetype, "text/x-fossil-wiki")==0 + || fossil_strcmp(zMimetype, "text/x-markdown")==0 + || fossil_strcmp(zMimetype, "text/x-org")==0 ){ + rc = AJAX_RENDER_WIKI; + } + } + return rc; +} + +/* +** Renders text/wiki content preview for various /ajax routes. +** +** pContent is text/wiki content to preview. zName is the name of the +** content, for purposes of determining the mimetype based on the +** extension (if NULL, mimetype text/plain is assumed). flags may be a +** bitmask of values from the ajax_render_preview_flags +** enum. *renderMode must specify the render mode to use. If +** *renderMode==AJAX_RENDER_GUESS then *renderMode gets set to the +** mode which is guessed at for the rendering (based on the mimetype). +** +** nIframeHeightEm is only used for the AJAX_RENDER_HTML_IFRAME +** renderMode, and specifies the height, in EM's, of the resulting +** iframe. If passed 0, it defaults to "some sane value." +*/ +void ajax_render_preview(Blob * pContent, const char *zName, + int flags, int * renderMode, + int nIframeHeightEm){ + const char * zMime; + + zMime = zName ? mimetype_from_name(zName) : "text/plain"; + if(AJAX_RENDER_GUESS==*renderMode){ + *renderMode = ajax_render_mode_for_mimetype(zMime); + } + switch(*renderMode){ + case AJAX_RENDER_HTML_IFRAME:{ + char * z64 = encode64(blob_str(pContent), blob_size(pContent)); + CX("<iframe width='100%%' frameborder='0' " + "marginwidth='0' style='height:%dem' " + "marginheight='0' sandbox='allow-same-origin' " + "src='data:text/html;base64,%z'" + "></iframe>", + nIframeHeightEm ? nIframeHeightEm : 40, + z64); + break; + } + case AJAX_RENDER_HTML_INLINE:{ + CX("%b",pContent); + break; + } + case AJAX_RENDER_WIKI: + safe_html_context(DOCSRC_FILE); + wiki_render_by_mimetype(pContent, zMime); + break; + default:{ + const char *zContent = blob_str(pContent); + if(AJAX_PREVIEW_LINE_NUMBERS & flags){ + output_text_with_line_numbers(zContent, blob_size(pContent), + zName, "on", 0); + }else{ + const char *zExt = strrchr(zName,'.'); + if(zExt && zExt[1]){ + CX("<pre><code class='language-%s'>%h</code></pre>", + zExt+1, zContent); + }else{ + CX("<pre>%h</pre>", zContent); + } + } + break; + } + } +} + +/* +** Renders diffs for ajax routes. pOrig is the "original" (v1) content +** and pContent is the locally-edited (v2) content. diffFlags is any +** set of flags suitable for passing to text_diff(). +** +** zOrigHash, if not NULL, must be the SCM-side hash of pOrig's +** contents. If set, additional information may be built into +** the diff output to enable dynamic loading of additional +** diff context. +*/ +void ajax_render_diff(Blob * pOrig, const char * zOrigHash, + Blob *pContent, u64 diffFlags){ + Blob out = empty_blob; + DiffConfig DCfg; + + diff_config_init(&DCfg, diffFlags); + DCfg.zLeftHash = zOrigHash; + text_diff(pOrig, pContent, &out, &DCfg); + if(blob_size(&out)==0){ + /* nothing to do */ + }else{ + CX("%b",&out); + } + blob_reset(&out); +} + +/* +** Uses P(zKey) to fetch a CGI environment variable. If that var is +** NULL or starts with '0' or 'f' then this function returns false, +** else it returns true. +*/ +int ajax_p_bool(char const *zKey){ + const char * zVal = P(zKey); + return (!zVal || '0'==*zVal || 'f'==*zVal) ? 0 : 1; +} + +/* +** Helper for /ajax routes. Clears the CGI content buffer, sets an +** HTTP error status code, and queues up a JSON response in the form +** of an object: +** +** {error: formatted message} +** +** If httpCode<=0 then it defaults to 500. +** +** After calling this, the caller should immediately return. +** +** Returns the resulting http code. +*/ +int ajax_route_error(int httpCode, const char * zFmt, ...){ + Blob msg = empty_blob; + Blob content = empty_blob; + va_list vargs; + + if( httpCode<=0 ) httpCode=500; + va_start(vargs,zFmt); + blob_vappendf(&msg, zFmt, vargs); + va_end(vargs); + blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg)); + blob_reset(&msg); + cgi_set_content(&content); + cgi_set_status(httpCode, "Error"); + cgi_set_content_type("application/json"); + return httpCode; +} + +void ajax_route_error_forbidden(){ + ajax_route_error(403, "Permission denied."); +} + +void ajax_route_error_captcha(){ + ajax_route_error(400, "Invalid captcha response."); +} + +void ajax_route_error_csrf(){ + ajax_route_error(403, "Invalid CSRF signature."); +} + +void ajax_route_error_404(const char *zMsg){ + ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found."); +} + +int ajax_check_csrf(int level){ + if( 0==cgi_csrf_safe(level) ){ + ajax_route_error_csrf(); + return 0; + } + return 1; +} + +/* +** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as +** logging in the user. +** +** Returns false (0) if bootstrapping fails, in which case it has +** reported the error and the route should immediately return. Returns +** true on success. +** +** If requireWrite is true then write permissions are required. +** If requirePost is true then the request is assumed to be using +** POST'ed data and CSRF validation is performed. +** +*/ +int ajax_route_bootstrap(int requireWrite, int requirePost){ + login_check_credentials(); + if( requireWrite!=0 && g.perm.Write==0 ){ + ajax_route_error(403,"Write permissions required."); + return 0; + }else if(requirePost && 0==cgi_csrf_safe(requirePost)){ + ajax_route_error(403, + "CSRF violation (make sure sending of HTTP " + "Referer headers is enabled for XHR " + "connections)."); + return 0; + } + cgi_set_content_type("application/json"); + return 1; +} + +/* +** Helper for collecting filename/check-in request parameters. +** +** If zFn is not NULL, it is assigned the value of the first one of +** the "filename" or "fn" CGI parameters which is set. +** +** If zCi is not NULL, it is assigned the value of the first one of +** the "checkin" or "ci" CGI parameters which is set. +** +** If a parameter is not NULL, it will be assigned NULL if the +** corresponding parameter is not set. +** +** Returns the number of non-NULL values it assigns to arguments. Thus +** if passed (&x, NULL), it returns 1 if it assigns non-NULL to *x and +** 0 if it assigns NULL to *x. +*/ +int ajax_get_fnci_args( const char **zFn, const char **zCi ){ + int rc = 0; + if(zCi!=0){ + *zCi = PD("checkin",P("ci")); + if( *zCi ) ++rc; + } + if(zFn!=0){ + *zFn = PD("filename",P("fn")); + if (*zFn) ++rc; + } + return rc; +} + +/* +** AJAX route /ajax/preview-text +** +** Required query parameters: +** +** filename=name of content, for use in determining the +** mimetype/render mode. +** +** content=text +** +** Optional query parameters: +** +** render_mode=integer (AJAX_RENDER_xxx) (default=AJAX_RENDER_GUESS) +** +** ln=0 or 1 to disable/enable line number mode in +** AJAX_RENDER_PLAIN_TEXT mode. +** +** iframe_height=integer (default=40) Height, in EMs of HTML preview +** iframe. +** +** Responds with the HTML content of the preview. On error it produces +** a JSON response as documented for ajax_route_error(). +** +** Extra response headers: +** +** x-ajax-render-mode: string representing the rendering mode +** which was really used (which will differ from the requested mode +** only if mode 0 (guess) was requested). The names are documented +** below in code and match those in the emitted JS object +** fossil.page.previewModes. +*/ +void ajax_route_preview_text(void){ + const char * zFilename = 0; + const char * zContent = P("content"); + int renderMode = atoi(PD("render_mode","0")); + int ln = atoi(PD("ln","0")); + int iframeHeight = atoi(PD("iframe_height","40")); + Blob content = empty_blob; + const char * zRenderMode = 0; + + ajax_get_fnci_args( &zFilename, 0 ); + + if(!ajax_route_bootstrap(0,1)){ + return; + } + if(zFilename==0){ + /* The filename is only used for mimetype determination, + ** so we can default it... */ + zFilename = "foo.txt"; + } + cgi_set_content_type("text/html"); + blob_init(&content, zContent, -1); + ajax_render_preview(&content, zFilename, + ln ? AJAX_PREVIEW_LINE_NUMBERS : 0, + &renderMode, iframeHeight); + /* + ** Now tell the caller if we did indeed use AJAX_RENDER_WIKI, so that + ** they can re-set the <base href> to an appropriate value (which + ** requires knowing the content's current check-in version, which we + ** don't have here). + */ + switch(renderMode){ + /* The strings used here MUST correspond to those used in the JS-side + ** fossil.page.previewModes map. + */ + case AJAX_RENDER_WIKI: zRenderMode = "wiki"; break; + case AJAX_RENDER_HTML_INLINE: zRenderMode = "htmlInline"; break; + case AJAX_RENDER_HTML_IFRAME: zRenderMode = "htmlIframe"; break; + case AJAX_RENDER_PLAIN_TEXT: zRenderMode = "text"; break; + case AJAX_RENDER_GUESS: + assert(!"cannot happen"); + } + if(zRenderMode!=0){ + cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode); + } +} + +/* +** AJAX route /ajax/artifact.json. +** URL arguments: +** +** uuid=ARTIFACT_ID REQUIRED +** +** and emits either: +** +** { error: "..." } +** +** with a non-200 response code or the artifact's manifest in JSON +** form with a 200 response code. +*/ +void ajax_route_artifact_json(void){ + const char *zUuid = P("uuid"); + Blob json = BLOB_INITIALIZER; + login_check_credentials(); + if( ! g.perm.Read ){ + ajax_route_error_forbidden(); + }else if( artifact_to_json_by_name(zUuid, &json) ){ + @ %b(&json) + }else{ + ajax_route_error_404("Cannot resolve artifact ID."); + } + blob_reset(&json); +} + +#if INTERFACE +/* +** Internal mapping of ajax sub-route names to various metadata. +*/ +struct AjaxRoute { + const char *zName; /* Name part of the route after "ajax/" */ + void (*xCallback)(); /* Impl function for the route. */ + int bWriteMode; /* True if requires write mode */ + int bPost; /* True if requires POST (i.e. CSRF + ** verification). Value is passed to + ** cgi_csrf_safe(). */ +}; +typedef struct AjaxRoute AjaxRoute; +#endif /*INTERFACE*/ + +/* +** Comparison function for bsearch() for searching an AjaxRoute +** list for a matching name. +*/ +int cmp_ajax_route_name(const void *a, const void *b){ + const AjaxRoute * rA = (const AjaxRoute*)a; + const AjaxRoute * rB = (const AjaxRoute*)b; + return fossil_strcmp(rA->zName, rB->zName); +} + +/* +** WEBPAGE: ajax hidden +** +** The main dispatcher for shared ajax-served routes. Requires the +** 'name' parameter be the main route's name (as defined in a list in +** this function), noting that fossil automatically assigns all path +** parts after "ajax" to "name", e.g. /ajax/foo/bar assigns +** name=foo/bar. +** +** This "page" is only intended to be used by higher-level pages which +** have certain Ajax-driven features in common. It is not intended to +** be used by clients and NONE of its HTTP interfaces are considered +** documented/stable/supported - they may change on any given build of +** fossil. +** +** The exact response type depends on the route which gets called. In +** the case of an initialization error it emits a JSON-format response +** as documented for ajax_route_error(). Individual routes may emit +** errors in different formats, e.g. HTML. +*/ +void ajax_route_dispatcher(void){ + const char * zName = P("name"); + AjaxRoute routeName = {0,0,0,0}; + const AjaxRoute * pRoute = 0; + const AjaxRoute routes[] = { + /* Keep these sorted by zName (for bsearch()) */ + {"artifact.json", ajax_route_artifact_json, 0, 0}, + {"preview-text", ajax_route_preview_text, 0, 1 + /* Preview does not require write permissions in the repo. It + ** should arguably require write permissions simply to limit abuse + ** but doing means that /chat does not work without check-in + ** permissions: + ** + ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898 + ** + ** This particular route is used by /fileedit and /chat, whereas + ** /wikiedit uses a simpler wiki-specific route. + */ + /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as + ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that + ** the filename is only used for mimetype determination. */ + } + }; + + if(zName==0 || zName[0]==0){ + ajax_route_error(400,"Missing required [route] 'name' parameter."); + return; + } + routeName.zName = zName; + pRoute = (const AjaxRoute *)bsearch(&routeName, routes, + count(routes), sizeof routes[0], + cmp_ajax_route_name); + if(pRoute==0){ + ajax_route_error(404,"Ajax route not found."); + return; + }else if(0==ajax_route_bootstrap(pRoute->bWriteMode, pRoute->bPost)){ + return; + } + pRoute->xCallback(); +} |
