plum-cms 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +79 -0
- data/app/assets/builds/tailwind.css +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +88 -5
- data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
- data/app/controllers/plum/form_submissions_controller.rb +13 -0
- data/app/controllers/plum/pages_controller.rb +8 -0
- data/app/controllers/plum/theme_assets_controller.rb +2 -0
- data/app/javascript/controllers/plum/write_controller.js +174 -0
- data/app/models/plum/asset.rb +1 -0
- data/app/models/plum/content_type.rb +1 -0
- data/app/models/plum/entry.rb +57 -0
- data/app/models/plum/entry_term.rb +1 -0
- data/app/models/plum/form_definition.rb +1 -0
- data/app/models/plum/global.rb +1 -0
- data/app/models/plum/nav_item.rb +1 -0
- data/app/models/plum/nav_menu.rb +1 -0
- data/app/models/plum/site.rb +2 -0
- data/app/models/plum/site_setting.rb +1 -0
- data/app/models/plum/static_cache_invalidation.rb +30 -0
- data/app/models/plum/taxonomy.rb +1 -0
- data/app/models/plum/term.rb +1 -0
- data/app/services/plum/config_sync.rb +252 -0
- data/app/services/plum/draft_diff.rb +177 -0
- data/app/services/plum/form_renderer.rb +12 -1
- data/app/services/plum/liquid_context.rb +0 -2
- data/app/services/plum/site_archive.rb +367 -0
- data/app/views/layouts/plum/write.html.erb +140 -0
- data/app/views/plum/cp/dashboard/show.html.erb +10 -3
- data/app/views/plum/cp/entries/_form.html.erb +2 -2
- data/app/views/plum/cp/entries/diff.html.erb +40 -0
- data/app/views/plum/cp/entries/edit.html.erb +24 -0
- data/app/views/plum/cp/entries/index.html.erb +3 -0
- data/app/views/plum/cp/entries/write.html.erb +60 -0
- data/config/plum_routes.rb +5 -0
- data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
- data/docs/config-as-code.md +103 -0
- data/docs/plum-cli.md +356 -0
- data/docs/portability.md +40 -0
- data/docs/roadmap.md +71 -69
- data/docs/static-caching.md +163 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +10 -1
- data/lib/plum/static_cache/middleware.rb +61 -0
- data/lib/plum/static_cache.rb +103 -0
- data/lib/plum/version.rb +1 -1
- data/lib/tasks/plum_config.rake +48 -0
- data/lib/tasks/plum_portability.rake +62 -0
- data/lib/tasks/plum_styles.rake +14 -9
- metadata +26 -5
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Static Page Caching
|
|
2
|
+
|
|
3
|
+
Plum ships a full-page static cache: the first request for a public page
|
|
4
|
+
renders through Liquid as normal, and the complete HTML response is written to
|
|
5
|
+
disk. Subsequent requests are served from that file without touching the
|
|
6
|
+
database or the template engine. When content changes, the affected site's
|
|
7
|
+
cache is deleted and pages lazily re-render on their next visit.
|
|
8
|
+
|
|
9
|
+
This is a cache on the one true render path — not a static site generator.
|
|
10
|
+
There is no build step, no dependency graph to get wrong, and the control
|
|
11
|
+
panel always shows live data.
|
|
12
|
+
|
|
13
|
+
**It is off by default and must be turned on explicitly.** Read "Should you
|
|
14
|
+
enable this?" below before flipping it on — the wrong setting on the wrong
|
|
15
|
+
deployment topology causes a specific, silent content bug (stale pages served
|
|
16
|
+
forever), not a crash you'd notice.
|
|
17
|
+
|
|
18
|
+
## Should you enable this?
|
|
19
|
+
|
|
20
|
+
Ask one question: **does your app run on more than one server/dyno/instance
|
|
21
|
+
at the same time?**
|
|
22
|
+
|
|
23
|
+
| Your deployment | Enable it? | Why |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| Single VPS/Droplet/EC2 box (Plum's standalone SQLite mode) | **Yes** | This is the shape it was designed for. One disk, one cache, invalidation always reaches it. Free page-load speed and DB-load reduction. |
|
|
26
|
+
| Docker Compose on one host, one app container | **Yes** | Still one filesystem for the app process. |
|
|
27
|
+
| Heroku with 1 dyno, Render with 1 instance | **Yes, cautiously** | Correct today, but re-check this the moment you scale to 2 dynos — nothing will warn you when you cross that line. |
|
|
28
|
+
| Heroku/Render/Fly with **2+ dynos or instances**, or any horizontally-scaled/load-balanced deployment | **No** | See "Why this fails on multiple nodes" below. Use a CDN in front of the app instead (see that section). |
|
|
29
|
+
| Plum mounted inside a larger host app (embedded/SaaS mode, e.g. many customer sites behind one Rails app) | **Depends entirely on the host app's node count** — almost always multi-node in practice, so usually **No** | The host app's deployment shape governs, not Plum's. |
|
|
30
|
+
| Not sure / evaluating locally | **Leave it off** | It has zero effect on correctness either way in dev; there's no reason to enable it before you know your production topology. |
|
|
31
|
+
|
|
32
|
+
If you're unsure, leave it off. The cost of leaving it off is slower page
|
|
33
|
+
responses and more DB load. The cost of turning it on wrongly is **visitors
|
|
34
|
+
silently seeing outdated or wrong content**, which is worse and harder to
|
|
35
|
+
debug — nothing errors, nothing logs, pages simply disagree depending on
|
|
36
|
+
which server answered the request.
|
|
37
|
+
|
|
38
|
+
### Why this fails on multiple nodes
|
|
39
|
+
|
|
40
|
+
The cache is plain files on the server's own local disk. Invalidation works
|
|
41
|
+
by *deleting those files* when content changes. On one server, that's
|
|
42
|
+
airtight: the file that would be stale no longer exists, so the next request
|
|
43
|
+
always re-renders fresh.
|
|
44
|
+
|
|
45
|
+
With two or more servers, each has its **own separate disk**. An editor
|
|
46
|
+
publishes a change; the request that triggers invalidation lands on
|
|
47
|
+
whichever server handled that specific HTTP request — say, server A. Server A
|
|
48
|
+
deletes its cached file. Server B's copy of that same file is untouched.
|
|
49
|
+
Visitors routed to server B by the load balancer keep seeing the old page
|
|
50
|
+
**indefinitely** — not for a few seconds until some TTL expires, but forever,
|
|
51
|
+
until something else happens to touch that page on server B too.
|
|
52
|
+
|
|
53
|
+
This is why there is deliberately no "auto-detect production and turn it on"
|
|
54
|
+
behavior. A setting that's silently wrong 100% of the time on a common
|
|
55
|
+
deployment shape (small teams scaling from 1 to 2 dynos is one of the most
|
|
56
|
+
ordinary things that happens to a growing app) is worse than a setting you
|
|
57
|
+
have to think about once.
|
|
58
|
+
|
|
59
|
+
### If you're on a multi-node platform and still want caching
|
|
60
|
+
|
|
61
|
+
Don't use the disk store. Put a CDN (Cloudflare, CloudFront, Fastly) in front
|
|
62
|
+
of the app and let *it* hold the cached pages — a CDN has exactly one shared
|
|
63
|
+
cache, not one per node, so the invalidation problem above doesn't exist. You
|
|
64
|
+
still get the "no build step, one render path" benefit described above; only
|
|
65
|
+
the storage layer changes. See "Serving cached files from the web server"
|
|
66
|
+
below for the shape this takes; a first-class CDN/surrogate-key purge backend
|
|
67
|
+
driven by the same capture/invalidation hooks used here is on the roadmap.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
1. `Plum::StaticCache::Middleware` checks every `GET` request with no query
|
|
72
|
+
string. If a cached file exists for the request host + path, it is served
|
|
73
|
+
immediately (`X-Plum-Static-Cache: hit`).
|
|
74
|
+
2. On a miss, the request renders normally. `Plum::PagesController` and
|
|
75
|
+
`Plum::ThemeAssetsController` mark their successful responses, and the
|
|
76
|
+
middleware writes the body to
|
|
77
|
+
`storage/plum_static_cache/{host}/{path}/index.html`
|
|
78
|
+
(theme assets keep their real filename and extension).
|
|
79
|
+
3. Content models flush their site's cache directory in an `after_commit`
|
|
80
|
+
hook (`Plum::StaticCacheInvalidation`). Flushing deletes files; it never
|
|
81
|
+
generates anything, so over-flushing is cheap and always correct — this is
|
|
82
|
+
what makes it safe on one server and unsafe across many, per above.
|
|
83
|
+
|
|
84
|
+
Requests with query strings (search, pagination, UTM-tagged links) are never
|
|
85
|
+
cached and never served from the cache. The control panel, API, and form
|
|
86
|
+
endpoints are untouched.
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Plum.configure do |config|
|
|
92
|
+
# Explicit opt-in. Off by default. Read "Should you enable this?" above
|
|
93
|
+
# before setting this to true.
|
|
94
|
+
config.static_cache_enabled = true
|
|
95
|
+
|
|
96
|
+
# Where cache files live. Default: Rails.root/storage/plum_static_cache
|
|
97
|
+
config.static_cache_path = "/var/www/site-cache"
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
There is no environment-based default (no "automatically on in production")
|
|
102
|
+
— it must be set explicitly, on purpose, by someone who has confirmed the
|
|
103
|
+
deployment is single-node. This is a deliberate change: earlier versions of
|
|
104
|
+
this feature turned on automatically in `production`, which was correct for
|
|
105
|
+
the single-VPS case this was designed around but silently wrong the moment an
|
|
106
|
+
app scaled to multiple nodes. Explicit opt-in means the person flipping the
|
|
107
|
+
switch is the person who knows the deployment topology.
|
|
108
|
+
|
|
109
|
+
Cache directories are keyed by request host. A site with a `domain` set
|
|
110
|
+
flushes `{domain}` and `www.{domain}`; sites without a domain flush the whole
|
|
111
|
+
cache root.
|
|
112
|
+
|
|
113
|
+
Administrators can clear a site's cache manually from the dashboard
|
|
114
|
+
("Clear page cache").
|
|
115
|
+
|
|
116
|
+
## Serving cached files from the web server (full measure)
|
|
117
|
+
|
|
118
|
+
The middleware already skips the database and rendering, but nginx or Caddy
|
|
119
|
+
can serve the files without invoking Ruby at all:
|
|
120
|
+
|
|
121
|
+
```nginx
|
|
122
|
+
server {
|
|
123
|
+
server_name example.com;
|
|
124
|
+
root /path/to/app/storage/plum_static_cache/$host;
|
|
125
|
+
|
|
126
|
+
location / {
|
|
127
|
+
try_files $uri $uri/index.html @rails;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
location @rails {
|
|
131
|
+
proxy_pass http://app_upstream;
|
|
132
|
+
proxy_set_header Host $host;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Anything not in the cache (first hits, search, forms, the control panel)
|
|
138
|
+
falls through to Rails. Because invalidation deletes files, the web server
|
|
139
|
+
can never serve a stale page that Plum knows is stale.
|
|
140
|
+
|
|
141
|
+
The same layout works for pushing to object storage: sync the cache directory
|
|
142
|
+
to S3/CloudFront and invalidate on deploy, or point a CDN at the app origin
|
|
143
|
+
and let the middleware serve as the fast backend.
|
|
144
|
+
|
|
145
|
+
## Forms and CSRF
|
|
146
|
+
|
|
147
|
+
Cached pages cannot carry per-session CSRF tokens, so public `{% form %}`
|
|
148
|
+
submissions use a hidden honeypot field (`form_submission[website]`) instead.
|
|
149
|
+
Submissions with a filled honeypot are silently accepted-and-dropped. The
|
|
150
|
+
control panel keeps standard Rails CSRF protection — only the public form
|
|
151
|
+
endpoint opts out.
|
|
152
|
+
|
|
153
|
+
This also means rendering a public page no longer writes a session cookie,
|
|
154
|
+
which is what makes responses safely shareable between visitors — and is true
|
|
155
|
+
regardless of whether the cache itself is enabled.
|
|
156
|
+
|
|
157
|
+
## What stays dynamic
|
|
158
|
+
|
|
159
|
+
- `/search` (query-dependent)
|
|
160
|
+
- Collection pagination beyond page 1 (`?page=2`)
|
|
161
|
+
- Form submission POSTs
|
|
162
|
+
- The JSON content API
|
|
163
|
+
- The entire control panel
|
|
@@ -12,6 +12,14 @@ Plum.configure do |config|
|
|
|
12
12
|
Plum::Engine.root.join("app/themes")
|
|
13
13
|
]
|
|
14
14
|
|
|
15
|
+
# Static page caching serves rendered pages from disk instead of hitting
|
|
16
|
+
# the database/Liquid on every request — a big win on a single server, but
|
|
17
|
+
# only correct on a single server. See docs/static-caching.md before
|
|
18
|
+
# enabling this on a multi-node/PaaS deployment (Heroku, Render, etc. with
|
|
19
|
+
# 2+ dynos or instances).
|
|
20
|
+
#
|
|
21
|
+
# config.static_cache_enabled = true
|
|
22
|
+
|
|
15
23
|
# White-label the control panel:
|
|
16
24
|
#
|
|
17
25
|
# config.cp_name = "My Brand"
|
data/lib/plum/configuration.rb
CHANGED
|
@@ -6,7 +6,8 @@ module Plum
|
|
|
6
6
|
:mailer_sender, :cp_name, :cp_subtitle, :cp_logo_path,
|
|
7
7
|
:cp_accent_color, :cp_sidebar_bg, :cp_sidebar_header_bg, :cp_sidebar_text, :cp_sidebar_muted,
|
|
8
8
|
:cp_back_url, :cp_back_label,
|
|
9
|
-
:powered_by_name, :powered_by_url
|
|
9
|
+
:powered_by_name, :powered_by_url,
|
|
10
|
+
:static_cache_enabled, :static_cache_path, :config_path
|
|
10
11
|
attr_writer :theme_paths
|
|
11
12
|
attr_reader :content_sources
|
|
12
13
|
|
|
@@ -25,6 +26,16 @@ module Plum
|
|
|
25
26
|
@cp_back_label = "← Back"
|
|
26
27
|
@powered_by_name = "Plum"
|
|
27
28
|
@powered_by_url = "https://plumcms.com"
|
|
29
|
+
# Explicit opt-in — see docs/static-caching.md before enabling. Off by
|
|
30
|
+
# default because it's only correct on a single-server deployment;
|
|
31
|
+
# silently corrupts pages (stale content served forever from whichever
|
|
32
|
+
# node didn't get the invalidation) on multi-node platforms like
|
|
33
|
+
# Heroku/Render with 2+ dynos.
|
|
34
|
+
@static_cache_enabled = false
|
|
35
|
+
@static_cache_path = nil
|
|
36
|
+
# Directory for config-as-code YAML (content types, fieldsets).
|
|
37
|
+
# nil = default to Rails.root/plum when the tasks are invoked.
|
|
38
|
+
@config_path = nil
|
|
28
39
|
@content_sources = ContentSourceRegistry.new
|
|
29
40
|
@current_site_resolver = ->(_controller) { Plum::Site.first_or_create_standalone! }
|
|
30
41
|
@current_user_resolver = lambda { |controller|
|
data/lib/plum/engine.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require_relative "configuration"
|
|
2
|
+
require_relative "static_cache"
|
|
3
|
+
require_relative "static_cache/middleware"
|
|
2
4
|
|
|
3
5
|
module Plum
|
|
4
6
|
class Engine < ::Rails::Engine
|
|
@@ -6,9 +8,16 @@ module Plum
|
|
|
6
8
|
config.paths["config/routes.rb"] = "config/plum_routes.rb"
|
|
7
9
|
config.paths["db/migrate"] = [ "db/engine_migrate" ]
|
|
8
10
|
|
|
11
|
+
initializer "plum.static_cache" do |app|
|
|
12
|
+
# Always installed; it no-ops per request unless StaticCache.enabled?
|
|
13
|
+
app.middleware.use Plum::StaticCache::Middleware
|
|
14
|
+
end
|
|
15
|
+
|
|
9
16
|
initializer "plum.assets" do |app|
|
|
10
17
|
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
11
|
-
|
|
18
|
+
# Importmap exposes these files as `plum/*`, so Propshaft must resolve
|
|
19
|
+
# them relative to the parent controllers directory.
|
|
20
|
+
app.config.assets.paths << root.join("app/javascript/controllers")
|
|
12
21
|
app.config.assets.paths << root.join("vendor/javascript")
|
|
13
22
|
|
|
14
23
|
lexxy_spec = Gem.loaded_specs["lexxy"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative "../static_cache"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
module StaticCache
|
|
5
|
+
# Serves cached pages before the request reaches Rails and captures
|
|
6
|
+
# renders on the way out. Controllers opt responses in by setting the
|
|
7
|
+
# X-Plum-Static-Cache header to "store" (see PagesController); everything
|
|
8
|
+
# else passes through untouched.
|
|
9
|
+
class Middleware
|
|
10
|
+
MARKER = StaticCache::MARKER_HEADER.downcase
|
|
11
|
+
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
if StaticCache.enabled? && cacheable_request?(env) && (file = StaticCache.read(env["HTTP_HOST"], env["PATH_INFO"]))
|
|
18
|
+
return serve(file, env)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
status, headers, body = @app.call(env)
|
|
22
|
+
maybe_store(env, status, headers, body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def cacheable_request?(env)
|
|
28
|
+
return false unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
|
|
29
|
+
|
|
30
|
+
env["QUERY_STRING"].to_s.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serve(file, env)
|
|
34
|
+
body = env["REQUEST_METHOD"] == "HEAD" ? [] : [ file.binread ]
|
|
35
|
+
headers = {
|
|
36
|
+
"content-type" => StaticCache.content_type_for(file),
|
|
37
|
+
"content-length" => file.size.to_s,
|
|
38
|
+
MARKER => "hit"
|
|
39
|
+
}
|
|
40
|
+
[ 200, headers, body ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def maybe_store(env, status, headers, body)
|
|
44
|
+
# The marker is internal — strip it even when the response isn't stored.
|
|
45
|
+
marker = headers.delete(MARKER) || headers.delete(StaticCache::MARKER_HEADER)
|
|
46
|
+
return [ status, headers, body ] unless marker == "store" && StaticCache.enabled? && cacheable_request?(env)
|
|
47
|
+
return [ status, headers, body ] unless status == 200 && env["REQUEST_METHOD"] == "GET"
|
|
48
|
+
return [ status, headers, body ] if headers["set-cookie"].present? || headers["Set-Cookie"].present?
|
|
49
|
+
|
|
50
|
+
chunks = []
|
|
51
|
+
body.each { |chunk| chunks << chunk.to_s }
|
|
52
|
+
body.close if body.respond_to?(:close)
|
|
53
|
+
full_body = chunks.join
|
|
54
|
+
|
|
55
|
+
StaticCache.store(env["HTTP_HOST"], env["PATH_INFO"], full_body)
|
|
56
|
+
headers[MARKER] = "miss"
|
|
57
|
+
[ status, headers, [ full_body ] ]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "rack/mime"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
# Full-page static cache. Rendered public pages are written to disk keyed by
|
|
5
|
+
# host + path; subsequent requests are served from the file without touching
|
|
6
|
+
# the database or Liquid. A web server can serve the same files directly
|
|
7
|
+
# (nginx try_files) for the "full measure" where Rails never sees the hit.
|
|
8
|
+
#
|
|
9
|
+
# Cache entries are invalidated by deletion (see StaticCacheInvalidation) —
|
|
10
|
+
# the next request re-renders and re-stores the page. Over-flushing is cheap,
|
|
11
|
+
# so anything ambiguous flushes the whole site.
|
|
12
|
+
module StaticCache
|
|
13
|
+
MARKER_HEADER = "X-Plum-Static-Cache".freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Explicit opt-in only — see docs/static-caching.md. There is
|
|
17
|
+
# deliberately no "auto-on in production" mode: this cache is only
|
|
18
|
+
# correct on a single-server deployment, and defaulting it on would
|
|
19
|
+
# silently corrupt pages the moment an app scales to 2+ nodes.
|
|
20
|
+
def enabled?
|
|
21
|
+
!!Plum.configuration.static_cache_enabled
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cache_root
|
|
25
|
+
Pathname(Plum.configuration.static_cache_path || Rails.root.join("storage", "plum_static_cache"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Pages are stored as {host}/{path}/index.html so a web server can map
|
|
29
|
+
# URLs to files directly. Paths with a file extension (theme assets) are
|
|
30
|
+
# stored verbatim.
|
|
31
|
+
def file_path(host, path)
|
|
32
|
+
key = normalized_path(path)
|
|
33
|
+
return nil unless key
|
|
34
|
+
|
|
35
|
+
base = cache_root.join(sanitized_host(host))
|
|
36
|
+
full = File.extname(key).present? ? base.join(key) : base.join(key, "index.html")
|
|
37
|
+
full = Pathname(File.expand_path(full))
|
|
38
|
+
return nil unless full.to_s.start_with?(cache_root.to_s + File::SEPARATOR)
|
|
39
|
+
|
|
40
|
+
full
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read(host, path)
|
|
44
|
+
file = file_path(host, path)
|
|
45
|
+
file if file&.file?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def store(host, path, body)
|
|
49
|
+
file = file_path(host, path)
|
|
50
|
+
return unless file
|
|
51
|
+
|
|
52
|
+
file.dirname.mkpath
|
|
53
|
+
tmp = file.sub_ext(".tmp-#{Process.pid}-#{Thread.current.object_id}")
|
|
54
|
+
tmp.binwrite(body)
|
|
55
|
+
File.rename(tmp, file)
|
|
56
|
+
file
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
Rails.logger.error("[Plum] static cache write failed for #{path}: #{e.message}")
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flush_site!(site)
|
|
63
|
+
return flush_all! if site.nil? || site.domain.blank?
|
|
64
|
+
|
|
65
|
+
[ site.domain, "www.#{site.domain}" ].each do |host|
|
|
66
|
+
dir = cache_root.join(sanitized_host(host))
|
|
67
|
+
FileUtils.rm_rf(dir) if dir.to_s.start_with?(cache_root.to_s) && dir.exist?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def flush_all!
|
|
72
|
+
return unless cache_root.exist?
|
|
73
|
+
|
|
74
|
+
cache_root.children.each { |child| FileUtils.rm_rf(child) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def content_type_for(file)
|
|
78
|
+
Rack::Mime.mime_type(File.extname(file.to_s), "text/html")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def sanitized_host(host)
|
|
84
|
+
cleaned = host.to_s.downcase.gsub(/[^a-z0-9.\-]/, "")
|
|
85
|
+
cleaned.presence || "_default"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rejects anything that could escape the cache directory and normalizes
|
|
89
|
+
# "/" and "/about/" style paths to a shared key.
|
|
90
|
+
def normalized_path(path)
|
|
91
|
+
decoded = begin
|
|
92
|
+
URI.decode_www_form_component(path.to_s)
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
return nil if decoded.include?("..") || decoded.include?("\0")
|
|
97
|
+
|
|
98
|
+
trimmed = decoded.delete_prefix("/").chomp("/")
|
|
99
|
+
trimmed.presence || "_root"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/plum/version.rb
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:config:export")
|
|
5
|
+
namespace :plum do
|
|
6
|
+
namespace :config do
|
|
7
|
+
def plum_config_site
|
|
8
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
9
|
+
abort "No Plum site exists" unless site
|
|
10
|
+
site
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def plum_config_dir
|
|
14
|
+
ENV["DIR"].presence || Plum.configuration.config_path || Rails.root.join("plum")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Write the content model (content types, fieldsets) to YAML files (SITE_ID optional; DIR defaults to plum/)"
|
|
18
|
+
task export: :environment do
|
|
19
|
+
files = Plum::ConfigSync.export(site: plum_config_site, dir: plum_config_dir)
|
|
20
|
+
puts "Exported #{files.length} config files to #{plum_config_dir}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc "Apply YAML config files to the database (SITE_ID optional; PRUNE=1 deletes types absent from files; FORCE=1 allows deleting types with entries)"
|
|
24
|
+
task sync: :environment do
|
|
25
|
+
result = Plum::ConfigSync.apply(
|
|
26
|
+
site: plum_config_site,
|
|
27
|
+
dir: plum_config_dir,
|
|
28
|
+
prune: ENV["PRUNE"].present?,
|
|
29
|
+
force: ENV["FORCE"].present?
|
|
30
|
+
)
|
|
31
|
+
puts "Config sync: #{result.summary}"
|
|
32
|
+
rescue Plum::ConfigSync::UnsafePruneError => e
|
|
33
|
+
abort e.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "Fail if the database content model has drifted from the YAML files (for CI)"
|
|
37
|
+
task check: :environment do
|
|
38
|
+
drift = Plum::ConfigSync.check(site: plum_config_site, dir: plum_config_dir)
|
|
39
|
+
if drift.any?
|
|
40
|
+
drift.each { |line| puts "DRIFT #{line}" }
|
|
41
|
+
abort "Content model has drifted from config files (#{drift.length} differences)"
|
|
42
|
+
else
|
|
43
|
+
puts "Content model matches config files"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:site:export")
|
|
5
|
+
namespace :plum do
|
|
6
|
+
namespace :site do
|
|
7
|
+
desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
|
|
8
|
+
task export: :environment do
|
|
9
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
10
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
11
|
+
abort "No Plum site exists" unless site
|
|
12
|
+
|
|
13
|
+
archive = Plum::SiteArchive.dump(site: site, path: path)
|
|
14
|
+
puts "Exported #{site.name} to #{archive}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
18
|
+
task import: :environment do
|
|
19
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
20
|
+
site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
|
|
21
|
+
puts "Imported #{site.name} as site #{site.id}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
namespace :site do
|
|
26
|
+
desc "Replace the local site with an archive — the 'pull' refresh (ARCHIVE required; SITE_ID optional; FORCE=1 required in production)"
|
|
27
|
+
task replace: :environment do
|
|
28
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
29
|
+
if Rails.env.production? && ENV["FORCE"].blank?
|
|
30
|
+
abort "Refusing to replace a site in production (set FORCE=1 to override)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
old_site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
34
|
+
old_name = old_site&.name
|
|
35
|
+
|
|
36
|
+
site = ActiveRecord::Base.transaction do
|
|
37
|
+
old_site&.destroy!
|
|
38
|
+
Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
puts old_name ? "Replaced #{old_name} with #{site.name} (site #{site.id})" : "Imported #{site.name} as site #{site.id}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
namespace :backup do
|
|
46
|
+
desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
|
|
47
|
+
task create: :environment do
|
|
48
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
49
|
+
abort "No Plum site exists" unless site
|
|
50
|
+
directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
|
|
51
|
+
filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
|
|
52
|
+
archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
|
|
53
|
+
puts "Backed up #{site.name} to #{archive}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
57
|
+
task restore: :environment do
|
|
58
|
+
Rake::Task["plum:site:import"].invoke
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/tasks/plum_styles.rake
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:build_styles")
|
|
5
|
+
require "fileutils"
|
|
2
6
|
|
|
3
|
-
namespace :plum do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
namespace :plum do
|
|
8
|
+
desc "Build Tailwind and copy the result into Plum's packaged stylesheet"
|
|
9
|
+
task build_styles: :environment do
|
|
10
|
+
system(Rails.root.join("bin/rails").to_s, "tailwindcss:build", exception: true)
|
|
11
|
+
source = Rails.root.join("app/assets/builds/tailwind.css")
|
|
12
|
+
destination = Rails.root.join("app/assets/stylesheets/plum/control_panel.css")
|
|
13
|
+
FileUtils.cp(source, destination)
|
|
14
|
+
puts "Packaged #{destination.relative_path_from(Rails.root)}"
|
|
15
|
+
end
|
|
11
16
|
end
|
|
12
17
|
end
|