Beta 7 is here, and I'm digging deep into my Bingo Lingo today!
As always, check out our release notes for a full list of this week's fixes.
Update:
If you notice Pages article comments have vanished, do one of two things:
Edit each database and under display add a value for comments per page.
Oh, good catch on the 8:30PM vs 7:30PM. Thank you. Total oversight on my part.
I spoke with Yeshua and I received a little more clarification on this request and how I can accomplish what I need without a plugin and nor without violating the ToS, but rather relying on the servers' configuration itself to isolate what is IPS and what is Shabbat script; thus... this reply will mark this as resolved. I thank everyone who contributed to the conversation as it helped me arrive to this solution to share with all.
In /var/www/html/.htaccess I modified the DirectoryIndex property to shabbat.php and at the end of shabbat.php I added a header("Location: index.php"); and voila, IPS admin is not upset about the software and the directory is sufficient; however the line I needed to add was:
DirectoryIndex shabbat.php index.php index.html index.htm
After that, IPS software's index.php is restored and untouched, and the Shabbat script prohibits access to the IPS software during the window of time required.
Thank you again for clarifying the 7:30PM vs 8:30PM.
Here is the complete .htaccess file:
DirectoryIndex shabbat.php index.php index.html index.htm
<IfModule mod_rewrite.c>
Options -MultiViews
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.(js|css|jpeg|jpg|gif|png|ico|map|webp)(\?|$) /404error.php [L,NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
Here is the shabbat.php script:
<?php
if(isset($_GET['debug']) && $_GET['debug'] == "1"){
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
}
date_default_timezone_set('America/New_York');
function getHebrewDate() {
$date = new DateTime('now', new DateTimeZone('America/New_York'));
$jd = gregoriantojd($date->format('m'), $date->format('d'), $date->format('Y'));
$hebrew_date = cal_from_jd($jd, CAL_JEWISH);
$hebrew_months = [
1 => 'Tishri',
2 => 'Heshvan',
3 => 'Kislev',
4 => 'Tevet',
5 => 'Shevat',
6 => 'Adar',
7 => 'Adar II',
8 => 'Nisan',
9 => 'Iyar',
10 => 'Sivan',
11 => 'Tammuz',
12 => 'Av',
13 => 'Elul'
];
return sprintf("%d %s %d",
$hebrew_date['day'],
$hebrew_months[$hebrew_date['month']],
$hebrew_date['year']
);
}
$current_time = new DateTime();
$friday_730pm = (new DateTime('friday this week 19:30'))->setTimezone(new DateTimeZone('America/New_York'));
$saturday_830pm = (new DateTime('saturday this week 20:30'))->setTimezone(new DateTimeZone('America/New_York'));
if ($current_time >= $friday_730pm && $current_time < $saturday_830pm) {
if (!empty($_SERVER['HTTP_X_SHABBAT_CHECK'])) {
return; // Exit if this header is already set
}
header('X-Shabbat-Check: true');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
header('Content-Type: text/html; charset=utf-8');
$remaining_time = $saturday_830pm->getTimestamp() - $current_time->getTimestamp();
$end_date = $saturday_830pm->format('l, F j, Y');
$current_hebrew_date = getHebrewDate();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shabbat Shalom</title>
<style>
body {
background-color: #2F4F4F; /* Slate Steel Gray */
color: #2C2C2C; /* Noir Graphite Dark Gray */
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background-color: rgba(255, 255, 255, 0.9);
padding: 2rem;
border-radius: 10px;
text-align: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 90%;
}
.countdown {
font-size: 2rem;
margin: 1rem 0;
font-weight: bold;
}
h1 {
margin-top: 0;
}
</style>
<script>
function startCountdown(seconds) {
const countdownElem = document.getElementById('countdown');
function updateCountdown() {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
countdownElem.textContent =
`${String(days).padStart(2, '0')}d ` +
`${String(hours).padStart(2, '0')}h ` +
`${String(minutes).padStart(2, '0')}m ` +
`${String(secs).padStart(2, '0')}s`;
if (seconds > 0) {
seconds--;
setTimeout(updateCountdown, 1000);
}
}
updateCountdown();
}
document.addEventListener('DOMContentLoaded', () => {
const remainingTime = <?php echo $remaining_time; ?>;
startCountdown(remainingTime);
});
</script>
</head>
<body>
<div class="container">
<h1>SHABBAT SHALOM!</h1>
<p>Our community is honoring Yeshua's Sabbath for Shabbat.</p>
<p>Our site will be back online in:</p>
<div class="countdown" id="countdown"></div>
<p>We will reopen at 8:30pm on <?php echo $end_date; ?>.</p>
<p>Current Date: <?php echo $current_hebrew_date; ?></p>
</div>
</body>
</html>
<?php
exit;
} else {
header("Location: index.php");
}
I believe this solution will work when IPS 5 is released as well, thus this solution is "future-proof" until the PHP syntax itself changes and the script needs to be adjusted for PHP 9 or PHP X whenever that comes out.
Shalom! ~Andrei
Several questions have arisen regarding the v5 Editor Extension system. While we have yet to create a fully fleshed out dev docs center, I wanted to share a tentative, high-level rundown of what's possible.
What you need
Before continuing, I want to state this material is intended for developers. It is expected you have prerequisite knowledge of JavaScript, and have also read through and understand the tiptap core concepts. Lastly, I want to restate the extension framework is a work in progress subject to change, and therefore the specifics in this post are all tentative, whereas the final v5 docs will be posted elsewhere.
Introduction
It is possible to add 3rd party plugins which have direct access to the Tiptap APIs from the following editor methods
ips.ui.editorv5.registerExtension(name, definition)
ips.ui.editorv5.registerNode(name, definition)
ips.ui.editorv5.registerMark(name, definition)
The definition is forwarded to the corresponding Tiptap extension generator Extension.create(definition), Node.create(definition) or Mark.create(definition). Several important concepts and differences between the Tiptap definitions and the ones in ips.ui.editorv5 are noted below
Add Buttons
Perhaps the most important addition to the underlying Tiptap definition is the ability to specify an addButtons() method which adds buttons to the toolbar
The method should return an object mapping strings to ButtonDefinitions, for example
const buttonDef = {
addButtons() {
return {
test1: {
html: '<div>Red background</div>',
command: ({commands}) => commands.toggleMark(this.name),
locations: [
{ after: 'bold'}
],
isAvailable: editor => editor.can().setMark(this.name),
isActive: editor => editor.isActive('bold')
}
}
}
}
Button Lang Strings
Make sure to register a lang string for the button either in the jslang.php file of the app or by calling ips.setString(key, value) directly. This string is used for the tooltip on the button, set it to an empty string to remove the tooltip.
The button lang key is generated as follows ipsCustom{ExtensionType}__{ExtensionName}_{ButtonKey} where
ExtensionType is Extension, Node or Mark and is determined by the type of plugin
ExtensionName is the name you passed to registerExtension, registerNode or registerMark
ButtonKey is the key in the object returned by addButtons()
Button Content
There are 2 ways to add content to a button:
ButtonDefinition.html - String - This string is parsed, sanitized then set as the innerHTML of the button in the toolbar.
All attributes except 'class', 'style', 'id', 'src', 'title', 'rel', 'alt' are removed
All the following elements are removed: 'script', 'link', 'style', 'pre', 'code', 'iframe', 'object', 'embed', 'noscript', 'video'
Only the first element is preserved. For ex, <div>some content></div><img /> would have to be wrapped in another element
ButtonDefinition.element - HTMLElement - Alternatively, you can use a HTMLElement instance. It is sanitized in the same way as the html
Also, note that, even if you specify an HTMLElement, only a copy of its outerHTML is inserted in the toolbar, so things like event listeners or references to the element will not return the element in the toolbar
ButtonDefinition.command - function({...CommandProps, event?: MouseEvent}):void
Called whenever the button is clicked. This function is passed the arguments object provided by editor.commands.command(props), with the additional property event which is the click event.
ButtonDefinition.isActive - function(Editor):boolean|boolean|undefined
This is called to determine if the button should have an 'active' class applied. It's passed the editor instance. Alternatively, you can pass a boolean to make it always active or inactive. If omitted, the button will never have the 'active' styling
ButtonDefinition.isAvailable - function(editor):boolean|boolean|undefined
This is called to determine if the button should be available in the toolbar. Like the isActive property, it gets passed the editor and returns a boolean
ButtonDefinition.locations - (LocationDef|Location|null)[]
The locations determine where the button will appear. Each element should be either the Location directly or a LocationDef. You null is interpreted as "put this at the end of the toolbar"
Omit the locations array to add the button to the end of the toolbar
Location - string - A location is a string representing a button key of another button in the toolbar.
You can also put the button in dropdown toolbars using the following strings as a Location
align__nested - the text align options
format__nested - the extra options that exist inside the ... dropdown
insert__nested - the + insert button (where the boxes and quotes natively live)
LocationDef - Object
LocationDef.before - Location|undefined - Optional: The key of another item in that toolbar that this button should be placed before. If there is no button with the provided key, the button is added to the end of the toolbar.
LocationDef.after - Location|undefined - Optional: Same as before, except this button will appear after the provided button.
If you define both LocationDef.before and LocationDef.after, the after rule will apply unless that item is nonexistent
See the [data-toolbar-item] attribute of items in the toolbar to get the key of items.
Use traditional JS functions
All the root-level methods of the definition object get bound to the built extension, so it is very important you use traditional functions instead of arrow functions for the root-level methods. For example, the following would not work
ips.ui.editorv5.registerNode('myextension', {
addButtons: () => ({
makeRed: {
html: "<div>make red</div>",
isActive: editor => editor.isActive(this.name),
...
}
})
})
But this would
ips.ui.editorv5.registerNode('myextension', {
addButtons() {
return {
makeRed: {
html: "<div>make red</div>",
isActive: editor => editor.isActive(this.name),
...
}
}
}
})
Conversely, it is recommended to use arrow functions instead of traditional functions for any closures (functions created inside the root-level methods). Doing it this way ensures the this keyword is preserved. For example
ips.ui.editorv5.registerNode('myextension', {
addKeyboardShortcuts() {
return {
// BAD
Enter({editor}) {
return editor.commands.toggleMark(this.name) // `this` may not be defined as expected
},
// GOOD
Escape: ({editor}) => {
return editor.commands.unsetMark(this.name) // `this` refers to the extension definition itself
}
}
}
})
The plugin name
Unlike the Tiptap API, you don't need to pass a name in the definition itself. The editor method will automatically add the name depending on the extension type (Node, Mark, or Extension) and the name argument of the register<Node|Mark|Extension>() method. That being said, keep in mind
You should be especially careful to make sure your name is unique. There can only be one extension of a given type with the same name.
Use this.name in the definition methods rather than the string name you give. All the root-level methods of the definition are bound to the generated extension so no worries about this not being defined
Be careful adding __ (double underscores) to the name, or to the restrictions (see below) for that matter. This can potentially lead to conflicting lang strings... for example if the extension is my__extension, and someone creates another extension named my with a restriction extension, the restriction's lang string will likely conflict with this extensions' lang string.
Restrictions
The Editor has 3 possible states: Minimal, Standard and Advanced. Community Admins can configure which user groups use which state, and can add restrictions to a given state level. For example, the feature "convert links to iframes" can be restricted to the Advanced Editor; in this case, the raw_embed restriction will be present unless the Advanced Editor is used.
From the perspective of apps, however, each restriction is a string, and you can see if a restriction is active by checking editor.options.restrictions.has(restriction) (editor.options.restrictions is of the type Set<string>). When a restriction is present the functionality corresponding to it should be disabled. The editor has no knowledge whether it's in the Minimal, Standard or Advanced state, but only whether the restriction string is present.
You can define restrictions for your extension by adding an array of strings as the property restrictions in your definition. Every restriction
Must contain only letters, numbers dashes and underscores.
Gets prefixed before passing to internal handlers with ipsCustom(Node|Mark|Extension)__{extension_name}__, where Node|Mark|Extension corresponds to the extension type, and extension_name is the name arg passed when registering the extension. It is a lot to type but this ensures there won't be conflicts with other restrictions. This prefixed name is also what gets added to the set editor.options.restrictions
Must have a lang string containing the human-readable name registered. The lang string tells admins what the feature does, and a warning is raised when there is not one. This can be added either to jslang.php in an app before building it, or by calling ips.setString(restrictionKey, 'Human Readable Name')
For example, in the js:
ips.ui.editorv5.registerNode('myNode', {
restrictions: [
"can_add"
],
addButtons() {
return {
addSomething: {
isAvailable: editor => !editor.options.restrictions.has('ipsCustomNode__myNode__can_add'),
html: '+',
}
}
}
})
To add the lang string in the JS, you'd add this before or immediately after registering the node
ips.setString("ipsCustomNode__myCustomNode__can_add", "Can add myNode nodes?");
Or, ideally, in jslang.php, add
$lang = array(
// ... other keys ...
"ipsCustomNode__myCustomNode__can_add" => "Can add myNode nodes?",
);
A note on Privileges vs Restrictions
A privilege-based system assumes a feature is disabled by default, whereas a restriction-based system assumes it's enabled by default. The Editor Restriction System is, well, restriction-based. Therefore, though it is technically possible to use a restriction as a privilege, where a feature is disabled unless the restriction is present, it is strongly discouraged because it creates a counter-intuitive UI for Admins: it will disappear when a user is less restricted.
The Empty Restriction
You can also define a restriction with an empty string as the unprefixed key. When this restriction is present, the extension isn't added to the editor instance. For every other key, the onus is on the developer to ensure the restriction is enforced accordingly.
ips.ui.editorv5.registerExtension('restrictibleExtension', {
restrictions: [ "" ] // "" disables the extension altogether
})
ips.setString('ipsCustomExtension__restrictibleExtension__', "Can use the restrictible third party extension")
Descriptions
For any given extension restriction lang string, you can add a more detailed restriction by creating a lang string with the same key suffixed by :description. This description appears in the AdminCP Restriction Configuration Form.
For example, if the restriction itself after prefixing is ipsCustomNode__mynode__, the description would be stored in the lang string ipsCustomNode__mynode__:description.
HTML Escaping
Lastly, it is also worth noting, for security purposes, any html inside the restriction name or description will be sanitized. For example, the lang definition "<code>" is inserted in the AdminCP Restriction Form's source HTML as <code>
IPS Plugins are Sandboxed
All the IPS plugin definitions are sandboxed, meaning any properties in the definition that are not whitelisted will be removed.
The biggest property that is removed is addProseMirrorPlugins(), but any property not in the following keys will be removed
[
'name',
'group',
'isInline',
'isBlock',
'isText',
'selectable',
'exitable',
'atom',
'addOptions',
'addKeyboardShortcuts',
'addAttributes',
'addGlobalAttributes',
'renderHTML',
'parseHTML',
'onCreate',
'onUpdate',
'onBeforeCreate',
'onFocus',
'onBlur',
'onBeforeCreate',
'onSelectionUpdate',
'onDestroy',
'onTransaction',
'priority',
'defaultOptions',
'addInputRules',
'marks',
'addNodeView',
'whitespace',
'defining',
'isolating',
'renderText'
]
Priority
The priority key's default value is higher than the Tiptap default. It is recommended to not specify this in the definition unless you know for sure you need to override other custom plugin commands, keyboard shortcuts, or HTML converters, there is no need to set this.
How to load the code?
Method 1: Using an App (best practice)
In most cases, it's best to add your extension inside a custom app. In most cases, the extension will define a new type of content that is not whitelisted by the backend Parser, so it is lost during sanitization.
This is why it is beneficial to use an app which has both the extension and a parser rule to whitelist the element/attributes. It also adds organization to your customization so that you won't forget where or how it's setup.
To do this, simply put your plugins in *.js files in <your app dir>/dev/editor/. When the app is built and installed it is all compiled and loaded seamlessly on the front end
Method 2: Using the ips:editorBeforeInit event
For simpler plugins that do not require any new content types (for example a <span> with a style attribute), you can indeed add them inside a <script> tag anywhere in the page, with either inline or remote JS code. To ensure that your extension is registered after ips.ui.editorv5 is created but before the editor is actually created, hook onto the event ips:editorBeforeInit which is fired on the document. For example
<script type="application/ecmascript">
document.addEventListener('ips:editorBeforeInit', () => {
ips.ui.editorv5.registerMark('redBackground', {
parseHTML() {
return [{tag: 'span', getAttrs: node => node.style.backgroundColor === "red" && null}]
},
renderHTML() {
return ['span', {style: 'background-color: red'}, 0]
},
...
})
}, {once: true}) // the once:true flag is very useful because it prevents the same code from needlessly running and replacing the extension over and over
</script>
This code can go anywhere, but directly in the theme is a good place to start. Note it gets cumbersome very quickly this way
The actual Event is a CustomEvent instance that has the following in its detail property
elem : HTMLElement - The element that has the data-ipseditorv5 attribute
textarea: HTMLTextAreaElement - The actual textarea that is being replaced with an editor in a form
content: string - The starting content of the editor.
options: Object - The config that is about to be passed to the editor. You can modify this object to change the default configuration, but note that this is stateful - you'd have to read/update the options each time the event fires instead of just once like for registering extensions.
Font family support
It is possible, when listening to this event, to add or change supported font families. At the core, a font family consists of
A main property. This is either a global family default (see below) or a string to be added as the first entry in the font-family CSS property
An array of fallbacks. The entries, like main, are either a string or a global font family default
The editor option ipsFontFamily.additionalFontDefinitions defines additional fonts as an object mapping the name to the font definition. For example, to add fonts from the ips:editorBeforeInit event
document.addEventListener('ips:editorBeforeInit', e => {
e.detail.options.ipsFontFamily = {
additionalFontDefinitions: {
"Comic Sans": { // the user will see "comic sans" in the dropdown
main: "Comic Sans MS", // this is added to the CSS property
fallbacks: [7] // 7 corresponds to 'sans-serif'. The compiled font family property will be `font-family: "Comic Sans MS", sans-serif;
}
}
};
})
Extending Fonts
For any builtin fonts, you can define them in your plugin to change their behavior.
For example, if you have MS-specific versions of Helvetica installed on your community, you could set fallbacks: ['Helvetica MS']. This will make Helvetica MS the first fallback, while preserving the default fallbacks after it.
Global Family Constants
In css, certain keywords are accepted as the font family which will translate to dynamic CSS behavior or OS preset fonts. The following numbers can be used in lieu of a font string to embed these keywords. Note the type number MUST be used in the js, not the stringified object property shown below
{
"6": "serif",
"7": "sans-serif",
"8": "monospace",
"9": "cursive",
"10": "fantasy",
"16": "emoji",
"17": "math",
"18": "fangsong"
}
Builtin support for unknown fonts
The option ipsFontFamily.allowUnknownFonts works as follows.
When processing HTML, if there exists a span[style] tag in the HTML that defines a font-family and the font is unknown, the editor will construct a temporary font family definition and preserve its value. That being said, the custom font will be forgotten should the user change the font to a builtin choice; to ensure an option remains visible, you'll need to define it in your extension. It is generally good practice to not allow these fonts as they will have varying support across devices.
Example Plugin
This is an example of an Extension which will register a Mark that creates <kbd> elements to denote keyboard shortcuts
ips.ui.editorv5.registerMark('myKbdElement', {
exitable: true,
parseHTML() {
return [{ tag: 'kbd' }]
},
renderHTML() {
return [
'kbd',
{
style: `
border-color: var(--i-color_hard);
background-color: var(--i-background_2)`
},
0
]
},
addButtons() {
return {
test1: {
html: '<span><i class="fa-solid fa-keyboard"></i></span>',
command: ({chain}) => chain().toggleMark(this.name).focus().run(),
// This puts the button before bold, after the 'link' button, inside the `...` menu and at the end of the toolbar
locations: [
{ before: 'bold' },
'link',
'format__nested',
null
],
isAvailable: editor => editor.can().setMark(this.name),
isActive: editor => editor.isActive(this.name)
}
}
},
addKeyboardShortcuts() {
return {
// "Mod" is command or ctrl, depending on system defaults;
"Mod-Shift-K": ({editor}) => editor.chain().toggleMark(this.name).focus().run()
}
},
restrictions: [
""
]
})
// This sets the restriction string. Ideally, this should go in the jslang.php of your app but it is ok to do this here
ips.setString("ipsCustomMark__myKbdElement__", "Can apply keyboard styles");
ips.setString("ipsCustomMark__myKbdElement_test1", "Keyboard Command");
Note that, in this case of <kbd> elements, you'd also have to add a parser rule via the AdminCP Dev Center to allow them to be saved.
I wish the Bulk Mail feature had a "send test mail" button to send an email to myself to check what it looks like before proceeding with the Bulk Mail.
Meanwhile my workaround is to save the Bulk Mail with only the Admin selected and proceed.
Hi Zak,
It's a good question. Developers are always reluctant to give a ballpark figure for when they expect a product to be released for two reasons: firstly, they are overly optimistic about how long things take, and secondly once a date or month is given, it starts a countdown. If we miss that date then it becomes a bit of a stick to wave at us.
All that said, we are confident that we'll release v5.0.0 final in Q1 2025. It will probably feel like quite a soft release as we won't update our website, etc, and we'll offer it alongside v4 which will be supported for quite a while yet. We'd expect most will stick with v4 while testing out v5 and waiting for it to mature a bit before converting over.
It is an honor to give a little something back to Invision Community that has been the source of so many fond memories!
I hope it will show some of the many capabilities the amazing team have created for us!
Can you start from a stock Invision Community 5 installation and have it themed, customized, and a new homepage built in under an hour without relying on custom templates and coding?
Yes, and Jimi Wikman, a long-time Invision Community customer, did just that in his latest YouTube video.
Jimi has over twenty-five years of experience in development and twenty years of experience in graphic design.
Invision Community 5 has been in testing for a few months now, and Jimi produced this amazing walkthrough of Invision Community 5's new page editor and theme editor while re-creating his own site.
Our vision for Invision Community 5 was to put the power into the hands of everyone, not just those who are proficient in PHP, HTML, and CSS. Jimi's video shows this vision as a reality as he moves through the theme editor to create his custom theme, and the page editor to build a custom homepage.
Sit back and enjoy watching Jimi put together a new site.
Thanks Jimi!
If you're interested in testing Invision Community 5 for yourself, just join our Beta Testing Club.
Hello,
it was discussed a lot my friend. Read this entry first:
https://invisioncommunity.com/developers/devblog/blog/ic5-introduction-to-listeners-r8/
Best regards and good luck! IC5 isn’t as bad as it might seem at first. 😉
Beta squad! (I'm trying something new, don't judge me)
Invision Community 5.0.0 Beta 2 is floating through our cloud deployment system and should be available soon.
There's a lot fixed in this version, so thank you for your patience over the past week or so. The full list of fixes is here: https://invisioncommunity.com/release-notes-v5/
Hopefully we didn't add to many new bugs. 🤞
Welcome to Beta 1!
We finally made it! After three months of intense alpha testing, and many more months of development, we're ready to move into beta testing.
For a full run down of how to access the beta, see the overview page.
A reminder that beta software will contain issues. We've done a lot of testing, but only limited testing upgrading from v4, so there's likely to be issues for some depending on server OS, and other site specific factors. It's best you don't immediately update your production site. 🙂
I want to extend my thanks to everyone that was involved in the early focus group, the very early alpha testing and the more public alpha testing. Each click, each error hit and each bug report moved us forward and I'm very grateful for your help.
Now we enter the most exciting phase, the last stretch before a full release!
You'll be able to download a little later today.
I found a service provider here who is able to complete this task, so it's basically solved:
https://invisioncommunity.com/third-party/providers-directory/
If you plan on offering multiple files for purchase, you will be better in the long run with using Downloads app to power the digital downloads.
Dawpi has a really good point that the Downloads app has features to monitor, update, and version your files. Is it overkill if you're only going to offer one or two 3D files? Yes. But if you plan on selling dozens or hundreds of 3D models, Downloads is very robust.