intranet-core 1.0.0

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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +38 -0
  3. data/lib/core_extensions.rb +12 -0
  4. data/lib/core_extensions/string.rb +43 -0
  5. data/lib/core_extensions/tree.rb +84 -0
  6. data/lib/core_extensions/webrick/httpresponse.rb +22 -0
  7. data/lib/intranet/abstract_responder.rb +34 -0
  8. data/lib/intranet/core.rb +125 -0
  9. data/lib/intranet/core/builder.rb +98 -0
  10. data/lib/intranet/core/haml_wrapper.rb +60 -0
  11. data/lib/intranet/core/locales.rb +47 -0
  12. data/lib/intranet/core/servlet.rb +42 -0
  13. data/lib/intranet/core/version.rb +8 -0
  14. data/lib/intranet/logger.rb +38 -0
  15. data/lib/intranet/resources/haml/http_error.haml +27 -0
  16. data/lib/intranet/resources/haml/skeleton.haml +52 -0
  17. data/lib/intranet/resources/haml/title_and_breadcrumb.haml +8 -0
  18. data/lib/intranet/resources/locales/en.yml +46 -0
  19. data/lib/intranet/resources/locales/fr.yml +46 -0
  20. data/lib/intranet/resources/www/background.jpg +0 -0
  21. data/lib/intranet/resources/www/error.png +0 -0
  22. data/lib/intranet/resources/www/favicon.ico +0 -0
  23. data/lib/intranet/resources/www/fonts/SourceSansPro.woff2 +0 -0
  24. data/lib/intranet/resources/www/nav.js +25 -0
  25. data/lib/intranet/resources/www/style.css +306 -0
  26. data/spec/core_extensions/string_spec.rb +135 -0
  27. data/spec/core_extensions/tree_spec.rb +208 -0
  28. data/spec/core_extensions/webrick/httpresponse_spec.rb +43 -0
  29. data/spec/intranet/core/fr.yml +5 -0
  30. data/spec/intranet/core/haml_wrapper_spec.rb +70 -0
  31. data/spec/intranet/core/locales_spec.rb +74 -0
  32. data/spec/intranet/core_spec.rb +403 -0
  33. data/spec/intranet/logger_spec.rb +129 -0
  34. data/spec/spec_helper.rb +50 -0
  35. data/spec/test_responder/responder.rb +42 -0
  36. data/spec/test_responder/www/style.css +5 -0
  37. metadata +218 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e210409259e3ae615be11b386d4f59eba6857577
