floss_funding 1.0.0.pre.alpha.1
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +135 -0
- data/CONTRIBUTING.md +134 -0
- data/LICENSE.txt +19 -0
- data/README.md +507 -0
- data/SECURITY.md +21 -0
- data/lib/floss_funding/check.rb +197 -0
- data/lib/floss_funding/config.rb +215 -0
- data/lib/floss_funding/poke.rb +111 -0
- data/lib/floss_funding/under_bar.rb +57 -0
- data/lib/floss_funding/version.rb +10 -0
- data/lib/floss_funding.rb +301 -0
- data.tar.gz.sig +0 -0
- metadata +267 -0
- metadata.gz.sig +3 -0
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# std libs
|
4
|
+
require "openssl"
|
5
|
+
|
6
|
+
module FlossFunding
|
7
|
+
# This module loads inside an anonymous module on Ruby 3.1+.
|
8
|
+
# This is why FlossFunding herein uses top-level namespace as `::FlossFunding`.
|
9
|
+
module Check
|
10
|
+
# When this module is included, extend the target with class-level helpers
|
11
|
+
# and set the deterministic time source (used in specs via Timecop).
|
12
|
+
#
|
13
|
+
# @param base [Module] the including module
|
14
|
+
# @param now [Time] the current time (defaults to Time.now)
|
15
|
+
# @return [void]
|
16
|
+
def self.included(base, now = Time.now)
|
17
|
+
base.extend(ClassMethods)
|
18
|
+
ClassMethods.now_time = now
|
19
|
+
end
|
20
|
+
|
21
|
+
# When this module is extended, also extend with class-level helpers and
|
22
|
+
# set the deterministic time source.
|
23
|
+
#
|
24
|
+
# @param base [Module] the extending module
|
25
|
+
# @param now [Time] the current time (defaults to Time.now)
|
26
|
+
# @return [void]
|
27
|
+
def self.extended(base, now = Time.now)
|
28
|
+
base.extend(ClassMethods)
|
29
|
+
ClassMethods.now_time = now
|
30
|
+
end
|
31
|
+
|
32
|
+
# Class-level API used by FlossFunding::Poke to perform activation checks
|
33
|
+
# and generate user-facing messages. Methods here are intended for inclusion
|
34
|
+
# into client libraries when they `extend FlossFunding::Check`.
|
35
|
+
module ClassMethods
|
36
|
+
class << self
|
37
|
+
# Time source used for month arithmetic and testing.
|
38
|
+
# @return [Time]
|
39
|
+
attr_accessor :now_time
|
40
|
+
end
|
41
|
+
|
42
|
+
# Decrypts a hex-encoded activation key using a namespace-derived key.
|
43
|
+
#
|
44
|
+
# @param activation_key [String] 64-character hex string for paid activation
|
45
|
+
# @param namespace [String] the namespace used to derive the cipher key
|
46
|
+
# @return [String, false] plaintext activation key (base word) on success; false if empty
|
47
|
+
def floss_funding_decrypt(activation_key, namespace)
|
48
|
+
return false if activation_key.empty?
|
49
|
+
|
50
|
+
cipher = OpenSSL::Cipher.new("aes-256-cbc").decrypt
|
51
|
+
cipher.key = Digest::MD5.hexdigest(namespace)
|
52
|
+
s = [activation_key].pack("H*")
|
53
|
+
|
54
|
+
cipher.update(s) + cipher.final
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns true for unpaid or opted-out activation_key that
|
58
|
+
# should not emit any console output (silent success).
|
59
|
+
# Otherwise false.
|
60
|
+
#
|
61
|
+
# @param activation_key [String]
|
62
|
+
# @param namespace [String]
|
63
|
+
# @return [Boolean]
|
64
|
+
def check_unpaid_silence(activation_key, namespace)
|
65
|
+
case activation_key
|
66
|
+
when ::FlossFunding::FREE_AS_IN_BEER, ::FlossFunding::BUSINESS_IS_NOT_GOOD_YET, "#{::FlossFunding::NOT_FINANCIALLY_SUPPORTING}-#{namespace}"
|
67
|
+
# Configured as unpaid
|
68
|
+
true
|
69
|
+
else
|
70
|
+
# Might be configured as paid
|
71
|
+
false
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Returns the list of valid plain text base words for the current month window.
|
76
|
+
#
|
77
|
+
# @return [Array<String>]
|
78
|
+
def base_words
|
79
|
+
::FlossFunding.base_words(num_valid_words_for_month)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Checks whether the given plaintext matches a valid plaintext base word.
|
83
|
+
#
|
84
|
+
# @param plain_text [String]
|
85
|
+
# @return [Boolean]
|
86
|
+
def check_activation(plain_text)
|
87
|
+
words = base_words
|
88
|
+
# Use fast binary search when available (Ruby >= 2.0), else fall back to include?
|
89
|
+
# We can't run CI on Ruby < 2.3 so the alternate branch is not going to have test coverage.
|
90
|
+
# :nocov:
|
91
|
+
if words.respond_to?(:bsearch)
|
92
|
+
!!words.bsearch { |word| plain_text == word }
|
93
|
+
else
|
94
|
+
words.include?(plain_text)
|
95
|
+
end
|
96
|
+
# :nocov:
|
97
|
+
end
|
98
|
+
|
99
|
+
# Entry point for activation key evaluation and output behavior.
|
100
|
+
#
|
101
|
+
# @param activation_key [String] value from ENV
|
102
|
+
# @param namespace [String] this activation key is valid for a specific namespace; can cover multiple projects / gems
|
103
|
+
# @param env_var_name [String] the ENV variable name checked
|
104
|
+
# @return [void]
|
105
|
+
def floss_funding_initiate_begging(activation_key, namespace, env_var_name)
|
106
|
+
if activation_key.empty?
|
107
|
+
# No activation key provided
|
108
|
+
::FlossFunding.add_unactivated(namespace)
|
109
|
+
return start_begging(namespace, env_var_name)
|
110
|
+
end
|
111
|
+
|
112
|
+
# A silent short circuit for valid unpaid activations
|
113
|
+
if check_unpaid_silence(activation_key, namespace)
|
114
|
+
::FlossFunding.add_activated(namespace)
|
115
|
+
::FlossFunding.add_activation_occurrence(namespace)
|
116
|
+
return
|
117
|
+
end
|
118
|
+
|
119
|
+
valid_activation_hex = !!(activation_key =~ ::FlossFunding::HEX_LICENSE_RULE)
|
120
|
+
unless valid_activation_hex
|
121
|
+
# Invalid activation key format
|
122
|
+
::FlossFunding.add_unactivated(namespace)
|
123
|
+
return start_coughing(activation_key, namespace, env_var_name)
|
124
|
+
end
|
125
|
+
|
126
|
+
# decrypt the activation key for this namespace
|
127
|
+
plain_text = floss_funding_decrypt(activation_key, namespace)
|
128
|
+
|
129
|
+
# A silent short circuit for valid paid activation keys
|
130
|
+
if check_activation(plain_text)
|
131
|
+
::FlossFunding.add_activated(namespace)
|
132
|
+
::FlossFunding.add_activation_occurrence(namespace)
|
133
|
+
return
|
134
|
+
end
|
135
|
+
|
136
|
+
# No valid activation key found
|
137
|
+
::FlossFunding.add_unactivated(namespace)
|
138
|
+
start_begging(namespace, env_var_name)
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
# Using the month gem to easily do month math.
|
144
|
+
#
|
145
|
+
# @return [Integer] number of valid words based on the month offset
|
146
|
+
def num_valid_words_for_month
|
147
|
+
now_month - ::FlossFunding::START_MONTH
|
148
|
+
end
|
149
|
+
|
150
|
+
# Returns the Month integer for the configured time source.
|
151
|
+
#
|
152
|
+
# @return [Integer]
|
153
|
+
def now_month
|
154
|
+
Month.new(ClassMethods.now_time.year, ClassMethods.now_time.month).to_i
|
155
|
+
end
|
156
|
+
|
157
|
+
# Emits a diagnostic message for invalid activation key format.
|
158
|
+
#
|
159
|
+
# @param activation_key [String]
|
160
|
+
# @param namespace [String]
|
161
|
+
# @param env_var_name [String]
|
162
|
+
# @return [void]
|
163
|
+
def start_coughing(activation_key, namespace, env_var_name)
|
164
|
+
puts <<-COUGHING
|
165
|
+
==============================================================
|
166
|
+
COUGH, COUGH.
|
167
|
+
Ahem, it appears as though you tried to set an activation key
|
168
|
+
for #{namespace}, but it was invalid.
|
169
|
+
|
170
|
+
Current (Invalid) Activation Key: #{activation_key}
|
171
|
+
Namespace: #{namespace}
|
172
|
+
ENV Variable: #{env_var_name}
|
173
|
+
|
174
|
+
Paid activation keys are 8 bytes, 64 hex characters, long.
|
175
|
+
Unpaid activation keys have varying lengths, depending on type and namespace.
|
176
|
+
Yours is #{activation_key.length} characters long, and doesn't match any paid or unpaid keys.
|
177
|
+
|
178
|
+
Please unset the current ENV variable #{env_var_name}, since it is invalid.
|
179
|
+
|
180
|
+
Then find the correct one, or get a new one @ https://floss-funding.dev and set it.
|
181
|
+
|
182
|
+
#{FlossFunding::FOOTER}
|
183
|
+
COUGHING
|
184
|
+
end
|
185
|
+
|
186
|
+
# Emits the standard friendly funding message for unactivated usage.
|
187
|
+
#
|
188
|
+
# @param namespace [String]
|
189
|
+
# @param env_var_name [String]
|
190
|
+
# @return [void]
|
191
|
+
def start_begging(namespace, env_var_name)
|
192
|
+
# During load, only emit a single-line note and defer the large blurb to at_exit
|
193
|
+
puts %(FLOSS Funding: Activation key missing for #{namespace}. Set ENV[#{env_var_name}] to activation key; details will be shown at exit.)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "rubygems" # For Gem::Specification
|
5
|
+
|
6
|
+
module FlossFunding
|
7
|
+
# Handles configuration loading from a .floss_funding.yml file located at the
|
8
|
+
# root of the including project (heuristically discovered by walking upward
|
9
|
+
# from the including file path until a Gemfile or *.gemspec is found).
|
10
|
+
#
|
11
|
+
# All APIs in this module require the including file path (e.g., `__FILE__`).
|
12
|
+
#
|
13
|
+
# The loaded config is merged over DEFAULT_CONFIG, so any unspecified keys fall
|
14
|
+
# back to defaults.
|
15
|
+
module Config
|
16
|
+
# The file name to look for in the project root.
|
17
|
+
# @return [String]
|
18
|
+
CONFIG_FILE_NAME = ".floss_funding.yml"
|
19
|
+
|
20
|
+
# Default configuration values for FlossFunding prompting.
|
21
|
+
# Also includes slots for gemspec-derived attributes we track per gem.
|
22
|
+
# @return [Hash{String=>Object}]
|
23
|
+
DEFAULT_CONFIG = {
|
24
|
+
"suggested_donation_amount" => [5],
|
25
|
+
"floss_funding_url" => ["https://floss-funding.dev"],
|
26
|
+
# Optional namespace override for when including without explicit namespace
|
27
|
+
# When set (non-empty string), this will be used as the namespace instead of the including module's name
|
28
|
+
"namespace" => [],
|
29
|
+
# Track both the base.name namespace(s) and any custom namespace(s) passed to Poke.new
|
30
|
+
"base_namespaces" => [],
|
31
|
+
"custom_namespaces" => [],
|
32
|
+
# Gemspec-derived attributes (nil when unknown)
|
33
|
+
"gem_name" => [],
|
34
|
+
"homepage" => [],
|
35
|
+
"authors" => [],
|
36
|
+
"funding_uri" => [],
|
37
|
+
}.freeze
|
38
|
+
|
39
|
+
class << self
|
40
|
+
# Loads configuration from .floss_funding.yml by walking up from the
|
41
|
+
# provided including file path to discover the project root.
|
42
|
+
#
|
43
|
+
# @param including_path [String] the including file path (e.g., __FILE__)
|
44
|
+
# @return [Hash{String=>Object}] configuration hash with defaults merged
|
45
|
+
# @raise [::FlossFunding::Error] if including_path is not a String
|
46
|
+
def load_config(including_path)
|
47
|
+
unless including_path.is_a?(String)
|
48
|
+
raise ::FlossFunding::Error, "including must be a String file path (e.g., __FILE__), got #{including_path.class}"
|
49
|
+
end
|
50
|
+
|
51
|
+
# Discover project root (Gemfile or *.gemspec)
|
52
|
+
project_root = find_project_root(including_path)
|
53
|
+
|
54
|
+
# Load YAML config if present (respect test stubs of find_config_file)
|
55
|
+
config_file = find_config_file(including_path)
|
56
|
+
raw_config = config_file ? load_yaml_file(config_file) : {}
|
57
|
+
|
58
|
+
# Strict filter: only allow known string keys, then normalize to arrays
|
59
|
+
filtered = {}
|
60
|
+
raw_config.each do |k, v|
|
61
|
+
next unless k.is_a?(String)
|
62
|
+
next unless DEFAULT_CONFIG.key?(k)
|
63
|
+
filtered[k] = normalize_to_array(v)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Load gemspec data for defaults if available
|
67
|
+
gemspec_data = project_root ? read_gemspec_data(project_root) : {}
|
68
|
+
# Prepare defaults from gemspec:
|
69
|
+
# - Store all gemspec attributes into config slots, as arrays
|
70
|
+
# - If floss_funding_url not set in YAML, default to gemspec funding_uri
|
71
|
+
gemspec_defaults = {}
|
72
|
+
unless gemspec_data.empty?
|
73
|
+
gemspec_defaults["gem_name"] = normalize_to_array(gemspec_data[:name]) # name is required by rubygems
|
74
|
+
gemspec_defaults["homepage"] = normalize_to_array(gemspec_data[:homepage]) if gemspec_data[:homepage]
|
75
|
+
gemspec_defaults["authors"] = normalize_to_array(gemspec_data[:authors]) # authors defaults to []
|
76
|
+
gemspec_defaults["funding_uri"] = normalize_to_array(gemspec_data[:funding_uri]) if gemspec_data[:funding_uri]
|
77
|
+
if gemspec_data[:funding_uri] && !filtered.key?("floss_funding_url")
|
78
|
+
gemspec_defaults["floss_funding_url"] = normalize_to_array(gemspec_data[:funding_uri])
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Merge precedence: DEFAULT < gemspec_defaults, with filtered_yaml overriding entirely when present
|
83
|
+
merged = {}
|
84
|
+
DEFAULT_CONFIG.keys.each do |key|
|
85
|
+
if filtered.key?(key)
|
86
|
+
# YAML-provided known string keys take full precedence (override defaults and gemspec values)
|
87
|
+
merged[key] = Array(filtered[key]).compact.flatten.uniq
|
88
|
+
else
|
89
|
+
# Otherwise, start from defaults and enrich with gemspec-derived values when available
|
90
|
+
merged[key] = []
|
91
|
+
merged[key].concat(Array(DEFAULT_CONFIG[key]))
|
92
|
+
merged[key].concat(Array(gemspec_defaults[key])) if gemspec_defaults.key?(key)
|
93
|
+
merged[key] = merged[key].compact.flatten.uniq
|
94
|
+
end
|
95
|
+
end
|
96
|
+
merged
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
# Finds the configuration file by walking up from including_path looking for
|
102
|
+
# a .floss_funding.yml file. This works for gems, Bundler projects, and
|
103
|
+
# plain Ruby projects without Gemfile/gemspec.
|
104
|
+
#
|
105
|
+
# @param including_path [String] the including file path
|
106
|
+
# @return [String, nil] absolute path to the config file or nil if not found
|
107
|
+
def find_config_file(including_path)
|
108
|
+
begin
|
109
|
+
start_dir = File.dirname(File.expand_path(including_path))
|
110
|
+
current_dir = start_dir
|
111
|
+
|
112
|
+
# Determine an upper boundary for search to avoid leaking configs from unrelated parents.
|
113
|
+
project_root = find_project_root(including_path)
|
114
|
+
|
115
|
+
# Prefer the directory that looks like the local project root (parent of lib)
|
116
|
+
lib_root = (File.basename(start_dir) == "lib") ? File.dirname(start_dir) : nil
|
117
|
+
|
118
|
+
boundary_dir = lib_root || project_root
|
119
|
+
|
120
|
+
# If we couldn't determine any reasonable boundary (very unusual), limit the
|
121
|
+
# search to the including file's directory and its immediate parent. This
|
122
|
+
# still allows patterns like lib/... including a config placed at the
|
123
|
+
# project root (parent of lib), while preventing accidental pickup of
|
124
|
+
# higher-level fixture/config files.
|
125
|
+
if boundary_dir.nil?
|
126
|
+
parent_once = File.dirname(start_dir)
|
127
|
+
while current_dir && current_dir != "/"
|
128
|
+
candidate = File.join(current_dir, CONFIG_FILE_NAME)
|
129
|
+
return candidate if File.exist?(candidate)
|
130
|
+
break if current_dir == parent_once
|
131
|
+
current_dir = File.dirname(current_dir)
|
132
|
+
end
|
133
|
+
return
|
134
|
+
end
|
135
|
+
|
136
|
+
# Search upward until the boundary (inclusive)
|
137
|
+
while current_dir && current_dir != "/"
|
138
|
+
candidate = File.join(current_dir, CONFIG_FILE_NAME)
|
139
|
+
return candidate if File.exist?(candidate)
|
140
|
+
break if current_dir == boundary_dir
|
141
|
+
current_dir = File.dirname(current_dir)
|
142
|
+
end
|
143
|
+
rescue
|
144
|
+
# Fall through to nil when any unexpected error happens
|
145
|
+
end
|
146
|
+
nil
|
147
|
+
end
|
148
|
+
|
149
|
+
# Attempts to find the root directory of the project that included
|
150
|
+
# FlossFunding::Poke by starting from the including file path and walking
|
151
|
+
# up the directory tree until a Gemfile or *.gemspec is found.
|
152
|
+
#
|
153
|
+
# @param including_path [String] the including file path
|
154
|
+
# @return [String, nil] the discovered project root directory or nil
|
155
|
+
def find_project_root(including_path)
|
156
|
+
begin
|
157
|
+
current_dir = File.dirname(File.expand_path(including_path))
|
158
|
+
while current_dir && current_dir != "/"
|
159
|
+
return current_dir if Dir.glob(File.join(current_dir, "*.gemspec")).any? || File.exist?(File.join(current_dir, "Gemfile"))
|
160
|
+
current_dir = File.dirname(current_dir)
|
161
|
+
end
|
162
|
+
rescue
|
163
|
+
nil
|
164
|
+
end
|
165
|
+
nil
|
166
|
+
end
|
167
|
+
|
168
|
+
# Loads and parses a YAML file from disk.
|
169
|
+
#
|
170
|
+
# @param file_path [String] absolute path to the YAML file
|
171
|
+
# @return [Hash] parsed YAML content or empty hash if parsing fails
|
172
|
+
def load_yaml_file(file_path)
|
173
|
+
begin
|
174
|
+
YAML.load_file(file_path) || {}
|
175
|
+
rescue
|
176
|
+
# If there's any error loading the file, return an empty hash
|
177
|
+
{}
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Reads gemspec data from the first *.gemspec in project_root using
|
182
|
+
# RubyGems API, and extracts fields of interest.
|
183
|
+
# @param project_root [String]
|
184
|
+
# @return [Hash] keys: :name, :homepage, :authors, :funding_uri
|
185
|
+
def read_gemspec_data(project_root)
|
186
|
+
gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).first
|
187
|
+
return {} unless gemspec_path
|
188
|
+
begin
|
189
|
+
spec = Gem::Specification.load(gemspec_path)
|
190
|
+
return {} unless spec
|
191
|
+
metadata = spec.metadata || {}
|
192
|
+
funding_uri = metadata["funding_uri"] || metadata[:funding_uri]
|
193
|
+
{
|
194
|
+
:name => spec.name,
|
195
|
+
:homepage => spec.homepage,
|
196
|
+
:authors => spec.authors,
|
197
|
+
:funding_uri => funding_uri,
|
198
|
+
}
|
199
|
+
rescue StandardError
|
200
|
+
{}
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Normalize a value from YAML or gemspec to an array.
|
205
|
+
# - nil => []
|
206
|
+
# - array => same array
|
207
|
+
# - scalar => [scalar]
|
208
|
+
def normalize_to_array(value)
|
209
|
+
return [] if value.nil?
|
210
|
+
return value.compact if value.is_a?(Array)
|
211
|
+
[value]
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FlossFunding
|
4
|
+
# Public API for including FlossFunding into your library/module.
|
5
|
+
#
|
6
|
+
# Usage patterns:
|
7
|
+
#
|
8
|
+
# 1. Traditional namespace (uses the including module's name):
|
9
|
+
#
|
10
|
+
# module MyGemLibrary
|
11
|
+
# include FlossFunding::Poke.new(__FILE__)
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# 2. Arbitrary custom namespace (can add version, or anything else):
|
15
|
+
#
|
16
|
+
# module MyGemLibrary
|
17
|
+
# include FlossFunding::Poke.new(__FILE__, "Custom::Namespace::V4")
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# In all cases, the first parameter must be a String file path (e.g., `__FILE__`).
|
21
|
+
module Poke
|
22
|
+
# Use class << self for defining class methods
|
23
|
+
class << self
|
24
|
+
# Hook invoked when including FlossFunding::Poke directly.
|
25
|
+
#
|
26
|
+
# Direct inclusion is not supported; always use `Poke.new(__FILE__, ...)`.
|
27
|
+
#
|
28
|
+
# @param base [Module] the target including module
|
29
|
+
# @raise [::FlossFunding::Error] always, instructing correct usage
|
30
|
+
def included(base)
|
31
|
+
raise ::FlossFunding::Error, "Do not include FlossFunding::Poke directly. Use include FlossFunding::Poke.new(__FILE__, optional_namespace, optional_env_prefix)."
|
32
|
+
end
|
33
|
+
|
34
|
+
# Builds a module suitable for inclusion which sets up FlossFunding.
|
35
|
+
#
|
36
|
+
# @param including_path [String] the including file path (e.g., `__FILE__`)
|
37
|
+
# @param namespace [String, nil] optional custom namespace for activation key
|
38
|
+
# @param env_prefix [String, nil] optional ENV var prefix; defaults to
|
39
|
+
# FlossFunding::UnderBar::DEFAULT_PREFIX when nil
|
40
|
+
# @return [Module] a module that can be included into your namespace
|
41
|
+
def new(including_path, namespace = nil, env_prefix = nil)
|
42
|
+
Module.new do
|
43
|
+
define_singleton_method(:included) do |base|
|
44
|
+
FlossFunding::Poke.setup_begging(base, namespace, env_prefix, including_path)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Performs common setup: extends the base with Check, computes the
|
50
|
+
# namespace and ENV var name, loads configuration, and initiates begging.
|
51
|
+
#
|
52
|
+
# @param base [Module] the module including the returned Poke module
|
53
|
+
# @param custom_namespace [String, nil] custom namespace or nil to use base.name
|
54
|
+
# @param env_prefix [String, nil] ENV var prefix or default when nil
|
55
|
+
# @param including_path [String] source file path of base (e.g., `__FILE__`)
|
56
|
+
# @return [void]
|
57
|
+
# @raise [::FlossFunding::Error] if including_path is not a String
|
58
|
+
# @raise [::FlossFunding::Error] if base.name is not a String
|
59
|
+
def setup_begging(base, custom_namespace, env_prefix, including_path)
|
60
|
+
unless including_path.is_a?(String)
|
61
|
+
raise ::FlossFunding::Error, "including_path must be a String file path (e.g., __FILE__), got #{including_path.class}"
|
62
|
+
end
|
63
|
+
unless base.respond_to?(:name) && base.name && base.name.is_a?(String)
|
64
|
+
raise ::FlossFunding::Error, "base must have a name (e.g., MyGemLibrary), got #{base.inspect}"
|
65
|
+
end
|
66
|
+
|
67
|
+
require "floss_funding/check"
|
68
|
+
# Extend the base with the checker module first
|
69
|
+
base.extend(::FlossFunding::Check)
|
70
|
+
|
71
|
+
# Load configuration from .floss_funding.yml if it exists
|
72
|
+
config = ::FlossFunding::Config.load_config(including_path)
|
73
|
+
|
74
|
+
# Three data points needed:
|
75
|
+
# 1. namespace (derived from the base class name, config, or param)
|
76
|
+
# 2. ENV variable name (derived from namespace)
|
77
|
+
# 3. activation key (derived from ENV variable)
|
78
|
+
namespace =
|
79
|
+
if custom_namespace && !custom_namespace.empty?
|
80
|
+
custom_namespace
|
81
|
+
else
|
82
|
+
base.name
|
83
|
+
end
|
84
|
+
|
85
|
+
# Track both base.name and the custom namespace (if provided) in the configuration arrays
|
86
|
+
config["base_namespaces"] ||= []
|
87
|
+
config["base_namespaces"] << base.name
|
88
|
+
config["custom_namespaces"] ||= []
|
89
|
+
config["custom_namespaces"] << custom_namespace if custom_namespace && !custom_namespace.empty?
|
90
|
+
# Deduplicate
|
91
|
+
config["base_namespaces"] = config["base_namespaces"].flatten.uniq
|
92
|
+
config["custom_namespaces"] = config["custom_namespaces"].flatten.uniq
|
93
|
+
|
94
|
+
env_var_name = ::FlossFunding::UnderBar.env_variable_name(
|
95
|
+
{
|
96
|
+
:prefix => env_prefix,
|
97
|
+
:namespace => namespace,
|
98
|
+
},
|
99
|
+
)
|
100
|
+
activation_key = ENV.fetch(env_var_name, "")
|
101
|
+
|
102
|
+
# Store configuration and ENV var name under the effective namespace
|
103
|
+
::FlossFunding.set_configuration(namespace, config)
|
104
|
+
::FlossFunding.set_env_var_name(namespace, env_var_name)
|
105
|
+
|
106
|
+
# Now call the begging method after extending
|
107
|
+
base.floss_funding_initiate_begging(activation_key, namespace, env_var_name)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module FlossFunding
|
2
|
+
# Utilities to convert Ruby namespaces to safe, uppercased, underscore forms
|
3
|
+
# for environment variable names. Protects against malicious or invalid class
|
4
|
+
# names via conservative character rules.
|
5
|
+
#
|
6
|
+
# See also: https://github.com/galtzo-floss/shields-badge/blob/main/lib/shields/badge.rb
|
7
|
+
module UnderBar
|
8
|
+
# Default ENV prefix used when none is provided.
|
9
|
+
# @return [String]
|
10
|
+
DEFAULT_PREFIX = "FLOSS_FUNDING_"
|
11
|
+
|
12
|
+
# Allowed characters for a single namespace segment. Max length 256 to avoid abuse.
|
13
|
+
# @return [Regexp]
|
14
|
+
SAFE_TO_UNDERSCORE = /\A[\p{UPPERCASE-LETTER}\p{LOWERCASE-LETTER}\p{DECIMAL-NUMBER}]{1,256}\Z/
|
15
|
+
|
16
|
+
# Pattern to insert underscores before capital letters.
|
17
|
+
# @return [Regexp]
|
18
|
+
SUBBER_UNDER = /(\p{UPPERCASE-LETTER})/
|
19
|
+
|
20
|
+
# Pattern for a leading underscore to be removed after transformation.
|
21
|
+
# @return [Regexp]
|
22
|
+
INITIAL_UNDERSCORE = /^_/
|
23
|
+
|
24
|
+
class << self
|
25
|
+
# Builds an uppercased ENV variable name from a Ruby namespace.
|
26
|
+
#
|
27
|
+
# @param opts [Hash]
|
28
|
+
# @option opts [String] :namespace (required) the Ruby namespace (e.g., "My::Lib")
|
29
|
+
# @option opts [String] :prefix optional ENV name prefix (defaults to DEFAULT_PREFIX)
|
30
|
+
# @return [String] the resulting ENV variable name
|
31
|
+
# @raise [FlossFunding::Error] when :namespace is not a String or contains invalid characters
|
32
|
+
def env_variable_name(opts = {})
|
33
|
+
namespace = opts[:namespace]
|
34
|
+
prefix = opts[:prefix] || DEFAULT_PREFIX
|
35
|
+
raise FlossFunding::Error, "namespace must be a String, but is #{namespace.class}" unless namespace.is_a?(String)
|
36
|
+
|
37
|
+
name_parts = namespace.split("::")
|
38
|
+
env_name = name_parts.map { |np| to_under_bar(np) }.join("_")
|
39
|
+
"#{prefix}#{env_name}".upcase
|
40
|
+
end
|
41
|
+
|
42
|
+
# Converts a single namespace segment to an underscored, uppercased string.
|
43
|
+
#
|
44
|
+
# @param string [String] the namespace segment to convert
|
45
|
+
# @return [String] an uppercased, underscore-separated representation
|
46
|
+
# @raise [FlossFunding::Error] when the string contains invalid characters or is too long
|
47
|
+
def to_under_bar(string)
|
48
|
+
safe = string[SAFE_TO_UNDERSCORE]
|
49
|
+
raise FlossFunding::Error, "Invalid! Each part of klass name must match #{SAFE_TO_UNDERSCORE}: #{safe} (#{safe.class}) != #{string[0..255]} (#{string.class})" unless safe == string.to_s
|
50
|
+
|
51
|
+
underscored = safe.gsub(SUBBER_UNDER) { "_#{$1}" }
|
52
|
+
shifted_leading_underscore = underscored.sub(INITIAL_UNDERSCORE, "")
|
53
|
+
shifted_leading_underscore.upcase
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|