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/fileedit.c | |
| download | relic-4f49b16079b27d7ce633619e4cddfdf2bffbc952.tar.gz | |
Diffstat (limited to 'src/fileedit.c')
| -rw-r--r-- | src/fileedit.c | 2070 |
1 files changed, 2070 insertions, 0 deletions
diff --git a/src/fileedit.c b/src/fileedit.c new file mode 100644 index 0000000..3f204df --- /dev/null +++ b/src/fileedit.c @@ -0,0 +1,2070 @@ +/* +** 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 code for the /fileedit page and related bits. +*/ +#include "config.h" +#include "fileedit.h" +#include <assert.h> +#include <stdarg.h> + +/* +** State for the "mini-checkin" infrastructure, which enables the +** ability to commit changes to a single file without a check-out +** db, e.g. for use via an HTTP request. +** +** Use CheckinMiniInfo_init() to cleanly initialize one to a known +** valid/empty default state. +** +** Memory for all non-const pointer members is owned by the +** CheckinMiniInfo instance, unless explicitly noted otherwise, and is +** freed by CheckinMiniInfo_cleanup(). Similarly, each instance owns +** any memory for its own Blob members, but NOT for its pointers to +** blobs. +*/ +struct CheckinMiniInfo { + Manifest * pParent; /* parent check-in. Memory is owned by this + object. */ + char *zParentUuid; /* Full UUID of pParent */ + char *zFilename; /* Name of single file to commit. Must be + relative to the top of the repo. */ + Blob fileContent; /* Content of file referred to by zFilename. */ + Blob fileHash; /* Hash of this->fileContent, using the repo's + preferred hash method. */ + Blob comment; /* Check-in comment text */ + char *zCommentMimetype; /* Mimetype of comment. May be NULL */ + char *zUser; /* User name */ + char *zDate; /* Optionally force this date string (anything + supported by date_in_standard_format()). + May be NULL. */ + Blob *pMfOut; /* If not NULL, checkin_mini() will write a + copy of the generated manifest here. This + memory is NOT owned by CheckinMiniInfo. */ + int filePerm; /* Permissions (via file_perm()) of the input + file. We need to store this before calling + checkin_mini() because the real input file + name may differ from the repo-centric + this->zFilename, and checkin_mini() requires + the permissions of the original file. For + web commits, set this to PERM_REG or (when + editing executable scripts) PERM_EXE before + calling checkin_mini(). */ + int flags; /* Bitmask of fossil_cimini_flags. */ +}; +typedef struct CheckinMiniInfo CheckinMiniInfo; + +/* +** CheckinMiniInfo::flags values. +*/ +enum fossil_cimini_flags { +/* +** Must have a value of 0. All other flags have unspecified values. +*/ +CIMINI_NONE = 0, +/* +** Tells checkin_mini() to use dry-run mode. +*/ +CIMINI_DRY_RUN = 1, +/* +** Tells checkin_mini() to allow forking from a non-leaf commit. +*/ +CIMINI_ALLOW_FORK = 1<<1, +/* +** Tells checkin_mini() to dump its generated manifest to stdout. +*/ +CIMINI_DUMP_MANIFEST = 1<<2, + +/* +** By default, content containing what appears to be a merge conflict +** marker is not permitted. This flag relaxes that requirement. +*/ +CIMINI_ALLOW_MERGE_MARKER = 1<<3, + +/* +** By default mini-checkins are not allowed to be "older" +** than their parent. i.e. they may not have a timestamp +** which predates their parent. This flag bypasses that +** check. +*/ +CIMINI_ALLOW_OLDER = 1<<4, + +/* +** Indicates that the content of the newly checked-in file is +** converted, if needed, to use the same EOL style as the previous +** version of that file. Only the in-memory/in-repo copies are +** affected, not the original file (if any). +*/ +CIMINI_CONVERT_EOL_INHERIT = 1<<5, +/* +** Indicates that the input's EOLs should be converted to Unix-style. +*/ +CIMINI_CONVERT_EOL_UNIX = 1<<6, +/* +** Indicates that the input's EOLs should be converted to Windows-style. +*/ +CIMINI_CONVERT_EOL_WINDOWS = 1<<7, +/* +** A hint to checkin_mini() to "prefer" creation of a delta manifest. +** It may decide not to for various reasons. +*/ +CIMINI_PREFER_DELTA = 1<<8, +/* +** A "stronger hint" to checkin_mini() to prefer creation of a delta +** manifest if it at all can. It will decide not to only if creation +** of a delta is not a realistic option or if it's forbidden by the +** forbid-delta-manifests repo config option. For this to work, it +** must be set together with the CIMINI_PREFER_DELTA flag, but the two +** cannot be combined in this enum. +** +** This option is ONLY INTENDED FOR TESTING, used in bypassing +** heuristics which may otherwise disable generation of a delta on the +** grounds of efficiency (e.g. not generating a delta if the parent +** non-delta only has a few F-cards). +*/ +CIMINI_STRONGLY_PREFER_DELTA = 1<<9, +/* +** Tells checkin_mini() to permit the addition of a new file. Normally +** this is disabled because there are hypothetically many cases where +** it could cause the inadvertent addition of a new file when an +** update to an existing was intended, as a side-effect of name-case +** differences. +*/ +CIMINI_ALLOW_NEW_FILE = 1<<10 +}; + +/* +** Initializes p to a known-valid default state. +*/ +static void CheckinMiniInfo_init( CheckinMiniInfo * p ){ + memset(p, 0, sizeof(CheckinMiniInfo)); + p->flags = CIMINI_NONE; + p->filePerm = -1; + p->comment = p->fileContent = p->fileHash = empty_blob; +} + +/* +** Frees all memory owned by p, but does not free p. + */ +static void CheckinMiniInfo_cleanup( CheckinMiniInfo * p ){ + blob_reset(&p->comment); + blob_reset(&p->fileContent); + blob_reset(&p->fileHash); + if(p->pParent){ + manifest_destroy(p->pParent); + } + fossil_free(p->zFilename); + fossil_free(p->zDate); + fossil_free(p->zParentUuid); + fossil_free(p->zCommentMimetype); + fossil_free(p->zUser); + CheckinMiniInfo_init(p); +} + +/* +** Internal helper which returns an F-card perms string suitable for +** writing as-is into a manifest. If it's not empty, it includes a +** leading space to separate it from the F-card's hash field. +*/ +static const char * mfile_permint_mstring(int perm){ + switch(perm){ + case PERM_EXE: return " x"; + case PERM_LNK: return " l"; + default: return ""; + } +} + +/* +** Given a ManifestFile permission string (or NULL), it returns one of +** PERM_REG, PERM_EXE, or PERM_LNK. +*/ +static int mfile_permstr_int(const char *zPerm){ + if(!zPerm || !*zPerm) return PERM_REG; + else if(strstr(zPerm,"x")) return PERM_EXE; + else if(strstr(zPerm,"l")) return PERM_LNK; + else return PERM_REG/*???*/; +} + +/* +** Internal helper for checkin_mini() and friends. Appends an F-card +** for p to pOut. +*/ +static void checkin_mini_append_fcard(Blob *pOut, + const ManifestFile *p){ + if(p->zUuid){ + assert(*p->zUuid); + blob_appendf(pOut, "F %F %s%s", p->zName, + p->zUuid, + mfile_permint_mstring(manifest_file_mperm(p))); + if(p->zPrior){ + assert(*p->zPrior); + blob_appendf(pOut, " %F\n", p->zPrior); + }else{ + blob_append(pOut, "\n", 1); + } + }else{ + /* File was removed from parent delta. */ + blob_appendf(pOut, "F %F\n", p->zName); + } +} + +/* +** Handles the F-card parts for create_manifest_mini(). +** +** If asDelta is true, F-cards will be handled as for a delta +** manifest, and the caller MUST have added a B-card to pOut before +** calling this. +** +** Returns 1 on success, 0 on error, and writes any error message to +** pErr (if it's not NULL). The only non-immediately-fatal/panic error +** is if pCI->filePerm is PERM_LNK or pCI would update a PERM_LNK +** in-repo file. +*/ +static int create_manifest_mini_fcards( Blob * pOut, + CheckinMiniInfo * pCI, + int asDelta, + Blob * pErr){ + int wroteThisCard = 0; + const ManifestFile * pFile; + int (*fncmp)(char const *, char const *) = /* filename comparator */ + filenames_are_case_sensitive() + ? fossil_strcmp + : fossil_stricmp; +#define mf_err(EXPR) if(pErr) blob_appendf EXPR; return 0 +#define write_this_card(NAME) \ + blob_appendf(pOut, "F %F %b%s\n", (NAME), &pCI->fileHash, \ + mfile_permint_mstring(pCI->filePerm)); \ + wroteThisCard = 1 + + assert(pCI->filePerm!=PERM_LNK && "This should have been validated before."); + assert(pCI->filePerm==PERM_REG || pCI->filePerm==PERM_EXE); + if(PERM_LNK==pCI->filePerm){ + goto err_no_symlink; + } + manifest_file_rewind(pCI->pParent); + if(asDelta!=0 && (pCI->pParent->zBaseline==0 + || pCI->pParent->nFile==0)){ + /* Parent is a baseline or a delta with no F-cards, so this is + ** the simplest case: create a delta with a single F-card. + */ + pFile = manifest_file_find(pCI->pParent, pCI->zFilename); + if(pFile!=0 && manifest_file_mperm(pFile)==PERM_LNK){ + goto err_no_symlink; + } + write_this_card(pFile ? pFile->zName : pCI->zFilename); + return 1; + } + while(1){ + int cmp; + if(asDelta==0){ + pFile = manifest_file_next(pCI->pParent, 0); + }else{ + /* Parent is a delta manifest with F-cards. Traversal of delta + ** manifest file entries is normally done via + ** manifest_file_next(), which takes into account the + ** differences between the delta and its parent and returns + ** F-cards from both. Each successive delta from the same + ** baseline includes all F-card changes from the previous + ** deltas, so we instead clone the parent's F-cards except for + ** the one (if any) which matches the new file. + */ + pFile = pCI->pParent->iFile < pCI->pParent->nFile + ? &pCI->pParent->aFile[pCI->pParent->iFile++] + : 0; + } + if(0==pFile) break; + cmp = fncmp(pFile->zName, pCI->zFilename); + if(cmp<0){ + checkin_mini_append_fcard(pOut,pFile); + }else{ + if(cmp==0 || 0==wroteThisCard){ + assert(0==wroteThisCard); + if(PERM_LNK==manifest_file_mperm(pFile)){ + goto err_no_symlink; + } + write_this_card(cmp==0 ? pFile->zName : pCI->zFilename); + } + if(cmp>0){ + assert(wroteThisCard!=0); + checkin_mini_append_fcard(pOut,pFile); + } + } + } + if(wroteThisCard==0){ + write_this_card(pCI->zFilename); + } + return 1; +err_no_symlink: + mf_err((pErr,"Cannot commit or overwrite symlinks " + "via mini-checkin.")); + return 0; +#undef write_this_card +#undef mf_err +} + +/* +** Creates a manifest file, written to pOut, from the state in the +** fully-populated and semantically valid pCI argument. pCI is not +** *semantically* modified by this routine but cannot be const because +** blob_str() may need to NUL-terminate any given blob. +** +** Returns true on success. On error, returns 0 and, if pErr is not +** NULL, writes an error message there. +** +** Intended only to be called via checkin_mini() or routines which +** have already completely vetted pCI for semantic validity. +*/ +static int create_manifest_mini( Blob * pOut, CheckinMiniInfo * pCI, + Blob * pErr){ + Blob zCard = empty_blob; /* Z-card checksum */ + int asDelta = 0; +#define mf_err(EXPR) if(pErr) blob_appendf EXPR; return 0 + + assert(blob_str(&pCI->fileHash)); + assert(pCI->pParent); + assert(pCI->zFilename); + assert(pCI->zUser); + assert(pCI->zDate); + + /* Potential TODOs include... + ** + ** - Maybe add support for tags. Those can be edited via /info page, + ** and feel like YAGNI/feature creep for this purpose. + */ + blob_zero(pOut); + manifest_file_rewind(pCI->pParent) /* force load of baseline */; + /* Determine whether we want to create a delta manifest... */ + if((CIMINI_PREFER_DELTA & pCI->flags) + && ((CIMINI_STRONGLY_PREFER_DELTA & pCI->flags) + || (pCI->pParent->pBaseline + ? pCI->pParent->pBaseline + : pCI->pParent)->nFile > 15 + /* 15 is arbitrary: don't create a delta when there is only a + ** tiny gain for doing so. That heuristic is not *quite* + ** right, in that when we're deriving from another delta, we + ** really should compare the F-card count between it and its + ** baseline, and create a delta if the baseline has (say) + ** twice or more as many F-cards as the previous delta. */) + && !db_get_boolean("forbid-delta-manifests",0) + ){ + asDelta = 1; + blob_appendf(pOut, "B %s\n", + pCI->pParent->zBaseline + ? pCI->pParent->zBaseline + : pCI->zParentUuid); + } + if(blob_size(&pCI->comment)!=0){ + blob_appendf(pOut, "C %F\n", blob_str(&pCI->comment)); + }else{ + blob_append(pOut, "C (no\\scomment)\n", 16); + } + blob_appendf(pOut, "D %s\n", pCI->zDate); + if(create_manifest_mini_fcards(pOut,pCI,asDelta,pErr)==0){ + return 0; + } + if(pCI->zCommentMimetype!=0 && pCI->zCommentMimetype[0]!=0){ + blob_appendf(pOut, "N %F\n", pCI->zCommentMimetype); + } + blob_appendf(pOut, "P %s\n", pCI->zParentUuid); + blob_appendf(pOut, "U %F\n", pCI->zUser); + md5sum_blob(pOut, &zCard); + blob_appendf(pOut, "Z %b\n", &zCard); + blob_reset(&zCard); + return 1; +#undef mf_err +} + +/* +** A so-called "single-file/mini/web check-in" is a slimmed-down form +** of the check-in command which accepts only a single file and is +** intended to accept edits to a file via the web interface or from +** the CLI from outside of a check-out. +** +** Being fully non-interactive is a requirement for this function, +** thus it cannot perform autosync or similar activities (which +** includes checking for repo locks). +** +** This routine uses the state from the given fully-populated pCI +** argument to add pCI->fileContent to the database, and create and +** save a manifest for that change. Ownership of pCI and its contents +** are unchanged. +** +** This function may modify pCI as follows: +** +** - If one of Manifest pCI->pParent or pCI->zParentUuid are NULL, +** then the other will be assigned based on its counterpart. Both +** may not be NULL. +** +** - pCI->zDate is normalized to/replaced with a valid date/time +** string. If its original value cannot be validated then +** this function fails. If pCI->zDate is NULL, the current time +** is used. +** +** - If the CIMINI_CONVERT_EOL_INHERIT flag is set, +** pCI->fileContent appears to be plain text, and its line-ending +** style differs from its previous version, it is converted to the +** same EOL style as the previous version. If this is done, the +** pCI->fileHash is re-computed. Note that only pCI->fileContent, +** not the original file, is affected by the conversion. +** +** - Else if one of the CIMINI_CONVERT_EOL_WINDOWS or +** CIMINI_CONVERT_EOL_UNIX flags are set, pCI->fileContent is +** converted, if needed, to the corresponding EOL style. +** +** - If EOL conversion takes place, pCI->fileHash is re-calculated. +** +** - If pCI->fileHash is empty, this routine populates it with the +** repository's preferred hash algorithm (after any EOL conversion). +** +** - pCI->comment may be converted to Unix-style newlines. +** +** pCI's ownership is not modified. +** +** This function validates pCI's state and fails if any validation +** fails. +** +** On error, returns false (0) and, if pErr is not NULL, writes a +** diagnostic message there. +** +** Returns true on success. If pRid is not NULL, the RID of the +** resulting manifest is written to *pRid. +** +** The check-in process is largely influenced by pCI->flags, and that +** must be populated before calling this. See the fossil_cimini_flags +** enum for the docs for each flag. +*/ +static int checkin_mini(CheckinMiniInfo * pCI, int *pRid, Blob * pErr){ + Blob mf = empty_blob; /* output manifest */ + int rid = 0, frid = 0; /* various RIDs */ + int isPrivate; /* whether this is private content + or not */ + ManifestFile * zFilePrev; /* file entry from pCI->pParent */ + int prevFRid = 0; /* RID of file's prev. version */ +#define ci_err(EXPR) if(pErr!=0){blob_appendf EXPR;} goto ci_error + + db_begin_transaction(); + if(pCI->pParent==0 && pCI->zParentUuid==0){ + ci_err((pErr, "Cannot determine parent version.")); + } + else if(pCI->pParent==0){ + pCI->pParent = manifest_get_by_name(pCI->zParentUuid, 0); + if(pCI->pParent==0){ + ci_err((pErr,"Cannot load manifest for [%S].", pCI->zParentUuid)); + } + }else if(pCI->zParentUuid==0){ + pCI->zParentUuid = rid_to_uuid(pCI->pParent->rid); + assert(pCI->zParentUuid); + } + assert(pCI->pParent->rid>0); + if(leaf_is_closed(pCI->pParent->rid)){ + ci_err((pErr,"Cannot commit to a closed leaf.")); + /* Remember that in order to override this we'd also need to + ** cancel TAG_CLOSED on pCI->pParent. There would seem to be no + ** reason we can't do that via the generated manifest, but the + ** commit command does not offer that option, so mini-checkin + ** probably shouldn't, either. + */ + } + if( !db_exists("SELECT 1 FROM user WHERE login=%Q", pCI->zUser) ){ + ci_err((pErr,"No such user: %s", pCI->zUser)); + } + if(!(CIMINI_ALLOW_FORK & pCI->flags) + && !is_a_leaf(pCI->pParent->rid)){ + ci_err((pErr,"Parent [%S] is not a leaf and forking is disabled.", + pCI->zParentUuid)); + } + if(!(CIMINI_ALLOW_MERGE_MARKER & pCI->flags) + && contains_merge_marker(&pCI->fileContent)){ + ci_err((pErr,"Content appears to contain a merge conflict marker.")); + } + if(!file_is_simple_pathname(pCI->zFilename, 1)){ + ci_err((pErr,"Invalid filename for use in a repository: %s", + pCI->zFilename)); + } + if(!(CIMINI_ALLOW_OLDER & pCI->flags) + && !checkin_is_younger(pCI->pParent->rid, pCI->zDate)){ + ci_err((pErr,"Check-in time (%s) may not be older " + "than its parent (%z).", + pCI->zDate, + db_text(0, "SELECT strftime('%%Y-%%m-%%dT%%H:%%M:%%f',%lf)", + pCI->pParent->rDate) + )); + } + { + /* + ** Normalize the timestamp. We don't use date_in_standard_format() + ** because that has side-effects we don't want to trigger here. + */ + char * zDVal = db_text( + 0, "SELECT strftime('%%Y-%%m-%%dT%%H:%%M:%%f',%Q)", + pCI->zDate ? pCI->zDate : "now"); + if(zDVal==0 || zDVal[0]==0){ + fossil_free(zDVal); + ci_err((pErr,"Invalid timestamp string: %s", pCI->zDate)); + } + fossil_free(pCI->zDate); + pCI->zDate = zDVal; + } + { /* Confirm that only one EOL policy is in place. */ + int n = 0; + if(CIMINI_CONVERT_EOL_INHERIT & pCI->flags) ++n; + if(CIMINI_CONVERT_EOL_UNIX & pCI->flags) ++n; + if(CIMINI_CONVERT_EOL_WINDOWS & pCI->flags) ++n; + if(n>1){ + ci_err((pErr,"More than 1 EOL conversion policy was specified.")); + } + } + /* Potential TODOs include: + ** + ** - Commit allows an empty check-in only with a flag, but we + ** currently disallow an empty check-in entirely. Conform with + ** commit? + ** + ** Non-TODOs: + ** + ** - Check for a commit lock would require auto-sync, which this + ** code cannot do if it's going to be run via a web page. + */ + + /* + ** Confirm that pCI->zFilename can be found in pCI->pParent. If + ** not, fail unless the CIMINI_ALLOW_NEW_FILE flag is set. This is + ** admittedly an artificial limitation, not strictly necessary. We + ** do it to hopefully reduce the chance of an "oops" where file + ** X/Y/z gets committed as X/Y/Z or X/y/z due to a typo or + ** case-sensitivity mismatch between the user/repo/filesystem, or + ** some such. + */ + manifest_file_rewind(pCI->pParent); + zFilePrev = manifest_file_find(pCI->pParent, pCI->zFilename); + if(!(CIMINI_ALLOW_NEW_FILE & pCI->flags) + && (!zFilePrev + || !zFilePrev->zUuid/*was removed from parent delta manifest*/) + ){ + ci_err((pErr,"File [%s] not found in manifest [%S]. " + "Adding new files is currently not permitted.", + pCI->zFilename, pCI->zParentUuid)); + }else if(zFilePrev + && manifest_file_mperm(zFilePrev)==PERM_LNK){ + ci_err((pErr,"Cannot save a symlink via a mini-checkin.")); + } + if(zFilePrev){ + prevFRid = fast_uuid_to_rid(zFilePrev->zUuid); + } + + if(((CIMINI_CONVERT_EOL_INHERIT & pCI->flags) + || (CIMINI_CONVERT_EOL_UNIX & pCI->flags) + || (CIMINI_CONVERT_EOL_WINDOWS & pCI->flags)) + && blob_size(&pCI->fileContent)>0 + ){ + /* Convert to the requested EOL style. Note that this inherently + ** runs a risk of breaking content, e.g. string literals which + ** contain embedded newlines. Note that HTML5 specifies that + ** form-submitted TEXTAREA content gets normalized to CRLF-style: + ** + ** https://html.spec.whatwg.org/#the-textarea-element + */ + const int pseudoBinary = LOOK_LONG | LOOK_NUL; + const int lookFlags = LOOK_CRLF | LOOK_LONE_LF | pseudoBinary; + const int lookNew = looks_like_utf8( &pCI->fileContent, lookFlags ); + if(!(pseudoBinary & lookNew)){ + int rehash = 0; + /*fossil_print("lookNew=%08x\n",lookNew);*/ + if(CIMINI_CONVERT_EOL_INHERIT & pCI->flags){ + Blob contentPrev = empty_blob; + int lookOrig, nOrig; + content_get(prevFRid, &contentPrev); + lookOrig = looks_like_utf8(&contentPrev, lookFlags); + nOrig = blob_size(&contentPrev); + blob_reset(&contentPrev); + /*fossil_print("lookOrig=%08x\n",lookOrig);*/ + if(nOrig>0 && lookOrig!=lookNew){ + /* If there is a newline-style mismatch, adjust the new + ** content version to the previous style, then re-hash the + ** content. Note that this means that what we insert is NOT + ** what's in the filesystem. + */ + if(!(lookOrig & LOOK_CRLF) && (lookNew & LOOK_CRLF)){ + /* Old has Unix-style, new has Windows-style. */ + blob_to_lf_only(&pCI->fileContent); + rehash = 1; + }else if((lookOrig & LOOK_CRLF) && !(lookNew & LOOK_CRLF)){ + /* Old has Windows-style, new has Unix-style. */ + blob_add_cr(&pCI->fileContent); + rehash = 1; + } + } + }else{ + const int oldSize = blob_size(&pCI->fileContent); + if(CIMINI_CONVERT_EOL_UNIX & pCI->flags){ + if(LOOK_CRLF & lookNew){ + blob_to_lf_only(&pCI->fileContent); + } + }else{ + assert(CIMINI_CONVERT_EOL_WINDOWS & pCI->flags); + if(!(LOOK_CRLF & lookNew)){ + blob_add_cr(&pCI->fileContent); + } + } + if((int)blob_size(&pCI->fileContent)!=oldSize){ + rehash = 1; + } + } + if(rehash!=0){ + hname_hash(&pCI->fileContent, 0, &pCI->fileHash); + } + } + }/* end EOL conversion */ + + if(blob_size(&pCI->fileHash)==0){ + /* Hash the content if it's not done already... */ + hname_hash(&pCI->fileContent, 0, &pCI->fileHash); + assert(blob_size(&pCI->fileHash)>0); + } + if(zFilePrev){ + /* Has this file been changed since its previous commit? Note + ** that we have to delay this check until after the potentially + ** expensive EOL conversion. */ + assert(blob_size(&pCI->fileHash)); + if(0==fossil_strcmp(zFilePrev->zUuid, blob_str(&pCI->fileHash)) + && manifest_file_mperm(zFilePrev)==pCI->filePerm){ + ci_err((pErr,"File is unchanged. Not committing.")); + } + } +#if 1 + /* Do we really want to normalize comment EOLs? Web-posting will + ** submit them in CRLF or LF format, depending on how exactly the + ** content is submitted (FORM (CRLF) or textarea-to-POST (LF, at + ** least in theory)). */ + blob_to_lf_only(&pCI->comment); +#endif + /* Create, save, deltify, and crosslink the manifest... */ + if(create_manifest_mini(&mf, pCI, pErr)==0){ + return 0; + } + isPrivate = content_is_private(pCI->pParent->rid); + rid = content_put_ex(&mf, 0, 0, 0, isPrivate); + if(pCI->flags & CIMINI_DUMP_MANIFEST){ + fossil_print("%b", &mf); + } + if(pCI->pMfOut!=0){ + /* Cross-linking clears mf, so we have to copy it, + ** instead of taking over its memory. */ + blob_reset(pCI->pMfOut); + blob_append(pCI->pMfOut, blob_buffer(&mf), blob_size(&mf)); + } + content_deltify(rid, &pCI->pParent->rid, 1, 0); + manifest_crosslink(rid, &mf, 0); + blob_reset(&mf); + /* Save and deltify the file content... */ + frid = content_put_ex(&pCI->fileContent, blob_str(&pCI->fileHash), + 0, 0, isPrivate); + if(zFilePrev!=0){ + assert(prevFRid>0); + content_deltify(frid, &prevFRid, 1, 0); + } + db_end_transaction((CIMINI_DRY_RUN & pCI->flags) ? 1 : 0); + if(pRid!=0){ + *pRid = rid; + } + return 1; +ci_error: + assert(db_transaction_nesting_depth()>0); + db_end_transaction(1); + return 0; +#undef ci_err +} + +/* +** COMMAND: test-ci-mini +** +** This is an on-going experiment, subject to change or removal at +** any time. +** +** Usage: %fossil test-ci-mini ?OPTIONS? FILENAME +** +** where FILENAME is a repo-relative name as it would appear in the +** vfile table. +** +** Options: +** -R|--repository REPO The repository file to commit to +** --as FILENAME The repository-side name of the input +** file, relative to the top of the +** repository. Default is the same as the +** input file name. +** -m|--comment COMMENT Required check-in comment +** -M|--comment-file FILE Reads check-in comment from the given file +** -r|--revision VERSION Commit from this version. Default is +** the check-out version (if available) or +** trunk (if used without a check-out). +** --allow-fork Allows the commit to be made against a +** non-leaf parent. Note that no autosync +** is performed beforehand. +** --allow-merge-conflict Allows check-in of a file even if it +** appears to contain a fossil merge conflict +** marker +** --user-override USER USER to use instead of the current +** default +** --date-override DATETIME DATE to use instead of 'now' +** --allow-older Allow a commit to be older than its +** ancestor +** --convert-eol-inherit Convert EOL style of the check-in to match +** the previous version's content +** --convert-eol-unix Convert the EOL style to Unix +** --convert-eol-windows Convert the EOL style to Windows. +** (Only one of the --convert-eol-X options may be used and they only +** modified the saved blob, not the input file.) +** --delta Prefer to generate a delta manifest, if +** able. The forbid-delta-manifests repo +** config option trumps this, as do certain +** heuristics. +** --allow-new-file Allow addition of a new file this way. +** Disabled by default to avoid that case- +** sensitivity errors inadvertently lead to +** adding a new file where an update is +** intended. +** -d|--dump-manifest Dumps the generated manifest to stdout +** immediately after it's generated +** --save-manifest FILE Saves the generated manifest to a file +** after successfully processing it +** --wet-run Disables the default dry-run mode +** +** Example: +** +** %fossil test-ci-mini -R REPO -m ... -r foo --as src/myfile.c myfile.c +** +*/ +void test_ci_mini_cmd(void){ + CheckinMiniInfo cimi; /* check-in state */ + int newRid = 0; /* RID of new version */ + const char * zFilename; /* argv[2] */ + const char * zComment; /* -m comment */ + const char * zCommentFile; /* -M FILE */ + const char * zAsFilename; /* --as filename */ + const char * zRevision; /* -r|--revision [=trunk|checkout] */ + const char * zUser; /* --user-override */ + const char * zDate; /* --date-override */ + char const * zManifestFile = 0;/* --save-manifest FILE */ + + /* This function should perform only the minimal "business logic" it + ** needs in order to fully/properly populate the CheckinMiniInfo and + ** then pass it on to checkin_mini() to do most of the validation + ** and work. The point of this is to avoid duplicate code when a web + ** front-end is added for checkin_mini(). + */ + CheckinMiniInfo_init(&cimi); + zComment = find_option("comment","m",1); + zCommentFile = find_option("comment-file","M",1); + zAsFilename = find_option("as",0,1); + zRevision = find_option("revision","r",1); + zUser = find_option("user-override",0,1); + zDate = find_option("date-override",0,1); + zManifestFile = find_option("save-manifest",0,1); + if(find_option("wet-run",0,0)==0){ + cimi.flags |= CIMINI_DRY_RUN; + } + if(find_option("allow-fork",0,0)!=0){ + cimi.flags |= CIMINI_ALLOW_FORK; + } + if(find_option("dump-manifest","d",0)!=0){ + cimi.flags |= CIMINI_DUMP_MANIFEST; + } + if(find_option("allow-merge-conflict",0,0)!=0){ + cimi.flags |= CIMINI_ALLOW_MERGE_MARKER; + } + if(find_option("allow-older",0,0)!=0){ + cimi.flags |= CIMINI_ALLOW_OLDER; + } + if(find_option("convert-eol-inherit",0,0)!=0){ + cimi.flags |= CIMINI_CONVERT_EOL_INHERIT; + }else if(find_option("convert-eol-unix",0,0)!=0){ + cimi.flags |= CIMINI_CONVERT_EOL_UNIX; + }else if(find_option("convert-eol-windows",0,0)!=0){ + cimi.flags |= CIMINI_CONVERT_EOL_WINDOWS; + } + if(find_option("delta",0,0)!=0){ + cimi.flags |= CIMINI_PREFER_DELTA; + } + if(find_option("delta2",0,0)!=0){ + /* Undocumented. For testing only. */ + cimi.flags |= CIMINI_PREFER_DELTA | CIMINI_STRONGLY_PREFER_DELTA; + } + if(find_option("allow-new-file",0,0)!=0){ + cimi.flags |= CIMINI_ALLOW_NEW_FILE; + } + db_find_and_open_repository(0, 0); + verify_all_options(); + user_select(); + if(g.argc!=3){ + usage("INFILE"); + } + if(zComment && zCommentFile){ + fossil_fatal("Only one of -m or -M, not both, may be used."); + }else{ + if(zCommentFile && *zCommentFile){ + blob_read_from_file(&cimi.comment, zCommentFile, ExtFILE); + }else if(zComment && *zComment){ + blob_append(&cimi.comment, zComment, -1); + } + if(!blob_size(&cimi.comment)){ + fossil_fatal("Non-empty check-in comment is required."); + } + } + db_begin_transaction(); + zFilename = g.argv[2]; + cimi.zFilename = mprintf("%/", zAsFilename ? zAsFilename : zFilename); + cimi.filePerm = file_perm(zFilename, ExtFILE); + cimi.zUser = fossil_strdup(zUser ? zUser : login_name()); + if(zDate){ + cimi.zDate = fossil_strdup(zDate); + } + if(zRevision==0 || zRevision[0]==0){ + if(g.localOpen/*check-out*/){ + zRevision = db_lget("checkout-hash", 0)/*leak*/; + }else{ + zRevision = db_main_branch(); + } + } + name_to_uuid2(zRevision, "ci", &cimi.zParentUuid); + if(cimi.zParentUuid==0){ + fossil_fatal("Cannot determine version to commit to."); + } + blob_read_from_file(&cimi.fileContent, zFilename, ExtFILE); + { + Blob theManifest = empty_blob; /* --save-manifest target */ + Blob errMsg = empty_blob; + int rc; + if(zManifestFile){ + cimi.pMfOut = &theManifest; + } + rc = checkin_mini(&cimi, &newRid, &errMsg); + if(rc){ + assert(blob_size(&errMsg)==0); + }else{ + assert(blob_size(&errMsg)); + fossil_fatal("%b", &errMsg); + } + if(zManifestFile){ + fossil_print("Writing manifest to: %s\n", zManifestFile); + assert(blob_size(&theManifest)>0); + blob_write_to_file(&theManifest, zManifestFile); + blob_reset(&theManifest); + } + } + if(newRid!=0){ + fossil_print("New version%s: %z\n", + (cimi.flags & CIMINI_DRY_RUN) ? " (dry run)" : "", + rid_to_uuid(newRid)); + } + db_end_transaction(0/*checkin_mini() will have triggered it to roll + ** back in dry-run mode, but we need access to + ** the transaction-written db state in this + ** routine.*/); + if(!(cimi.flags & CIMINI_DRY_RUN) && newRid!=0 && g.localOpen!=0){ + fossil_warning("The check-out state is now out of sync " + "with regards to this commit. It needs to be " + "'update'd or 'close'd and re-'open'ed."); + } + CheckinMiniInfo_cleanup(&cimi); +} + +/* +** If the fileedit-glob setting has a value, this returns its Glob +** object (in memory owned by this function), else it returns NULL. +*/ +Glob *fileedit_glob(void){ + static Glob * pGlobs = 0; + static int once = 0; + if(0==pGlobs && once==0){ + char * zGlobs = db_get("fileedit-glob",0); + once = 1; + if(0!=zGlobs && 0!=*zGlobs){ + pGlobs = glob_create(zGlobs); + } + fossil_free(zGlobs); + } + return pGlobs; +} + +/* +** Returns true if the given filename qualifies for online editing by +** the current user, else returns false. +** +** Editing requires that the user have the Write permission and that +** the filename match the glob defined by the fileedit-glob setting. +** A missing or empty value for that glob disables all editing. +*/ +int fileedit_is_editable(const char *zFilename){ + Glob * pGlobs = fileedit_glob(); + if(pGlobs!=0 && zFilename!=0 && *zFilename!=0 && 0!=g.perm.Write){ + return glob_match(pGlobs, zFilename); + }else{ + return 0; + } +} + +/* +** Given a repo-relative filename and a manifest RID, returns the UUID +** of the corresponding file entry. Returns NULL if no match is +** found. If pFilePerm is not NULL, the file's permission flag value +** is written to *pFilePerm. +*/ +static char *fileedit_file_uuid(char const *zFilename, + int vid, int *pFilePerm){ + Stmt stmt = empty_Stmt; + char * zFileUuid = 0; + db_prepare(&stmt, "SELECT uuid, perm FROM files_of_checkin " + "WHERE filename=%Q %s AND checkinID=%d", + zFilename, filename_collation(), vid); + if(SQLITE_ROW==db_step(&stmt)){ + zFileUuid = fossil_strdup(db_column_text(&stmt, 0)); + if(pFilePerm){ + *pFilePerm = mfile_permstr_int(db_column_text(&stmt, 1)); + } + } + db_finalize(&stmt); + return zFileUuid; +} + +/* +** Returns true if the current user is allowed to edit the given +** filename, as determined by fileedit_is_editable(), else false, +** in which case it queues up an error response and the caller +** must return immediately. +*/ +static int fileedit_ajax_check_filename(const char * zFilename){ + if(0==fileedit_is_editable(zFilename)){ + ajax_route_error(403, "File is disallowed by the " + "fileedit-glob setting."); + return 0; + } + return 1; +} + +/* +** Passed the values of the "checkin" and "filename" request +** properties, this function verifies that they are valid and +** populates: +** +** - *zRevUuid = the fully-expanded value of zRev (owned by the +** caller). zRevUuid may be NULL. +** +** - *pVid = the RID of zRevUuid. pVid May be NULL. If the vid +** cannot be resolved or is ambiguous, pVid is not assigned. +** +** - *frid = the RID of zFilename's blob content. May not be NULL +** unless zFilename is also NULL. If BOTH of zFilename and frid are +** NULL then no confirmation is done on the filename argument - only +** zRev is checked. +** +** Returns 0 if the given file is not in the given check-in or if +** fileedit_ajax_check_filename() fails, else returns true. If it +** returns false, it queues up an error response and the caller must +** return immediately. +*/ +static int fileedit_ajax_setup_filerev(const char * zRev, + char ** zRevUuid, + int * pVid, + const char * zFilename, + int * frid){ + char * zFileUuid = 0; /* file content UUID */ + const int checkFile = zFilename!=0 || frid!=0; + int vid = 0; + + if(checkFile && !fileedit_ajax_check_filename(zFilename)){ + return 0; + } + vid = symbolic_name_to_rid(zRev, "ci"); + if(0==vid){ + ajax_route_error(404,"Cannot resolve name as a check-in: %s", + zRev); + return 0; + }else if(vid<0){ + ajax_route_error(400,"Check-in name is ambiguous: %s", + zRev); + return 0; + }else if(pVid!=0){ + *pVid = vid; + } + if(checkFile){ + zFileUuid = fileedit_file_uuid(zFilename, vid, 0); + if(zFileUuid==0){ + ajax_route_error(404, "Check-in does not contain file."); + return 0; + } + } + if(zRevUuid!=0){ + *zRevUuid = rid_to_uuid(vid); + } + if(checkFile){ + assert(zFileUuid!=0); + if(frid!=0){ + *frid = fast_uuid_to_rid(zFileUuid); + } + fossil_free(zFileUuid); + } + return 1; +} + +/* +** AJAX route /fileedit?ajax=content +** +** Query parameters: +** +** filename=FILENAME +** checkin=CHECKIN_NAME +** +** User must have Write access to use this page. +** +** Responds with the raw content of the given page. On error it +** produces a JSON response as documented for ajax_route_error(). +** +** Extra response headers: +** +** x-fileedit-file-perm: empty or "x" or "l", representing PERM_REG, +** PERM_EXE, or PERM_LINK, respectively. +** +** x-fileedit-checkin-branch: branch name for the passed-in check-in. +*/ +static void fileedit_ajax_content(void){ + const char * zFilename = 0; + const char * zRev = 0; + int vid, frid; + Blob content = empty_blob; + const char * zMime; + + ajax_get_fnci_args( &zFilename, &zRev ); + if(!ajax_route_bootstrap(1,0) + || !fileedit_ajax_setup_filerev(zRev, 0, &vid, + zFilename, &frid)){ + return; + } + zMime = mimetype_from_name(zFilename); + content_get(frid, &content); + if(0==zMime){ + if(looks_like_binary(&content)){ + zMime = "application/octet-stream"; + }else{ + zMime = "text/plain"; + } + } + { /* Send the is-exec bit via response header so that the UI can be + ** updated to account for that. */ + int fperm = 0; + char * zFuuid = fileedit_file_uuid(zFilename, vid, &fperm); + const char * zPerm = mfile_permint_mstring(fperm); + assert(zFuuid); + cgi_printf_header("x-fileedit-file-perm:%s\r\n", zPerm); + fossil_free(zFuuid); + } + { /* Send branch name via response header for UI usability reasons */ + char * zBranch = branch_of_rid(vid); + if(zBranch!=0 && zBranch[0]!=0){ + cgi_printf_header("x-fileedit-checkin-branch: %s\r\n", zBranch); + } + fossil_free(zBranch); + } + cgi_set_content_type(zMime); + cgi_set_content(&content); +} + +/* +** AJAX route /fileedit?ajax=diff +** +** Required query parameters: +** +** filename=FILENAME +** content=text +** checkin=check-in version +** +** Optional parameters: +** +** sbs=integer (1=side-by-side or 0=unified, default=0) +** +** ws=integer (0=diff whitespace, 1=ignore EOL ws, 2=ignore all ws) +** +** Reminder to self: search info.c for isPatch to see how a +** patch-style siff can be produced. +** +** User must have Write access to use this page. +** +** Responds with the HTML content of the diff. On error it produces a +** JSON response as documented for ajax_route_error(). +*/ +static void fileedit_ajax_diff(void){ + /* + ** Reminder: we only need the filename to perform valdiation + ** against fileedit_is_editable(), else this route could be + ** abused to get diffs against content disallowed by the + ** whitelist. + */ + const char * zFilename = 0; + const char * zRev = 0; + const char * zContent = P("content"); + char * zRevUuid = 0; + int vid, frid, iFlag; + u64 diffFlags = DIFF_HTML | DIFF_NOTTOOBIG; + Blob content = empty_blob; + + iFlag = atoi(PD("sbs","0")); + if(0==iFlag){ + diffFlags |= DIFF_LINENO; + }else{ + diffFlags |= DIFF_SIDEBYSIDE; + } + iFlag = atoi(PD("ws","2")); + if(2==iFlag){ + diffFlags |= DIFF_IGNORE_ALLWS; + }else if(1==iFlag){ + diffFlags |= DIFF_IGNORE_EOLWS; + } + diffFlags |= DIFF_STRIP_EOLCR; + ajax_get_fnci_args( &zFilename, &zRev ); + if(!ajax_route_bootstrap(1,1) + || !fileedit_ajax_setup_filerev(zRev, &zRevUuid, &vid, + zFilename, &frid)){ + return; + } + if(!zContent){ + zContent = ""; + } + cgi_set_content_type("text/html"); + blob_init(&content, zContent, -1); + { + Blob orig = empty_blob; + char * const zOrigUuid = rid_to_uuid(frid); + content_get(frid, &orig); + ajax_render_diff(&orig, zOrigUuid, &content, diffFlags); + fossil_free(zOrigUuid); + blob_reset(&orig); + } + fossil_free(zRevUuid); + blob_reset(&content); +} + +/* +** Sets up and validates most, but not all, of p's check-in-related +** state from the CGI environment. Returns 0 on success or a suggested +** HTTP result code on error, in which case a message will have been +** written to pErr. +** +** It always fails if it cannot completely resolve the 'file' and 'r' +** parameters, including verifying that the refer to a real +** file/version combination and editable by the current user. All +** others are optional (at this level, anyway, but upstream code might +** require them). +** +** If the 3rd argument is not NULL and an error is related to a +** missing arg then *bIsMissingArg is set to true. This is +** intended to allow /fileedit to squelch certain initialization +** errors. +** +** Intended to be used only by /filepage and /filepage_commit. +*/ +static int fileedit_setup_cimi_from_p(CheckinMiniInfo * p, Blob * pErr, + int * bIsMissingArg){ + char * zFileUuid = 0; /* UUID of file content */ + const char * zFlag; /* generic flag */ + int rc = 0, vid = 0, frid = 0; /* result code, check-in/file rids */ + +#define fail(EXPR) blob_appendf EXPR; goto end_fail + zFlag = PD("filename",P("fn")); + if(zFlag==0 || !*zFlag){ + rc = 400; + if(bIsMissingArg){ + *bIsMissingArg = 1; + } + fail((pErr,"Missing required 'filename' parameter.")); + } + p->zFilename = fossil_strdup(zFlag); + + if(0==fileedit_is_editable(p->zFilename)){ + rc = 403; + fail((pErr,"Filename [%h] is disallowed " + "by the [fileedit-glob] repository " + "setting.", + p->zFilename)); + } + + zFlag = PD("checkin",P("ci")); + if(!zFlag){ + rc = 400; + if(bIsMissingArg){ + *bIsMissingArg = 1; + } + fail((pErr,"Missing required 'checkin' parameter.")); + } + vid = symbolic_name_to_rid(zFlag, "ci"); + if(0==vid){ + rc = 404; + fail((pErr,"Could not resolve check-in version.")); + }else if(vid<0){ + rc = 400; + fail((pErr,"Check-in name is ambiguous.")); + } + p->zParentUuid = rid_to_uuid(vid)/*fully expand it*/; + + zFileUuid = fileedit_file_uuid(p->zFilename, vid, &p->filePerm); + if(!zFileUuid){ + rc = 404; + fail((pErr,"Check-in [%S] does not contain file: " + "[%h]", p->zParentUuid, p->zFilename)); + }else if(PERM_LNK==p->filePerm){ + rc = 400; + fail((pErr,"Editing symlinks is not permitted.")); + } + + /* Find the repo-side file entry or fail... */ + frid = fast_uuid_to_rid(zFileUuid); + assert(frid); + + /* Read file content from submit request or repo... */ + zFlag = P("content"); + if(zFlag==0){ + content_get(frid, &p->fileContent); + }else{ + blob_init(&p->fileContent,zFlag,-1); + } + if(looks_like_binary(&p->fileContent)){ + rc = 400; + fail((pErr,"File appears to be binary. Cannot edit: " + "[%h]",p->zFilename)); + } + + zFlag = PT("comment"); + if(zFlag!=0 && *zFlag!=0){ + blob_append(&p->comment, zFlag, -1); + } + zFlag = P("comment_mimetype"); + if(zFlag){ + p->zCommentMimetype = fossil_strdup(zFlag); + zFlag = 0; + } +#define p_int(K) atoi(PD(K,"0")) + if(p_int("dry_run")!=0){ + p->flags |= CIMINI_DRY_RUN; + } + if(p_int("allow_fork")!=0){ + p->flags |= CIMINI_ALLOW_FORK; + } + if(p_int("allow_older")!=0){ + p->flags |= CIMINI_ALLOW_OLDER; + } + if(0==p_int("exec_bit")){ + p->filePerm = PERM_REG; + }else{ + p->filePerm = PERM_EXE; + } + if(p_int("allow_merge_conflict")!=0){ + p->flags |= CIMINI_ALLOW_MERGE_MARKER; + } + if(p_int("prefer_delta")!=0){ + p->flags |= CIMINI_PREFER_DELTA; + } + + /* EOL conversion policy... */ + switch(p_int("eol")){ + case 1: p->flags |= CIMINI_CONVERT_EOL_UNIX; break; + case 2: p->flags |= CIMINI_CONVERT_EOL_WINDOWS; break; + default: p->flags |= CIMINI_CONVERT_EOL_INHERIT; break; + } +#undef p_int + /* + ** TODO?: date-override date selection field. Maybe use + ** an input[type=datetime-local]. + */ + p->zUser = fossil_strdup(g.zLogin); + return 0; +end_fail: +#undef fail + fossil_free(zFileUuid); + return rc ? rc : 500; +} + +/* +** Renders a list of all open leaves in JSON form: +** +** [ +** {checkin: UUID, branch: branchName, timestamp: string} +** ] +** +** The entries are ordered newest first. +** +** If zFirstUuid is not NULL then *zFirstUuid is set to a copy of the +** full UUID of the first (most recent) leaf, which must be freed by +** the caller. It is set to 0 if there are no leaves. +*/ +static void fileedit_render_leaves_list(char ** zFirstUuid){ + Blob sql = empty_blob; + Stmt q = empty_Stmt; + int i = 0; + + if(zFirstUuid){ + *zFirstUuid = 0; + } + blob_append(&sql, timeline_query_for_tty(), -1); + blob_append_sql(&sql, " AND blob.rid IN (SElECT rid FROM leaf " + "WHERE NOT EXISTS(" + "SELECT 1 from tagxref WHERE tagid=%d AND " + "tagtype>0 AND rid=leaf.rid" + ")) " + "ORDER BY mtime DESC", TAG_CLOSED); + db_prepare_blob(&q, &sql); + CX("["); + while( SQLITE_ROW==db_step(&q) ){ + const char * zUuid = db_column_text(&q, 1); + if(i++){ + CX(","); + }else if(zFirstUuid){ + *zFirstUuid = fossil_strdup(zUuid); + } + CX("{"); + CX("\"checkin\":%!j,", zUuid); + CX("\"branch\":%!j,", db_column_text(&q, 7)); + CX("\"timestamp\":%!j", db_column_text(&q, 2)); + CX("}"); + } + CX("]"); + db_finalize(&q); +} + +/* +** For the given fully resolved UUID, renders a JSON object containing +** the fileeedit-editable files in that check-in: +** +** { +** checkin: UUID, +** editableFiles: [ filename1, ... filenameN ] +** } +** +** They are sorted by name using filename_collation(). +*/ +static void fileedit_render_checkin_files(const char * zFullUuid){ + Blob sql = empty_blob; + Stmt q = empty_Stmt; + int i = 0; + + CX("{\"checkin\":%!j," + "\"editableFiles\":[", zFullUuid); + blob_append_sql(&sql, "SELECT filename FROM files_of_checkin(%Q) " + "ORDER BY filename %s", + zFullUuid, filename_collation()); + db_prepare_blob(&q, &sql); + while( SQLITE_ROW==db_step(&q) ){ + const char * zFilename = db_column_text(&q, 0); + if(fileedit_is_editable(zFilename)){ + if(i++){ + CX(","); + } + CX("%!j", zFilename); + } + } + db_finalize(&q); + CX("]}"); +} + +/* +** AJAX route /fileedit?ajax=filelist +** +** Fetches a JSON-format list of leaves and/or filenames for use in +** creating a file selection list in /fileedit. It has different modes +** of operation depending on its arguments: +** +** 'leaves': just fetch a list of open leaf versions, in this +** format: +** +** [ +** {checkin: UUID, branch: branchName, timestamp: string} +** ] +** +** The entries are ordered newest first. +** +** 'checkin=CHECKIN_NAME': fetch the current list of is-editable files +** for the current user and given check-in name: +** +** { +** checkin: UUID, +** editableFiles: [ filename1, ... filenameN ] // sorted by name +** } +** +** On error it produces a JSON response as documented for +** ajax_route_error(). +*/ +static void fileedit_ajax_filelist(){ + const char * zCi = PD("checkin",P("ci")); + + if(!ajax_route_bootstrap(1,0)){ + return; + } + cgi_set_content_type("application/json"); + if(zCi!=0){ + char * zCiFull = 0; + if(0==fileedit_ajax_setup_filerev(zCi, &zCiFull, 0, 0, 0)){ + /* Error already reported */ + return; + } + fileedit_render_checkin_files(zCiFull); + fossil_free(zCiFull); + }else if(P("leaves")!=0){ + fileedit_render_leaves_list(0); + }else{ + ajax_route_error(500, "Unhandled URL argument."); + } +} + +/* +** AJAX route /fileedit?ajax=commit +** +** Required query parameters: +** +** filename=FILENAME +** checkin=Parent check-in UUID +** content=text +** comment=non-empty text +** +** Optional query parameters: +** +** comment_mimetype=text (NOT currently honored) +** +** dry_run=int (1 or 0) +** +** include_manifest=int (1 or 0), whether to include +** the generated manifest in the response. +** +** +** User must have Write permissions to use this page. +** +** Responds with JSON (with some state repeated +** from the input in order to avoid certain race conditions +** client-side): +** +** { +** checkin: newUUID, +** filename: theFilename, +** mimetype: string, +** branch: name of the check-in's branch, +** isExe: bool, +** dryRun: bool, +** manifest: text of manifest, +** } +** +** On error it produces a JSON response as documented for +** ajax_route_error(). +*/ +static void fileedit_ajax_commit(void){ + Blob err = empty_blob; /* Error messages */ + Blob manifest = empty_blob; /* raw new manifest */ + CheckinMiniInfo cimi; /* check-in state */ + int rc; /* generic result code */ + int newVid = 0; /* new version's RID */ + char * zNewUuid = 0; /* newVid's UUID */ + char const * zMimetype; + char * zBranch = 0; + + if(!ajax_route_bootstrap(1,1)){ + return; + } + db_begin_transaction(); + CheckinMiniInfo_init(&cimi); + rc = fileedit_setup_cimi_from_p(&cimi, &err, 0); + if(0!=rc){ + ajax_route_error(rc,"%b",&err); + goto end_cleanup; + } + if(blob_size(&cimi.comment)==0){ + ajax_route_error(400,"Empty check-in comment is not permitted."); + goto end_cleanup; + } + if(0!=atoi(PD("include_manifest","0"))){ + cimi.pMfOut = &manifest; + } + checkin_mini(&cimi, &newVid, &err); + if(blob_size(&err)){ + ajax_route_error(500,"%b",&err); + goto end_cleanup; + } + assert(newVid>0); + zNewUuid = rid_to_uuid(newVid); + cgi_set_content_type("application/json"); + CX("{"); + CX("\"checkin\":%!j,", zNewUuid); + CX("\"filename\":%!j,", cimi.zFilename); + CX("\"isExe\": %s,", cimi.filePerm==PERM_EXE ? "true" : "false"); + zMimetype = mimetype_from_name(cimi.zFilename); + if(zMimetype!=0){ + CX("\"mimetype\": %!j,", zMimetype); + } + zBranch = branch_of_rid(newVid); + if(zBranch!=0){ + CX("\"branch\": %!j,", zBranch); + fossil_free(zBranch); + } + CX("\"dryRun\": %s", + (CIMINI_DRY_RUN & cimi.flags) ? "true" : "false"); + if(blob_size(&manifest)>0){ + CX(",\"manifest\": %!j", blob_str(&manifest)); + } + CX("}"); +end_cleanup: + db_end_transaction(0/*noting that dry-run mode will have already + ** set this to rollback mode. */); + fossil_free(zNewUuid); + blob_reset(&err); + blob_reset(&manifest); + CheckinMiniInfo_cleanup(&cimi); +} + +/* +** WEBPAGE: fileedit +** +** Enables the online editing and committing of text files. Requires +** that the user have Write permissions and that a user with setup +** permissions has set the fileedit-glob setting to a list of glob +** patterns matching files which may be edited (e.g. "*.wiki,*.md"). +** Note that fileedit-glob, by design, is a local-only setting. +** It does not sync across repository clones, and must be explicitly +** set on any repositories where this page should be activated. +** +** Optional query parameters: +** +** filename=FILENAME Repo-relative path to the file. +** checkin=VERSION Check-in version, using any unambiguous +** symbolic version name. +** +** If passed a filename but no check-in then it will attempt to +** load that file from the most recent leaf check-in. +** +** Once the page is loaded, files may be selected from any open leaf +** version. The only way to edit files from non-leaf checkins is to +** pass both the filename and check-in as URL parameters to the page. +** Users with the proper permissions will be presented with "Edit" +** links in various file-specific contexts for files which match the +** fileedit-glob, regardless of whether they refer to leaf versions or +** not. +*/ +void fileedit_page(void){ + const char * zFileMime = 0; /* File mime type guess */ + CheckinMiniInfo cimi; /* Check-in state */ + int previewRenderMode = AJAX_RENDER_GUESS; /* preview mode */ + Blob err = empty_blob; /* Error report */ + const char *zAjax = P("name"); /* Name of AJAX route for + sub-dispatching. */ + + /* + ** Internal-use URL parameters: + ** + ** name=string The name of a page-specific AJAX operation. + ** + ** Noting that fossil internally stores all URL path components + ** after the first as the "name" value. Thus /fileedit?name=blah is + ** equivalent to /fileedit/blah. The latter is the preferred + ** form. This means, however, that no fileedit ajax routes may make + ** use of the name parameter. + ** + ** Which additional parameters are used by each distinct ajax route + ** is an internal implementation detail and may change with any + ** given build of this code. An unknown "name" value triggers an + ** error, as documented for ajax_route_error(). + */ + + /* Allow no access to this page without check-in privilege */ + login_check_credentials(); + if( !g.perm.Write ){ + if(zAjax!=0){ + ajax_route_error(403, "Write permissions required."); + }else{ + login_needed(g.anon.Write); + } + return; + } + /* No access to anything on this page if the fileedit-glob is empty */ + if( fileedit_glob()==0 ){ + if(zAjax!=0){ + ajax_route_error(403, "Online editing is disabled for this " + "repository."); + return; + } + style_header("File Editor (disabled)"); + CX("<h1>Online File Editing Is Disabled</h1>\n"); + if( g.perm.Admin ){ + CX("<p>To enable online editing, the " + "<a href='%R/setup_settings'>" + "<code>fileedit-glob</code> repository setting</a>\n" + "must be set to a comma- and/or newine-delimited list of glob\n" + "values matching files which may be edited online." + "</p>\n"); + }else{ + CX("<p>Online editing is disabled for this repository.</p>\n"); + } + style_finish_page(); + return; + } + + /* Dispatch AJAX methods based tail of the request URI. + ** The AJAX parts do their own permissions/CSRF check and + ** fail with a JSON-format response if needed. + */ + if( 0!=zAjax ){ + /* preview mode is handled via /ajax/preview-text */ + if(0==strcmp("content",zAjax)){ + fileedit_ajax_content(); + }else if(0==strcmp("filelist",zAjax)){ + fileedit_ajax_filelist(); + }else if(0==strcmp("diff",zAjax)){ + fileedit_ajax_diff(); + }else if(0==strcmp("commit",zAjax)){ + fileedit_ajax_commit(); + }else{ + ajax_route_error(500, "Unhandled ajax route name."); + } + return; + } + + db_begin_transaction(); + CheckinMiniInfo_init(&cimi); + style_header("File Editor"); + style_emit_noscript_for_js_page(); + /* As of this point, don't use return or fossil_fatal(). Write any + ** error in (&err) and goto end_footer instead so that we can be + ** sure to emit the error message, do any cleanup, and end the + ** transaction cleanly. + */ + { + int isMissingArg = 0; + if(fileedit_setup_cimi_from_p(&cimi, &err, &isMissingArg)==0){ + assert(cimi.zFilename); + zFileMime = mimetype_from_name(cimi.zFilename); + }else if(isMissingArg!=0){ + /* Squelch these startup warnings - they're non-fatal now but + ** used to be fatal. */ + blob_reset(&err); + } + } + + /******************************************************************** + ** All errors which "could" have happened up to this point are of a + ** degree which keep us from rendering the rest of the page, and + ** thus have already caused us to skipped to the end of the page to + ** render the errors. Any up-coming errors, barring malloc failure + ** or similar, are not "that" fatal. We can/should continue + ** rendering the page, then output the error message at the end. + ********************************************************************/ + + /* The CSS for this page lives in a common file but much of it we + ** don't want inadvertently being used by other pages. We don't + ** have a common, page-specific container we can filter our CSS + ** selectors, but we do have the BODY, which we can decorate with + ** whatever CSS we wish... + */ + style_script_begin(__FILE__,__LINE__); + CX("document.body.classList.add('fileedit');\n"); + style_script_end(); + + /* Status bar */ + CX("<div id='fossil-status-bar' " + "title='Status message area. Double-click to clear them.'>" + "Status messages will go here.</div>\n" + /* will be moved into the tab container via JS */); + + CX("<div id='fileedit-edit-status'>" + "<span class='name'>(no file loaded)</span>" + "<span class='links'></span>" + "</div>"); + + /* Main tab container... */ + CX("<div id='fileedit-tabs' class='tab-container'></div>"); + + /* The .hidden class on the following tab elements is to help lessen + the FOUC effect of the tabs before JS re-assembles them. */ + + /***** File/version info tab *****/ + { + CX("<div id='fileedit-tab-fileselect' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='File Selection' " + "class='hidden'" + ">"); + CX("<div id='fileedit-file-selector'></div>"); + CX("</div>"/*#fileedit-tab-fileselect*/); + } + + /******* Content tab *******/ + { + CX("<div id='fileedit-tab-content' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='File Content' " + "class='hidden'" + ">"); + CX("<div class='fileedit-options flex-container " + "flex-row child-gap-small'>"); + CX("<div class='input-with-label'>" + "<button class='fileedit-content-reload confirmer' " + ">Discard & Reload</button>" + "<div class='help-buttonlet'>" + "Reload the file from the server, discarding " + "any local edits. To help avoid accidental loss of " + "edits, it requires confirmation (a second click) within " + "a few seconds or it will not reload." + "</div>" + "</div>"); + style_select_list_int("select-font-size", + "editor_font_size", "Editor font size", + NULL/*tooltip*/, + 100, + "100%", 100, "125%", 125, + "150%", 150, "175%", 175, + "200%", 200, NULL); + wikiedit_emit_toggle_preview(); + CX("</div>"); + CX("<div class='flex-container flex-column stretch'>"); + CX("<textarea name='content' id='fileedit-content-editor' " + "class='fileedit' rows='25'>"); + CX("</textarea>"); + CX("</div>"/*textarea wrapper*/); + CX("</div>"/*#tab-file-content*/); + } + + /****** Preview tab ******/ + { + CX("<div id='fileedit-tab-preview' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='Preview' " + "class='hidden'" + ">"); + CX("<div class='fileedit-options flex-container flex-row'>"); + CX("<button id='btn-preview-refresh' " + "data-f-preview-from='fileContent' " + /* ^^^ fossil.page[methodName]() OR text source elem ID, + ** but we need a method in order to support clients swapping out + ** the text editor with their own. */ + "data-f-preview-via='_postPreview' " + /* ^^^ fossil.page[methodName](content, callback) */ + "data-f-preview-to='_previewTo' " + /* ^^^ dest elem ID */ + ">Refresh</button>"); + /* Toggle auto-update of preview when the Preview tab is selected. */ + CX("<div class='input-with-label'>" + "<input type='checkbox' value='1' " + "id='cb-preview-autorefresh' checked>" + "<label for='cb-preview-autorefresh'>Auto-refresh?</label>" + "<div class='help-buttonlet'>" + "If on, the preview will automatically " + "refresh (if needed) when this tab is selected." + "</div>" + "</div>"); + + /* Default preview rendering mode selection... */ + previewRenderMode = zFileMime + ? ajax_render_mode_for_mimetype(zFileMime) + : AJAX_RENDER_GUESS; + style_select_list_int("select-preview-mode", + "preview_render_mode", + "Preview Mode", + "Preview mode format.", + previewRenderMode, + "Guess", AJAX_RENDER_GUESS, + "Wiki/Markdown", AJAX_RENDER_WIKI, + "HTML (iframe)", AJAX_RENDER_HTML_IFRAME, + "HTML (inline)", AJAX_RENDER_HTML_INLINE, + "Plain Text", AJAX_RENDER_PLAIN_TEXT, + NULL); + /* Allow selection of HTML preview iframe height */ + style_select_list_int("select-preview-html-ems", + "preview_html_ems", + "HTML Preview IFrame Height (EMs)", + "Height (in EMs) of the iframe used for " + "HTML preview", + 40 /*default*/, + "", 20, "", 40, + "", 60, "", 80, + "", 100, NULL); + /* Selection of line numbers for text preview */ + style_labeled_checkbox("cb-line-numbers", + "preview_ln", + "Add line numbers to plain-text previews?", + "1", P("preview_ln")!=0, + "If on, plain-text files (only) will get " + "line numbers added to the preview."); + CX("</div>"/*.fileedit-options*/); + CX("<div id='fileedit-tab-preview-wrapper'></div>"); + CX("</div>"/*#fileedit-tab-preview*/); + } + + /****** Diff tab ******/ + { + CX("<div id='fileedit-tab-diff' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='Diff' " + "class='hidden'" + ">"); + + CX("<div class='fileedit-options flex-container " + "flex-row child-gap-small' " + "id='fileedit-tab-diff-buttons'>"); + CX("<button class='sbs'>Side-by-side</button>" + "<button class='unified'>Unified</button>"); + if(0){ + /* For the time being let's just ignore all whitespace + ** changes, as files with Windows-style EOLs always show + ** more diffs than we want then they're submitted to + ** ?ajax=diff because JS normalizes them to Unix EOLs. + ** We can revisit this decision later. */ + style_select_list_int("diff-ws-policy", + "diff_ws", "Whitespace", + "Whitespace handling policy.", + 2, + "Diff all whitespace", 0, + "Ignore EOL whitespace", 1, + "Ignore all whitespace", 2, + NULL); + } + CX("</div>"); + CX("<div id='fileedit-tab-diff-wrapper'>" + "Diffs will be shown here." + "</div>"); + CX("</div>"/*#fileedit-tab-diff*/); + } + + /****** Commit ******/ + CX("<div id='fileedit-tab-commit' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='Commit' " + "class='hidden'" + ">"); + { + /******* Commit flags/options *******/ + CX("<div class='fileedit-options flex-container flex-row'>"); + style_labeled_checkbox("cb-dry-run", + "dry_run", "Dry-run?", "1", + 0, + "In dry-run mode, the Commit button performs " + "all work needed for committing changes but " + "then rolls back the transaction, and thus " + "does not really commit."); + style_labeled_checkbox("cb-allow-fork", + "allow_fork", "Allow fork?", "1", + cimi.flags & CIMINI_ALLOW_FORK, + "Allow committing to create a fork?"); + style_labeled_checkbox("cb-allow-older", + "allow_older", "Allow older?", "1", + cimi.flags & CIMINI_ALLOW_OLDER, + "Allow saving against a parent version " + "which has a newer timestamp?"); + style_labeled_checkbox("cb-exec-bit", + "exec_bit", "Executable?", "1", + PERM_EXE==cimi.filePerm, + "Set the executable bit?"); + style_labeled_checkbox("cb-allow-merge-conflict", + "allow_merge_conflict", + "Allow merge conflict markers?", "1", + cimi.flags & CIMINI_ALLOW_MERGE_MARKER, + "Allow saving even if the content contains " + "what appear to be fossil merge conflict " + "markers?"); + style_labeled_checkbox("cb-prefer-delta", + "prefer_delta", + "Prefer delta manifest?", "1", + db_get_boolean("forbid-delta-manifests",0) + ? 0 + : (db_get_boolean("seen-delta-manifest",0) + || cimi.flags & CIMINI_PREFER_DELTA), + "Will create a delta manifest, instead of " + "baseline, if conditions are favorable to " + "do so. This option is only a suggestion."); + style_labeled_checkbox("cb-include-manifest", + "include_manifest", + "Response manifest?", "1", + 0, + "Include the manifest in the response? " + "It's generally only useful for debug " + "purposes."); + style_select_list_int("select-eol-style", + "eol", "EOL Style", + "EOL conversion policy, noting that " + "webpage-side processing may implicitly change " + "the line endings of the input.", + (cimi.flags & CIMINI_CONVERT_EOL_UNIX) + ? 1 : (cimi.flags & CIMINI_CONVERT_EOL_WINDOWS + ? 2 : 0), + "Inherit", 0, + "Unix", 1, + "Windows", 2, + NULL); + + CX("</div>"/*checkboxes*/); + } + + { /******* Commit comment, button, and result manifest *******/ + CX("<fieldset class='fileedit-options commit-message'>" + "<legend>Message (required)</legend><div>\n"); + /* We have two comment input fields, defaulting to single-line + ** mode. JS code sets up the ability to toggle between single- + ** and multi-line modes. */ + CX("<input type='text' name='comment' " + "id='fileedit-comment'></input>"); + CX("<textarea name='commentBig' class='hidden' " + "rows='5' id='fileedit-comment-big'></textarea>\n"); + { /* comment options... */ + CX("<div class='flex-container flex-column child-gap-small'>"); + CX("<button id='comment-toggle' " + "title='Toggle between single- and multi-line comment mode, " + "noting that switching from multi- to single-line will cause " + "newlines to get stripped.'" + ">Toggle single-/multi-line</button> "); + if(0){ + /* Manifests support an N-card (comment mime type) but it has + ** yet to be honored where comments are rendered, so we don't + ** currently offer it as an option here: + ** https://fossil-scm.org/forum/forumpost/662da045a1 + ** + ** If/when it's ever implemented, simply enable this block and + ** adjust the container's layout accordingly (as of this + ** writing, that means changing the CSS class from + ** 'flex-container flex-column' to 'flex-container flex-row'). + */ + style_select_list_str("comment-mimetype", "comment_mimetype", + "Comment style:", + "Specify how fossil will interpret the " + "comment string.", + NULL, + "Fossil", "text/x-fossil-wiki", + "Markdown", "text/x-markdown", + "Plain text", "text/plain", + NULL); + CX("</div>\n"); + } + CX("<div class='fileedit-hint flex-container flex-row'>" + "(Warning: switching from multi- to single-line mode will " + "strip out all newlines!)</div>"); + } + CX("</div></fieldset>\n"/*commit comment options*/); + CX("<div class='flex-container flex-column' " + "id='fileedit-commit-button-wrapper'>" + "<button id='fileedit-btn-commit'>Commit</button>" + "</div>\n"); + CX("<div id='fileedit-manifest'></div>\n" + /* Manifest gets rendered here after a commit. */); + } + CX("</div>"/*#fileedit-tab-commit*/); + + /****** Help/Tips ******/ + CX("<div id='fileedit-tab-help' " + "data-tab-parent='fileedit-tabs' " + "data-tab-label='Help' " + "class='hidden'" + ">"); + { + CX("<h1>Help & Tips</h1>"); + CX("<ul>"); + CX("<li><strong>Only files matching the <code>fileedit-glob</code> " + "repository setting</strong> can be edited online. That setting " + "must be a comma- or newline-delimited list of glob patterns " + "for files which may be edited online.</li>"); + CX("<li>Committing edits creates a new commit record with a single " + "modified file.</li>"); + CX("<li>\"Delta manifests\" (see the checkbox on the Commit tab) " + "make for smaller commit records, especially in repositories " + "with many files.</li>"); + CX("<li>The file selector allows, for usability's sake, only files " + "in leaf check-ins to be selected, but files may be edited via " + "non-leaf check-ins by passing them as the <code>filename</code> " + "and <code>checkin</code> URL arguments to this page.</li>"); + CX("<li>The editor stores some number of local edits in one of " + "<code>window.fileStorage</code> or " + "<code>window.sessionStorage</code>, if able, but which storage " + "is unspecified and may differ across environments. When " + "committing or force-reloading a file, local edits to that " + "file/check-in combination are discarded.</li>"); + CX("</ul>"); + } + CX("</div>"/*#fileedit-tab-help*/); + + builtin_fossil_js_bundle_or("fetch", "dom", "tabs", "confirmer", + "storage", "popupwidget", "copybutton", + "pikchr", NULL); + /* + ** Set up a JS-side mapping of the AJAX_RENDER_xyz values. This is + ** used for dynamically toggling certain UI components on and off. + ** Must come after window.fossil has been initialized and before + ** fossil.page.fileedit.js. Potential TODO: move this into the + ** window.fossil bootstrapping so that we don't have to "fulfill" + ** the JS multiple times. + */ + ajax_emit_js_preview_modes(1); + builtin_fossil_js_bundle_or("diff", NULL); + builtin_request_js("fossil.page.fileedit.js"); + builtin_fulfill_js_requests(); + { + /* Dynamically populate the editor, display any error in the err + ** blob, and/or switch to tab #0, where the file selector + ** lives. The extra C scopes here correspond to JS-level scopes, + ** to improve grokability. */ + style_script_begin(__FILE__,__LINE__); + CX("\n(function(){\n"); + CX("try{\n"); + { + char * zFirstLeafUuid = 0; + CX("fossil.config['fileedit-glob'] = "); + glob_render_json_to_cgi(fileedit_glob()); + CX(";\n"); + if(blob_size(&err)>0){ + CX("fossil.error(%!j);\n", blob_str(&err)); + } + /* Populate the page with the current leaves and, if available, + the selected check-in's file list, to save 1 or 2 XHR requests + at startup. That makes this page uncacheable, but compressed + delivery of this page is currently less than 6k. */ + CX("fossil.page.initialLeaves = "); + fileedit_render_leaves_list(cimi.zParentUuid ? 0 : &zFirstLeafUuid); + CX(";\n"); + if(zFirstLeafUuid){ + assert(!cimi.zParentUuid); + cimi.zParentUuid = zFirstLeafUuid; + zFirstLeafUuid = 0; + } + if(cimi.zParentUuid){ + CX("fossil.page.initialFiles = "); + fileedit_render_checkin_files(cimi.zParentUuid); + CX(";\n"); + } + CX("fossil.onPageLoad(function(){\n"); + { + if(blob_size(&err)>0){ + CX("fossil.error(%!j);\n", + blob_str(&err)); + CX("fossil.page.tabs.switchToTab(0);\n"); + } + if(cimi.zParentUuid && cimi.zFilename){ + CX("fossil.page.loadFile(%!j,%!j);\n", + cimi.zFilename, cimi.zParentUuid) + /* Reminder we cannot embed the JSON-format + content of the file here because if it contains + a SCRIPT tag then it will break the whole page. */; + } + } + CX("});\n")/*fossil.onPageLoad()*/; + } + CX("}catch(e){" + "fossil.error(e); console.error('Exception:',e);" + "}\n"); + CX("})();")/*anonymous function*/; + style_script_end(); + } + blob_reset(&err); + CheckinMiniInfo_cleanup(&cimi); + db_end_transaction(0); + style_finish_page(); +} |