4
+ data.tar.gz: a971e1e9e6aac78e4ca5fc5503fc47682514a587
5
+ SHA512:
6
+ metadata.gz: 99566f62fd07cc59ba83f622469b433f2cfea9e099c0d6a14192e8ab00f2fb7857f31c1b18aff1b1a42cc35945334735c28164b868cb90b2d5d2e6175473a705
7
+ data.tar.gz: 239ded33d2cb26e300ae0b6d7d8b071d84d7d5e205ade2a10c0bf443f599f201aecfb5a6fa7ddaa8523f312782d0e7bc864770da0d51fe1db35bcf2bb922d435
data/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # intranet-core
2
+
3
+ `intranet-core` provides the core component of a generic, highly customizable
4
+ intranet built around [WEBrick HTTP server](https://rubygems.org/gems/webrick).
5
+ Each section of the intranet can be provided by a different _module_, which
6
+ is basically a Webrick servlet in charge of a specific subdirectory of the
7
+ web server.
8
+
9
+ ## Usage
10
+
11
+ ### Creating a custom _module_
12
+
13
+ You can create a new _module_ by deriving from `Intranet::AbstractResponder`:
14
+
15
+ ```ruby
16
+ require 'intranet/abstract_responder'
17
+
18
+ class MyModule << Intranet::AbstractResponder
19
+ def initialize(params = {})
20
+ @params = params
21
+ end
22
+
23
+ def generate_page(path, query)
24
+ # generate HTML for the given path
25
+ end
26
+ end
27
+ ```
28
+
29
+ ### Starting the Intranet
30
+
31
+ The Intranet is controlled by the `Intranet::Core` class.
32
+
33
+ ```ruby
34
+ intranet = Intranet::Core.new
35
+ module = MyModule.new
36
+ intranet.register_module(module, 'my_module', __dir__)
37
+ intranet.start
38
+ ```
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'core_extensions/string'
4
+ require_relative 'core_extensions/tree'
5
+ require_relative 'core_extensions/webrick/httpresponse'
6
+
7
+ String.include CoreExtensions::String
8
+ WEBrick::HTTPResponse.include CoreExtensions::WEBrick::HTTPResponse
9
+
10
+ # @!visibility protected
11
+ module CoreExtensions
12
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreExtensions
4
+ # @!visibility protected
5
+ # Extension of Ruby's standard library +String+ class.
6
+ module String
7
+ # Replaces all accented characters in a string with their non-accented version.
8
+ # @return [String]
9
+ def unaccentize # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
10
+ tr('ÀÁÂÃÄÅàáâãäåĀāĂ㥹', 'AAAAAAaaaaaaAaAaAa')
11
+ .tr('ÇçĆćĈĉĊċČčÐðĎďĐđ', 'CcCcCcCcCcDdDdDd')
12
+ .tr('ÈÉÊËèéêëĒēĔĕĖėĘęĚě', 'EEEEeeeeEeEeEeEeEe')
13
+ .tr('ĜĝĞğĠġĢģĤĥĦħ', 'GgGgGgGgHhHh')
14
+ .tr('ÌÍÎÏìíîïĨĩĪīĬĭĮįİı', 'IIIIiiiiIiIiIiIiIi')
15
+ .tr('ĴĵĶķĸĹĺĻļĽľĿŀŁł', 'JjKkkLlLlLlLlLl')
16
+ .tr('ÑñŃńŅņŇňʼnŊŋ', 'NnNnNnNnnNn')
17
+ .tr('ÒÓÔÕÖØòóôõöøŌōŎŏŐő', 'OOOOOOooooooOoOoOo')
18
+ .tr('ŔŕŖŗŘřŚśŜŝŞşŠšſŢţŤťŦŧ', 'RrRrRrSsSsSsSssTtTtTt')
19
+ .tr('ÙÚÛÜùúûüŨũŪūŬŭŮůŰűŲų', 'UUUUuuuuUuUuUuUuUuUu')
20
+ .tr('ŴŵÝýÿŶŷŸŹźŻżŽž', 'WwYyyYyYZzZzZz')
21
+ .gsub(/ß/, 'ss')
22
+ .gsub(/Æ/, 'AE')
23
+ .gsub(/æ/, 'ae')
24
+ .gsub(/Œ/, 'OE')
25
+ .gsub(/œ/, 'oe')
26
+ .gsub(/IJ/, 'IJ')
27
+ .gsub(/ij/, 'ij')
28
+ end
29
+
30
+ # Converts a string to a snake-cased format suitable for URL and/or CSS attributes.
31
+ # @return [String]
32
+ def urlize
33
+ strip.unaccentize.downcase.tr(' \'', '_').delete('^-_a-z0-9')
34
+ end
35
+
36
+ # Tests whether a string is urlize-d, ie. it is only constituted of characters suitable for URL
37
+ # and/or CSS attributes.
38
+ # @return [Boolean]
39
+ def urlized?
40
+ scan(/[^-_a-z0-9]/).empty?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreExtensions
4
+ # N-ary tree data structure.
5
+ # Each node of the tree contains an +Object+ (the node value) and can be accessed from its parent
6
+ # node using an identifier +Object+. A node can be identified uniquely by the succession of
7
+ # identifiers that lead from the tree root to itself.
8
+ class Tree
9
+ # The value of the current tree node.
10
+ attr_accessor :value
11
+
12
+ # The child nodes of the current tree node.
13
+ # @return [Hash]
14
+ attr_reader :children_nodes
15
+
16
+ # Creates a new tree with a root element and no child nodes.
17
+ # @param node_value [Object] The value associated to the root element
18
+ def initialize(node_value = nil)
19
+ @value = node_value
20
+ @children_nodes = {}
21
+ end
22
+
23
+ # Queries
24
+
25
+ # Check if the current Tree node has children.
26
+ def children?
27
+ !@children_nodes.empty?
28
+ end
29
+
30
+ # Tests whether the current tree node has a specific child.
31
+ # @param child_id [Object] The unique identifier of the child.
32
+ # @return [Boolean] True if the child node identified by +child_id+ exists, False otherwise.
33
+ def child_exists?(child_id)
34
+ child_node(child_id)
35
+ true
36
+ rescue KeyError
37
+ false
38
+ end
39
+
40
+ # Retrieves the child of the current tree node.
41
+ # @param child_id [Object] The unique identifier of the child.
42
+ # @return [Object] The node value of the child identified by +child_id+.
43
+ # @raise [KeyError] If the child node identified by +child_id+ does not exist.
44
+ def child_node(child_id)
45
+ @children_nodes.fetch(child_id)
46
+ end
47
+
48
+ # Returns a hash representation of the current tree node and all its children, recursively
49
+ # (depth-first).
50
+ # @param separator [String] The separator to be used between node identifiers.
51
+ # @return [Hash]
52
+ def to_h(separator = '/', id_prefix = '')
53
+ hash = {}
54
+ id_prefix.empty? ? hash[separator] = @value : hash[id_prefix] = @value
55
+ @children_nodes.each do |id, node|
56
+ hash.merge!(node.to_h(separator, id_prefix + separator + id.to_s))
57
+ end
58
+ hash
59
+ end
60
+
61
+ # Returns the string representation of the current tree node and all its children, recursively
62
+ # (depth-first).
63
+ # @return [String]
64
+ def to_s(offset = '')
65
+ str = offset + 'VALUE: ' + @value.to_s + "\n"
66
+ @children_nodes.each do |id, node|
67
+ str += offset + ' * ID: \'' + id.to_s + '\'' + "\n"
68
+ str += node.to_s(offset + ' ')
69
+ end
70
+ str
71
+ end
72
+
73
+ # Retrieves the value associated to a child node of the current tree node, inserting it first if
74
+ # it does not exist.
75
+ # @param child_id [Object] The unique identifier for the child node to retrieve or insert.
76
+ # @param child_value [Object] The value associated to the child node to insert. Ignored if a
77
+ # child node identified by the given +child_id+ already exists.
78
+ # @return [Object] The value associated to the child node identified by the given +child_id+.
79
+ def add_child_node(child_id, child_value = nil)
80
+ @children_nodes[child_id] = Tree.new(child_value) if @children_nodes[child_id].nil?
81
+ @children_nodes[child_id]
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'htmlentities'
4
+ require 'webrick'
5
+ require_relative '../../intranet/core/haml_wrapper'
6
+
7
+ module CoreExtensions
8
+ # @!visibility protected
9
+ module WEBrick
10
+ # @!visibility protected
11
+ # Extension of +WEBrick::HTTPResponse+ to provide the hook
12
+ # +create_error_page+.
13
+ module HTTPResponse
14
+ include Intranet::Core::HamlWrapper
15
+
16
+ # Provides custom error pages for common HTTP errors.
17
+ def create_error_page
18
+ @body << to_markup('http_error', error: @status)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Intranet
4
+ # The default implementation and interface of an Intranet module.
5
+ class AbstractResponder
6
+ # Destroys the responder instance.
7
+ # This method gets called when server is shut down.
8
+ def finalize
9
+ # nothing to do
10
+ end
11
+
12
+ # Generates the HTML content associated to the given +path+ and +query+.
13
+ # @param path [String] The requested URI, relative to that module root URI
14
+ # @param query [Hash] The URI variable/value pairs, if any
15
+ # @return [Array] The HTTP return code, the MIME type and the answer body.
16
+ def generate_page(path, query)
17
+ [404, '', '']
18
+ end
19
+
20
+ # Provides the list of Cascade Style Sheets (CSS) dependencies for this module.
21
+ # If redefined, this method should probably append dependencies rather than overwriting them.
22
+ # @return [Array] The list of CSS dependencies, as URL path from server root
23
+ def css_dependencies
24
+ ['/design/style.css']
25
+ end
26
+
27
+ # Provides the list of Javascript files (JS) dependencies for this module.
28
+ # If redefined, this method should probably append dependencies rather than overwriting them.
29
+ # @return [Array] The list of JS dependencies, as URL path from server root
30
+ def js_dependencies
31
+ ['/design/nav.js']
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require_relative 'logger'
5
+ require_relative 'core/locales'
6
+ require_relative 'core/haml_wrapper'
7
+ require_relative 'core/builder'
8
+ require_relative 'core/servlet'
9
+ require_relative 'core/version'
10
+ require_relative '../core_extensions'
11
+
12
+ # The main Intranet namespace.
13
+ module Intranet
14
+ # The core of the Intranet.
15
+ class Core
16
+ # The port currently used by the Intranet.
17
+ # @return [Integer]
18
+ attr_reader :port
19
+
20
+ # Initializes a new Intranet core instance. The first available port will be used, starting at
21
+ # +preferred_port+. If +preferred_port+ port is 80 and the user has not enough priviledges to
22
+ # use that port, port 8080 (or one of the following ports) will be used.
23
+ # @param logger [Object] The logger.
24
+ # @param preferred_port [Integer] The preferred port for the web server.
25
+ def initialize(logger, preferred_port = 80)
26
+ @logger = logger
27
+
28
+ # Initialize translation module
29
+ Intranet::Core::Locales.initialize
30
+
31
+ # Initialize HAML wrapper
32
+ Intranet::Core::HamlWrapper.initialize
33
+
34
+ # Instanciate Intranet Builder and register page builders
35
+ @builder = Intranet::Core::Builder.new(@logger)
36
+
37
+ # Instanciate WebRick HTTP server
38
+ @server = load_http_server(preferred_port)
39
+ www_dir = File.join(__dir__, 'resources', 'www')
40
+ mount_default_servlets(www_dir)
41
+ end
42
+
43
+ # Registers a new module to the core. The server must not be running. If +path+ is empty, the
44
+ # module will be used as Home module. Otherwise, the module will be accessible through the
45
+ # given +path+ under the web server root.
46
+ # @param responder [Intranet::AbstractResponder] The module responder instance.
47
+ # @param path [Array] The path, relative to the web server root, representing the module root
48
+ # directory. If empty, the responder will be registered as Home responder
49
+ # (to serve /index.html in particular). Subdirectories are allowed using
50
+ # an array element for each directory level. Each element must only contain
51
+ # the following characters: +-_a-z0-9+.
52
+ # @param resources_dir [String] The path to the directory that contains additional resources
53
+ # required by the module. This directory should contain three
54
+ # subdirectories: +haml/+, +locales/+ and +www/+.
55
+ # @raise [ArgumentError] If one of the element of the +path+ contains invalid characters.
56
+ # @raise [Errno::EALREADY] If the server is already running.
57
+ def register_module(responder, path, resources_dir)
58
+ raise ArgumentError unless path.all?(&:urlized?)
59
+ raise Errno::EALREADY if @server.status != :Stop
60
+
61
+ @builder.register(responder, path)
62
+ module_add_servlet(path.empty? ? ['home'] : path, resources_dir)
63
+ module_add_locales(resources_dir)
64
+ module_add_haml_templates(resources_dir)
65
+ end
66
+
67
+ # Starts the web server.
68
+ def start
69
+ @logger.info('Intranet::Core: using locale \'' + I18n.default_locale.to_s + '\'')
70
+ @logger.info('Intranet::Core: running Intranet version ' + VERSION)
71
+ # Start serving HTTP requests
72
+ @server.start
73
+ end
74
+
75
+ # Stops the web server and finalizes all registered module responders.
76
+ def stop
77
+ @logger.info('Intranet::Runner: requesting system shutdown...')
78
+ @server.shutdown
79
+ @builder.finalize
80
+ @logger.close
81
+ end
82
+
83
+ private
84
+
85
+ # See https://github.com/nahi/webrick/blob/master/lib/webrick/accesslog.rb#L69
86
+ ACCESSLOG_FMT = "%h '%r' -> %s (%b bytes in %Ts)"
87
+
88
+ def load_http_server(preferred_port)
89
+ @port = preferred_port
90
+ begin
91
+ WEBrick::HTTPServer.new(Port: @port, Logger: @logger, AccessLog: [[@logger, ACCESSLOG_FMT]])
92
+ rescue Errno::EACCES # not enough permission to use port 80
93
+ @port = 8080
94
+ retry
95
+ rescue Errno::EADDRINUSE
96
+ @port += 1
97
+ retry
98
+ end
99
+ end
100
+
101
+ def mount_default_servlets(www_dir)
102
+ # Configure handlers for HTTP server
103
+ # Start serving www/ as HTTP server root "/" using the class WEBrick::HTTPServlet::FileHandler
104
+ # You can write your own servlet (it must inherit from WEBrick::HTTPServlet::AbstractServlet)
105
+ # http://ruby-doc.org/stdlib-1.9.3/libdoc/webrick/rdoc/WEBrick/HTTPServlet/AbstractServlet.html
106
+ # https://www.igvita.com/2007/02/13/building-dynamic-webrick-servers-in-ruby/
107
+ @server.mount('/design', WEBrick::HTTPServlet::FileHandler, www_dir)
108
+ @server.mount('/', Intranet::Core::Servlet, @builder)
109
+ end
110
+
111
+ def module_add_servlet(path, resources_dir)
112
+ @server.mount('/design/' + path.join('/'),
113
+ WEBrick::HTTPServlet::FileHandler,
114
+ File.join(resources_dir, 'www'))
115
+ end
116
+
117
+ def module_add_locales(resources_dir)
118
+ Intranet::Core::Locales.add_path(File.join(resources_dir, 'locales'))
119
+ end
120
+
121
+ def module_add_haml_templates(resources_dir)
122
+ Intranet::Core::HamlWrapper.add_path(File.join(resources_dir, 'haml'))
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'haml_wrapper'
4
+ require_relative 'version'
5
+ require_relative '../../core_extensions'
6
+
7
+ module Intranet
8
+ class Core
9
+ # @!visibility protected
10
+ # Builder for the Intranet. The builder is in charge of storing registered modules (responders
11
+ # instances) and to call the appropriate responder according to the received URL.
12
+ class Builder
13
+ include HamlWrapper
14
+
15
+ # The tree-like structure containing all registered responders (for Haml)
16
+ # @return [CoreExtensions::Tree]
17
+ attr_reader :responders
18
+
19
+ # Initializes a new builder.
20
+ # @param logger [Object] The logger.
21
+ def initialize(logger)
22
+ @logger = logger
23
+ @responders = CoreExtensions::Tree.new
24
+ end
25
+
26
+ # Finalizes the builder. Each registered responder is called for +finalize+.
27
+ def finalize
28
+ @responders.to_h.each do |path, responder|
29
+ next if responder.nil?
30
+
31
+ @logger.debug('Intranet::Builder: finalize responder at \'' + path + '\'')
32
+ responder.finalize
33
+ end
34
+ end
35
+
36
+ # Processes the given URL path and query. The corresponding responder is called to get the
37
+ # page content. If no responder can handle the request, HTTP error 404 is returned. If the
38
+ # responder returns a partial HTML content (HTTP error 206), it is assumed to be the page
39
+ # body and integrated into the Intranet template.
40
+ # @param path [String] The requested path, relative to the web server root. This path is
41
+ # supposed secured and normalized (no '../' in particular).
42
+ # @param query [Hash] The content of the GET parameters of the URL.
43
+ # @return [Array] The HTTP return code, the MIME type and the answer body.
44
+ def do_get(path, query = {})
45
+ resp, responder_path = get_responder(path)
46
+ status, mime_type, body = resp.generate_page(responder_path, query)
47
+
48
+ # Generate header and footer when partial content is returned by the responder
49
+ if status == 206 && mime_type == 'text/html'
50
+ body = to_markup('skeleton', is_home: path == '/index.html', body: body,
51
+ css: resp.css_dependencies, js: resp.js_dependencies)
52
+ status = 200
53
+ end
54
+ [status, mime_type, body]
55
+ rescue KeyError, NoMethodError
56
+ [404, '', '']
57
+ end
58
+
59
+ # Registers a new responder. If a responder is already registered with the same path, the new
60
+ # one overrides the old one.
61
+ # @param responder [Intranet::AbstractResponder] The responder instance of the module.
62
+ # @param path [Array] The path, relative to the web server root, representing the module root
63
+ # directory. If empty, the responder will be registered as Home responder
64
+ # (to serve /index.html in particular). Subdirectories are allowed using
65
+ # an array element for each directory level.
66
+ # @raise [ArgumentError] If one of the element of the +path+ contains invalid characters.
67
+ def register(responder, path = [])
68
+ raise ArgumentError unless path.all?(&:urlized?)
69
+
70
+ current_node = @responders
71
+ path.each do |part|
72
+ next if part.empty? || part == '.'
73
+
74
+ current_node = current_node.add_child_node(part)
75
+ end
76
+ current_node.value = responder
77
+ end
78
+
79
+ private
80
+
81
+ # Get the responder instance associated with the given path.
82
+ # @param path [String] The absolute URL path.
83
+ # @return [Array] The responder instance (possibly nil) and the remaining of the URL path that
84
+ # has not been parsed.
85
+ def get_responder(path)
86
+ current_treenode = @responders
87
+ relative_path = path[1..-1].split('/').delete_if do |part|
88
+ if current_treenode.child_exists?(part)
89
+ current_treenode = current_treenode.child_node(part)
90
+ true
91
+ end
92
+ end
93
+
94
+ [current_treenode.value, '/' + relative_path.join('/')]
95
+ end
96
+ end
97
+ end
98
+ end