intranet-core 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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