-
Notifications
You must be signed in to change notification settings - Fork 0
Quickstart tutorial ‐ create a website using Zurfbirb
Here we will set up our first website using Zurfbirb.
It will be a random color generator with the ability to add comments, favorite and unfavorite colors, and show the user their sequence of browsing.
Requirements
First of all, it is assumed you have installed Zurfbirb either remotely (recommended - easiest) or locally, AND that you can see the default Zurfbirb home page. If this is not the case, go here https://github.com/verachell/Zurfbirb/wiki to do it and come back here after.
It is recommended you do this step first time around if you think you may want to use tailwind in the future. However, this tutorial works OK without Tailwind - the layout just is not so nice. If you wish to do this part, follow the instructions for installing tailwind with Zurfbirb at https://github.com/verachell/Zurfbirb/wiki/Using-Tailwind-v-4.x-with-Zurfbirb/ and come back here after.
Now that you have installed tailwind, open a browser window and navigate to your home page again (hit shift-reload if nothing changes). The main page is designed such that if you have tailwind installed as above with a css file out.css
in assets/styles
, the default home page will show with one line of text highlighted in yellow background. Tip: If you don't want to use tailwind, simply use a different CSS file in assets/styles and reference it as the stylesheet.
For the code in this tutorial, we will be using tailwind css as installed above. That's NOT an important part of the tutorial - except for the menu which we will get to later. So you may ignore the styles (or in the case of the menu, substitute your own static stylesheet and classes instead).
First, let’s look at config/config.rb
and make sure everything looks sensible. Feel free to inspect it. In particular, check the first line for DOC_ROOT
which for a remote server was generated by the install script, or for local install was defined by you. it should reflect the mysite directory (or whatever your document root is) e.g. DOC_ROOT = "/home/newuser/public_html/mysite"
If it does not look correct (it should already be correct on a remote server), then edit it manually. No trailing slash.
We will use some of these configurable things in the creation of this websites, so we will learn as we go. So far, everything is fine, but we want our home page not to be the default one.
We want the homepage to be our random color generator.
The best way to do this is to create a new file in the routed
directory. Let's call it homegen.rb
Put these contents in the file:
doctype_html
open_head
title("Random color generator")
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
close_head
open_body
h1("Random color generator", :class =>"text-3xl text-violet-500")
pg("Welcome to the random color generator!")
close_body
Notice that all of these lines are actually ruby methods. These ones happen to be part of the Zurfbirb core, but you are welcome to use any ruby methods in your code. You may also install additional gems and use methods from those. The html-generating functions from Zurfbirb in the code above such as open_head
, link
, h1
, pg
have the option to put an arbitrary number of arguments with them, but any printed text has to be placed as the first argument(s) in that method call. Hash arguments refer to html attributes e.g.
h1("Random color generator", :class =>"text-3xl text-violet-500")
You don’t have to have any printed text at all on these, e.g.
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
Zurfbirb does not try to police what you are doing when calling the html-generating methods, so you’re free to do what you want. It will let you put any attributes on any html generating function. So for example, you could technically put :rel => "applepie"
instead of :rel => "stylesheet"
. It wouldn’t make sense to the browser, but it is assumed that you know what html you are trying to generate. Thus, if new attributes become available in official HTML specs one day, you will be able to use them in Zurfbirb straight away.
Anyway, once you have the text pasted into homegen.rb
go to config/config.rb
and edit this line:
$relate_root = {"/" => "routed/default_homepage.rb"}
switch out routed/default_homepage.rb
to routed/homegen.rb
You can add as many other key-value pairs as you like in the hash, which are basically url-filepath pairs. In this way, you can match any url to any ruby file (later we will come to additional ways you can do this). For now, we just want the one above.
Then navigate to your home page. Oh no! Your stylesheet doesn’t seem to have caught up. The h1 text should be large and in purple. You could run the ./tailwindcss
... command again whenever you make style changes, but I find it more efficient to have a separate terminal window that you can leave tailwind on the --watch
option so it will update automatically. If you are doing it locally, open a new terminal window for this. If you are on DIgitalOcean, I recommend using your droplet console window (the very first one you had for this) - if its gone inactive, launch a new one. If you use Clouding.io, just use the terminal window that you ssh'd into the server with. Then navigate to your styles/assets directory in the console (e.g. cd /home/newuser/public_html/mysite/styles/assets
) and put
./tailwindcss -i in.css -o out.css --watch
Alternatively just use a different static stylesheet such as simple.css that doesn’t need updating. w3.css is another easy option that won’t need updating when you use a new class. We will continue with Tailwind for the purposes of this tutorial, but we will be using it minimally just to get the hang of how styling works in general.
Anyway, now that you have regenerated the stylesheet (and hopefully put it on watch), load the page again (you may need shift-reload). You should see the title in violet.
We want to build the random color generator and generate colors. We also want to add a couple of extra pages, such as About and Privacy Policy (we will introduce you to the rudimentary templating system for the latter two pages).
But first, let’s do some DRYing and take out parts that we don’t want to repeatedly type every time we create a new page. In this case, it’s the head section.
Cut out of homegen.rb
these lines:
doctype_html
open_head
title("Random color generator")
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
close_head
Create a new file in the component_fns
directory. You can name the new file anything you like, but I find it easiest to name the file partials.rb
.
In that file, create a function called top
. You can name it anything you want, in this example we will call it top. Then paste the lines you cut from the other file, then type end.
So it should look like this:
def top
doctype_html
open_head
title("Random color generator")
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
close_head
end
Now in routed/homegen.rb
, in the place where you cut the lines, simply call top
. So homegen.rb should look like this:
top
open_body
h1("Random color generator", :class =>"text-3xl text-violet-500")
pg("Welcome to the random color generator!")
close_body
Re-load the page - it should still work. All methods you define in all files in the component_fns
directory are available for you to use anywhere at any time. So you can define as many methods as you want. If you write lots of methods, you can choose how to allocate them across different files within the component_fns
directory. You can put as many or as few methods as you want in one file. I try to group them, so for example any partials such as top
, footer
, etc will be in partials.rb
. I put logic specific to certain pages under their own different .rb files (but still in component_fns
directory).
Uh-oh. Can you see a looming problem with our new top
method? It works for what we want now, but what about when we re-use it across different pages? The problem is that as currently written, all pages using the top
method will wind up with the title "Random color generator" which we don’t want on every page. For example, our About page should be titled About. No problem, let’s just put an argument to that method. So go to component_fns/partials.rb
and adjust the first and fourth lines to use an argument:
def top(pagetitle)
doctype_html
open_head
title(pagetitle)
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
close_head
end
Now reload the page. Oh no! it’s a blank page. In this case, its because in homegen.rb
we forgot to put the argument when we called top
. But if you didn’t know what the error was, you can inspect Apache’s error log. Let’s do that now. The errors will be displayed by the interpreter just like in any ruby program.
So inspect the error log file. On a remote Linux server, the normal setup you did in this tutorial means that the error log is located at the Apache default of /var/log/apache2/error.log
, so in your Webmin terminal window do sudo cat /var/log/apache2/error.log
(or to truncate output to most recent lines, do sudo cat /var/log/apache2/error.log|tail -50
)
For your local Apache setup, check the Apache documentation for where the error log file is located in a default install on your operating system. You will want to look at the last lines as being the most recent.
Regardless of how you inspect the file, the last few lines will look something like:
/home/newuser/public_html/mysite/component_fns/partials.rb:1:in `top': wrong number of arguments (given 0, expected 1) (ArgumentError)
from routed/homegen.rb:1:in `<top (required)>'
from /home/newuser/public_html/mysite/index.rb:116:in `load'
from /home/newuser/public_html/mysite/index.rb:116:in `<main>'
So you can see it’s easy to tell where the error is coming from using the error log - it told us we had the wrong number of arguments for the top
method in routed/homegen.rb
Lets fix that:
In routed/homegen.rb
, change the first line to contain the pagetitle argument
top("Random color generator")
Now reload the page - it should work and the title should appear at the top tab.
NOTE: (optional) you may wish to consider in the method definition for top
, to include a default pagetitle in case you forget to specify one.
Go to component_fns
and create a new file, you can call it anything, I will call it colors.rb
. You don't have to do it this way, you could equally well add the method in the partials.rb
file under the top
method, but this way keeps it better organized. How you do it is up to you and has no effect on the output. Zurfbirb isn't the thought police!
In the colors.rb
file, put this method in which generates an array with strings of random hex colors:
def gen_color_array(how_many)
result = Array.new
nums = *("0".."9")
letts = *("A".."F")
hexdig = nums + letts
unless how_many <= 0
how_many.times {
str = "#"
6.times {str << hexdig.sample}
result << str
}
end
result
end
Then in homegen.rb
call the method and have it print the results:
top("Random color generator")
open_body
h1("Random color generator", :class =>"text-3xl text-violet-500")
pg("Welcome to the random color generator!")
col_arr = gen_color_array(8)
col_arr.each{ |acolor|
open_div(:style => "background-color:#{acolor}")
pg(span_v(acolor, :style => "background-color:white"), :style => "text-align:center")
close_div
}
close_body
Then navigate your browser to the home page, and you will see your random color generator in action. It's not super pretty but it does generate colors.
One thing I have introduced here is the ability of Zurfbirb to have nested html elements. This occurs in the line:
pg(span_v(acolor, :style => "background-color:white"), :style => "text-align:center")
which, for an example translates to the html:
<p style="text-align:center"><span style="background-color:white">#F3932E</span></p>
(The actual background color style attribute is handled in the div line above it)
How does nesting work? If you want element(s) inside of another element (e.g. a span inside a pg), the inner element(s) ruby method names need to be suffixed with _v
. Therefore, pg for paragraph is handled normally, which means it will be printed (actually via the puts command). But the span needs to be suffixed with _v
i.e. span_v
to indicate we want its value only. The value-only version of span is then passed into the pg method, this way the span part isn't printed twice. You can see from the method calls and arguments that the argument background-color white belongs to span_v
while text-align center belongs to the pg
method. NOTE: _v
versions of commonly used, but not all, html generating methods have been defined in the Zurfbirb core. If any _v
methods you wish to use are missing, either follow the pattern in the Zurfbirb core to add your own ones in your copy of the core, or raise an issue on the repo and I will be more than happy to add those ones in.
Elements which most commonly have other html elements nested within them anyway (e.g. div
, head
, body
, footer
, ol
, ul
), have instead been provided with open and close methods for clarity and ease of use, e.g. open_div
, close_div
, open_head
, close_head
. So you shouldn’t need to use _v
to nest into a div, head, body, footer, etc.
A commonsense approach has been taken in defining the html methods in Zurfbirb, where you shouldn't need to use _v
often. You should mainly only need to use _v
on em
, strong
, span
and text such as that which are typically nested within other text.
We will come back again a bit later in this tutorial to the generator page and connect to a database to add the ability for users to write comments. This will also illustrate use of forms and Zurfbirb's csrf protection in form submission.
Subsequently, we will add the ability for users to favorite the colors they like best and display them later using cookies (this part without the database).
Finally, we will display the user’s navigation history to illustrate the use of the built-in session handler.
But first, we will take a look at the optional templating system to quickly build pages. The templating system is quite basic, but it’s helpful for when you have several different pages (e.g. About and Privacy Policy) that have similar fields and layout to each other but different content. You can define as many different templates as you wish (for example you might have photo gallery pages which have their own template). You can have as many different pages per template as you wish. You may also have as many fields per template as you wish - the only limit is readability.
All of that said, things will quickly get unwieldy and harder to keep track of the more pages and fields you have per template (regardless of number of templates). Zurfbirb is not designed as a replacement for a full web framework or a CMS, so if you have heavier templating needs consider one of those. Also, Zurfbirb has no built in admin dashboard nor does it have any built in authentication system. So if you need to update pages in your template system, this is done by editing files in Webmin if on a remote server, or in your usual text editor for a local install.
Zurfbirb's template system is designed to place the logic of pages with similar layouts together in a DRY manner.
Let’s start with an About page and a Privacy policy page.
The templating system is described in config/config.rb
so take a look at the commented-out lines there to learn a bit about how it works.
One of these examples is the article template, which we will uncomment and use. Uncomment the lines starting:
ps1 = {:url => "/about-us",
...
ps2 = {:url => "/privacy-policy",
...
article = {:template_fn => :article_page,
...
and also uncomment
$template_map = [article]
You’re able to customize the fields on these as you wish in the :locals
hash with any names and fields you want. For example, you could add :updated
for the date that page was last updated, or any other fields you want - names and values and how you use them are completely up to you. Here for the sake of simplicity we will stick to just the 2 fields of :title
and :body_html
.
Note: in the locals hash, for readability purposes you are welcome to put the content of large fields elsewhere so you don't have a really long line of code in config.rb
. So for example, :body_html => in_file("about-us.html")
means that the :body_html
value is equivalent to the text contained in the file about-us.html
. Zurfbirb will look for about-us.html
in the following directories: component_fns
, templates
, forms
, scripts
, html
, and assets/styles
. When we create this about-us.html
file ourselves, which we will do shortly, we will put it in the templates folder to keep it with related files.
Note: the in_file
method is available to you in Zurfbirb anywhere at any time. It is not restricted just to the templating system. in_file
returns the contents of the file - but does not print it, since Zurfbirb doesn't presume that is what you want to do with it. You may wish to modify or process it first, for example. Thus, if using the in_file method, you should either a) assign the returned item to a variable e.g. mystring = in_file("sometext.txt")
, b) use it as an argument in a method e.g. :body_html => in_file("about-us.html")
or c) if you do want to display it right away without further processing, use puts, e.g. puts in_file("text.txt")
In config.rb
, the line starting article = {:template_fn => :article_page,
shows that the article template method must be named article_page
. Zurfbirb will look for that method in any .rb
files in the templates
folder. Therefore, set up a new .rb
file in the template folder. You can name the file anything you like, it won't matter. I will name it pages.rb
Inside pages.rb, put the following content:
def article_page(title_str:, body_html:)
top(title_str)
open_body
h1(title_str, :class =>"text-3xl text-violet-500")
puts body_html
close_body
end
Then still in the templates
directory (you could use the html
directory or another directory but we will use templates
), create a new file about-us.html
because in config.rb
we said that the content for the /about-us
URL was in a file called about-us.html
. The about-us.html
and /about-us
nomenclature do NOT have to match. They happen to do so in this example but they do not have to. You get to decide these values in config.rb
, which you can see if you inspect the templating area there. There is purposely very little convention in Zurfbirb.
You are encouraged to add your own extra field(s) to modify this particular stet of templates.
Put your desired html code in about-us.html
, for example
<p>This is the about us page</p>
<p>This website is a random color generator. This website is written in Ruby!</p>
Similarly, create a privacy-policy.html
file because that was what we said in config.rb
for the privacy policy page content. Put some content in it, for example:
<p>We are not tracking you. We are not using Google Analytics or any other analytics software. This is a test website. If our privacy policy changes we will update it here.</p>
Through this templating system, we have now created 2 new pages. What are the URLs of these pages? Look in the section we uncommented in config.rb
. The value of the :url
key tells us which urls they are - which is /about-us
and /privacy-policy
.
Therefore, you want to navigate to yourhomepage/about-us
and /privacy-policy
. I assume you know what I mean by yourhomepage
- on a remote server that would be http://your.server.ip.address
and on a local install that would be http://localhost
.
This should "just work" and both pages should be visible at their respective URLs.
See how the templating system passed the title onto the top function, and the title is visible in the browser tab? Easy, isn't it?
Now that we have 3 pages, let’s create a menu to navigate between them.
While you could certainly create it as a partial like we did for the top method, I will illustrate a different option that is also available to you. Suppose in the case of making a menu, you already have an html menu somewhere from an old project, and you don’t want to waste time translating your existing html line by line into ruby methods such as open_nav(:class =>
... etc
In this situation, simply put your html menu into a file in the html directory. We will use the in_file
method to access it. Let's work with this example menu, so make a file in the html directory and we can call it menu.html
. Here we are using Tailwind classes, but you can feel free to use whatever you like - however, for those not using tailwind, you will miss out on seeing one thing - the background color change. It does not matter too much if you miss seeing it, but if you are not using tailwind and still want to see the background change then instead of these classes you can use something like style="background-color:cyan"
in your html and then make subsequent changes accordingly when you get to that point later in this step. Again, if that seems to complicated for you just continue as written without tailwind, it will only affect 1 small thing on the menu that is OK to disregard.
html/menu.html:
<nav>
<div class="bg-gray-600 grid grid-cols-3 gap-2 pt-1 pb-1">
<div class="mx-auto rounded-md bg-cyan-200 min-w-30 text-center"><a href="/">Home</a></div>
<div class="mx-auto rounded-md bg-cyan-200 min-w-30 text-center"><a href="/about-us">About</a></div>
<div class="mx-auto rounded-md bg-cyan-200 min-w-30 text-center"><a href="/privacy-policy">Privacy Policy</a></div>
</div>
</nav>
Now we will create a partial so we can use menu as a method any time. Actually this example is simple enough that we could just do puts in_file("menu.html")
when we want to use it. But for the sake of clarity, we will give it its own ruby method. So in component_fns
, open the partials.rb
file and add this code to it:
def menu
puts in_file("menu.html")
end
Now we want our templated pages to use the menu. Also our homepage. So in the templates
folder, open pages.rb
and insert a call to the menu method in the appropriate place right after open_body
:
def article_page(title_str:, body_html:)
top(title_str)
open_body
menu
h1(title_str, :class =>"text-3xl text-violet-500")
puts body_html
close_body
end
We also need to call menu
for the homepage, so in routed/homegen.rb
, add it in after the body
tag:
top("Random color generator")
open_body
menu
h1("Random color generator", :class =>"text-3xl text-violet-500")
...
Now navigate to your site (you may need to shift-reload). It will have a menu that you can use to go between the pages. Hmmm. It would improve UX if the menu made it clear which page we are on. Right now, all 3 menu buttons are the same color. But we want to make the current page be a different color. How do we do that? Here I will introduce Zurfbirb's path variable and also a text substitution method.
The moment a page is requested, its URL is handed down to you, which you get "for free" in these variables:
-
@req_path
- the original requested path, which may contain query parameters -
@plain_path
- requested path with query parameters stripped out
If you want to see where those variables come from, take a look at index.rb
. Note: .htaccess
and index.rb
provide most of the logic of where to route requested URLs. The first port of call is .htaccess
and then if the request appears valid, it will pass it along to index.rb
which will then route the request to the appropriate thing you have written in Ruby.
Getting back to our menu UX, we want @plain_path
since we don’t care about parameters, we just need to know the main url we are on.
What we are trying to do is to change the color class of the menu button if we are on its URL.
So in the menu method, we can query @plain_path. We will also create variables for the colors we want to use.
In component_fns/partials.rb
, go to your menu
method. Insert these lines that show the color logic at the top of the menu function
def menu
onpagecolor = "bg-violet-200"
homecolor = "bg-cyan-200"
aboutcolor = "bg-cyan-200"
privacycolor = "bg-cyan-200"
if @plain_path == "/"
homecolor = onpagecolor
elsif @plain_path == "/about-us"
aboutcolor = onpagecolor
elsif @plain_path == "/privacy-policy"
privacycolor = onpagecolor
else
# do nothing
end
puts in_file("menu.html")
end
We have the colors we want assigned to variables, but how to get them into the html code? Don’t worry, Zurfbirb has a method to easily replace text with the contents of variables. It’s actually a method better suited to forms (for example, client-side validation). But in this case we will use it to substitute the appropriate color on the menu button depending on which page we are on.
The method is named sub_vars_in_trusted_str
As its name implies, this should only be used with strings whose content is known to you, for security reasons it should never be used on user input or other untrusted strings.
sub_vars_in_trusted_str
takes a string (e.g. our html code) and a hash of arguments and switches out areas labelled like this: %%{}
For example, with arg of :name => user
in the optional args, then %%{name}
in str is converted to the string value of the passed argument variable e.g. the value of the variable user. The reason we are using hash mapping is that the names inside %%{}
can only contain alphabet characters. Your actual variable names might contain other characters, and so as not to constrain your normal variable choice, we map those onto the %%{}
variable names. You can find out more about that method in the Zurfbirb core if you interested.
So moving on, we need to make 3 small changes to menu.html
to allow variable substitution. Go to html/menu.html
. Instead of bg-cyan-200
we are going to change this as follows:
<nav>
<div class="bg-gray-600 grid grid-cols-3 gap-2 pt-1 pb-1">
<div class="mx-auto rounded-md %%{hcol} min-w-30 text-center"><a href="/">Home</a></div>
<div class="mx-auto rounded-md %%{acol} min-w-30 text-center"><a href="/about-us">About</a></div>
<div class="mx-auto rounded-md %%{pcol} min-w-30 text-center"><a href="/privacy-policy">Privacy Policy</a></div>
</div>
</nav>
then go back to component_fns/partials.rb
and change the final part of menu between the 2 end statements as follows so the whole thing looks like this:
def menu
onpagecolor = "bg-violet-200"
homecolor = "bg-cyan-200"
aboutcolor = "bg-cyan-200"
privacycolor = "bg-cyan-200"
if @plain_path == "/"
homecolor = onpagecolor
elsif @plain_path == "/about-us"
aboutcolor = onpagecolor
elsif @plain_path == "/privacy-policy"
privacycolor = onpagecolor
else
# do nothing
end
unsub_menu = in_file("menu.html")
sub_menu = sub_vars_in_trusted_str(unsub_menu, {:hcol => homecolor, :acol => aboutcolor, :pcol =>privacycolor})
puts sub_menu
end
Check the pages on your browser. This should just work - the menu background color indicates what page you are on. Remember to regenerate your css if you do not have tailwindcss on --watch
You may wish to allow your users to leave comments. So far, everything has been stateless - you have not yet implemented a way for anyone to permanently modify anything relating to the website. Now we want to add state.
Adding a comment ability to our home page, where users can leave comments, allows us to understand how Zurbirb handles forms and csrf protection as well as database interactions.
So far, we haven’t created a database. Zurfbirb does not care which type of database and which type of Ruby ORM you use. For beginners, I recommend the sqlite
database with the sequel
ORM since the requirements were already installed as part of setup, so we do not need to install anything further. But if you prefer to use Postgresql
with the ActiveRecord
ORM, that is fine too - but you need to install those dependencies yourself.
We have already installed all the requirements for sqlite and sequel in our setup of Zurfbirb, even though we don’t have a database yet. The database connection is shown in the commented-out lines in config/config.rb
In config/config.rb, scroll to the database section. There are 4 lines you should uncomment, and one of them you will additionally need to modify. Uncomment these lines:
require 'sequel'
require 'sqlite3'
require 'date'
DB = Sequel.sqlite('/home/newuser/zurfbirb_for_apache/user_comments.db')
In the final line above, beginning DB (the database connection line), you will additionally need to modify the path. If you are using sqlite with sequel as in this tutorial the database need not exist yet - which makes it nice and easy. For remote install, instead of newuser
the path needs to have your non-privileged user name. For local install, specify the full path of the directory that you made for the private key (i.e. the zurfbirb_for_apache
one). In both cases, the full path needs to end with the filename of the desired database, in this case user_comments.db
Remember to inspect your file system to check that the directory you are specifying actually exists and that that it is readable and writeable by the Apache user. For a remote install, this should automatically be the case if you used the install script as directed. It should have created the zurfbirb_for_apache
directory in your user's home directory (e.g. /home/newuser
NOT your document root). You can check that the directory exists using the Webmin file manager or via terminal window command line. You should find that the zurfbirb_for_apache
directory exists in the newuser’s home directory, and that it is readable and writeable by www-data
(the Apache user for Linux). For local install, you would have set up this directory manually during install, so you will know what path to check.
For security reasons, you would want your database to be outside of your web root, which it is in the case of a remote install as per the instructions.
Note: there is nothing stopping you from locating your database elsewhere on the server, so long as the path of permissions allows www-data to read and write to that directory. You have a lot of choice in what you do, but sensible defaults have been provided.
Once you have uncommented those lines and changed the DB location path to reflect your path, you are ready to move on.
Let's start our first form. You could put it in the forms
directory or component_fns
as for partials. it doesn't really matter, it is personal preference. Also, if instead of Ruby you prefer to do your forms using html, that is fine too - you would do it as you did for the menu.
Zurfbirb has csrf protection built in by default in ruby-made forms using the Zurfbirb open_form
method as we are about to do. This will be explained as we go. But you can add it to html forms as well with %%{csrf}
in the form and then a call to string replacement using sub_vars_in_trusted_str
without arguments (unless you have other substitutions to make at that time). We will cover that too.
In the component_fns
folder, create a new file called comments.rb
. Inside it, put this text:
def comment_form
h4("add your comment!")
open_form(:method => "post", :action => "/")
label("Your name or nickname", :for => "nickname")
br
input(:type => "text", :id => "nickname", :name => "nickname", :maxlength => "50", :class => "rounded border-2 border-gray-400")
br
label("Enter your comment:", :for => "ucomment")
br
open_textarea_required(:minlength => "10", :maxlength => "1000", :rows => "5", :cols => "30", :id => "ucomment", :name => "ucomment", :class => "rounded border border-2 border-gray-400")
close_textarea
br
input(:type => "submit", :value => "Submit", :class => "p-2 bg-cyan-500 rounded")
close_form
end
In a production site you would want to have something to discourage bot submissions in there, for example a honeypot or captcha. But in this case we will proceed with it as is. In your homegen.rb
, call comment_form
as shown below. The form will be visible (although it won’t do anything yet).
top("Random color generator")
open_body
menu
h1("Random color generator", :class =>"text-3xl text-violet-500")
pg("Welcome to the random color generator!")
col_arr = gen_color_array(8)
col_arr.each{ |acolor|
open_div(:style => "background-color:#{acolor}")
pg(span_v(acolor, :style => "background-color:white"), :style => "text-align:center")
close_div
}
comment_form
close_body
Now we have a working copy of form submission (although nothing happens because we have not done submit logic yet). However, now is a good time to see csrf protecition which is built into the open_form method. Right click on the home page in browser, and go to view page source code. You will see that your browser produces this additional line within the form that looks something like this <input type="hidden" name="RandomToken" value="15e3915a623017205f707f35a6fa3a7bf2e90ee3">
this is a csrf token. You have a reasonable amount of control over csrf settings, see more info on how this works in official docs here TO DO. But sensible defaults have been provided so that you do not need to touch the settings if you don't want to.
You may be wondering if you can have csrf protection on forms created by html instead of ruby (e.g. one that you have done with in_file
method). The answer is you can, but you have to add this code %%{csrf}
in the place in the form where you want your csrf code to appear and you need to call sub_vars_in_trusted_str
. Therefore, there is the risk that you might forget to do this. Let's take a look at a working example of this strategy. For now, go to the forms
directory and make a new file called comment-html.html
and put this code in it. It is the exact same code as your Ruby form produced earlier, but without the csrf token. We will look at how to add it in after.
<form method="post" action="/">
<label for="nickname">Your name or nickname</label>
<br >
<input type="text" id="nickname" name="nickname" maxlength="50" class="rounded border-2 border-gray-400" >
<br >
<label for="ucomment">Enter your comment:</label>
<br >
<textarea required minlength="10" maxlength="1000" rows="5" cols="30" id="ucomment" name="ucomment" class="rounded border border-2 border-gray-400"> </textarea>
<br >
<input type="submit" value="Submit" class="p-2 bg-cyan-500 rounded" >
</form>
then in routed/homegen.rb
, remove the line
comment_form
and replace it with
puts in_file("comment-html.html")
Now reload your home page. You will see the same form again, but if you try to submit it, because it has no csrf token, you will get an error message. You can also try this with an incorrect csrf value if you like.
You can customize the csrf token mismatch error page if you prefer, to something themed to your other pages. Simply create a new page in the routed folder called for example my-csrf-403-mismatch.rb
, and have it render with whatever method calls you want (e.g. top
, menu
, etc) to ensure it matches your other pages better. Then in config/config.rb
, change the line ERROR_TOKEN_MISMATCH_403 = "routed/default-token-mismatch-403.rb"
to reflect your new 403 path, e.g. ERROR_TOKEN_MISMATCH_403 = "routed/my-csrf-403-mismatch.rb"
But there is no need to do that now, creating a pretty 403 page is something you can leave for later.
Now we want to put in the csrf ability. So go back to forms/comment-html.html
Right after the first line (<form method="post" action="/">
) insert a new line:
%%{csrf}
We still need to have it substitute the csrf for a proper token, which is done via sub_vars_in_trusted_str
. Go to your homegen.rb
page and replace the line beginning
puts in_file
...
with
puts sub_vars_in_trusted_str(in_file("comment-html.html"))
You do not need to supply an argument here - in any method call to sub_vars_in_trusted_str
you always get a %%{csrf}
substitution "for free"
Now reload your home page. You will see that the form is there and when you go to view source code, you will see a csrf token. If you try to submit the form, no errors are displayed.
So that is csrf protection in Zurfbirb in a nutshell, although as mentioned there are more features and settings you can customize in config.rb
, for example you can choose to skip csrf checks for certain pages that would otherwise have them. You can also opt to specify URLs that are ONLY allowed as POST
and which will give an error if trying to access with a GET
.
Now after that csrf detour, lets actually make the comment form work to submit comments to the database! We will also want to view the comments on the home page.
To ensure that our database doesn't grow beyond a certain size no matter how many spam submissions, we will only keep the newest 4 comments, erasing the old ones as we go. In the component_fns
directory, create a new file called comment_db_fns.rb
and put this code in it:
require 'sequel'
require 'sqlite3'
require 'date'
def display_comments
unless DB.table_exists?(:comments)
DB.create_table(:comments) do
primary_key :id, null: false
DateTime :created, null: false
String :name
String :comment, null: false
end
end
entries = DB[:comments]
if entries.count == 0
h4("No comments yet. Be the first to have your say!")
else
h3("The ", entries.count.to_s, " most recent comments:")
ds3 = entries.order(:created).reverse
results = ds3.all
results.each{|h| pg(strong_v(h[:name]), ": ", h[:comment])
}
end
end
def delete_oldest_comment
# identifies oldest comment and deletes it. Returns true if something
# was deleted, false if no comments to delete
# this is useful to do as a way of pruning the database
# that way spam entries can't occupy more than a set amount of space
if method_post?
comments_all= DB[:comments]
if comments_all.count > 0
# we have enough that we can delete one
# generate sql for items we want
ds = comments_all.order(:created)
# execute the query
results = ds.all
# find oldest
oldest_id = results.first[:id]
to_delete = comments_all.where(id: oldest_id)
# delete oldest
to_delete.delete
true
else
false
end
else
false
end
end
def add_comment(text, uname = "anonymous user")
if method_post?
entries = DB[:comments]
entries.insert(comment: text, created: Time.now, name: uname)
else
false
end
end
def add_comment_and_prune(text, uname = "anonymous user", num_comments_to_keep = 4)
if method_post?
entries = DB[:comments]
if entries.count >= num_comments_to_keep
# need to prune to maintain same number of entries
delete_oldest_comment
# this would be better rewritten as a while loop, in case we somehow started
# with many more than num_comments_to_keep. That said, it still deletes
# 1 for every 1 it adds if we are above that amount, so number
# of entries is still constant
end
add_comment(text, uname)
else
false
end
end
def submit_comment
if method_post?
# create a comments tables if it doesn't already exist
unless DB.table_exists?(:comments)
DB.create_table(:comments) do
primary_key :id, null: false
DateTime :created, null: false
String :name
String :comment, null: false
end
end
entries = DB[:comments]
acomment = @form_params["ucomment"][0..1000]
sani_comment = Sanitize.fragment(acomment, SANITIZE_DEFAULT)
nickname = @form_params["nickname"][0..30]
if nickname.empty?
nickname = "an anonymous user"
else
nickname = Sanitize.fragment(nickname, SANITIZE_DEFAULT)
end
add_comment_and_prune(sani_comment, nickname)
end
end
Here we are using sequel gem by Jeremy Evans to interface with the database. You can learn more about sequel at the official Sequel doc pages at https://sequel.jeremyevans.net/. Remember, you can use whatever you want in Zurfbirb - Activerecord and Postgresql, whatever, so long as you set up your database connection in config/config.rb
Then in your homegen.rb
in the routed folder, between the last 2 lines puts sub_vars
... and close_body
, insert these lines to do the database work:
...
br
if method_post?
submit_comment
end
br
open_div(:class => "bg-yellow-200 rounded-xl m-5 p-2")
display_comments
close_div
...
Now reload your home page. You can accept comments now! Give it a try. One improvement to make would be not accepting comments with a blank comment field. Another would be not accepting resubmissions of an existing comment. For nowthough, we will move on.
We want to have the ability for favoriting and unfavoriting colors, and then display the user’s favorites. Here we will make use of javascript cookies. In the scripts directory, make a file called colfaves.js
and put this text in it:
<script>
function getCookie(cname) {
// credit: this function from https://www.w3schools.com/js/js_cookies.asp
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i <ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
function addFaves(str) {
let delim = '^';
let favecookie = getCookie('faves');
if (favecookie.includes(str)) {
return false;
} else {
let newval = favecookie + str + delim;
document.cookie = 'faves=' + newval;
return true;
}}
function removeFaves(str) {
let delim = '^';
let favecookie = getCookie('faves');
let torem = str + delim;
let removed = favecookie.replace(torem, "");
document.cookie = 'faves=' + removed;
}
function colkeepFunction(){
var caller = event.target;
iden = caller.id;
if (caller.checked == true){
addFaves(caller.id);
} else {
removeFaves(caller.id);
}
}
</script>
Then in homegen.rb
, change the contents of col_arr.each
as follows, to include the checkbox and link that color code to the color keeping javascript function in the previous file:
...
col_arr.each{ |acolor|
colcode = acolor[1,6]
chbox = ' <input type="checkbox" class="checkbox" name="' + colcode + '" id="' + colcode + '" onclick="colkeepFunction()"> '
open_div(:style => "background-color:#{acolor}")
pg(span_v(acolor, :style => "background-color:white"), chbox, :style => "text-align:center")
close_div
}
...
and at the end, before closing body tag, put
puts in_file("colfaves.js")
So your homegen.rb should look like this:
top("Random color generator")
open_body
menu
h1("Random color generator", :class =>"text-3xl text-violet-500")
pg("Welcome to the random color generator!")
col_arr = gen_color_array(8)
col_arr.each{ |acolor|
colcode = acolor[1,6]
chbox = ' <input type="checkbox" class="checkbox" name="' + colcode + '" id="' + colcode + '" onclick="colkeepFunction()"> '
open_div(:style => "background-color:#{acolor}")
pg(span_v(acolor, :style => "background-color:white"), chbox, :style => "text-align:center")
close_div
}
puts sub_vars_in_trusted_str(in_file("comment-html.html"))
br
if method_post?
submit_comment
end
br
open_div(:class => "bg-yellow-200 rounded-xl m-5 p-2")
display_comments
close_div
puts in_file("colfaves.js")
close_body
This should work in the cookies, in the sense that you can reload the page, inspect cookies wtih F12, check and uncheck the boxes and watch cookie content change for the faves cookie. When looking at the faves
cookie in Chromium, you may need to reload the page to see the cookies in the storage.
Unfortunately the cookies are in plaintext since on-page if we want to set and unset cookies we have to do it in Javascript. And when we are in Javascript we cannot "reach up" into Zurfbirb's file session handler. Since this is not sensitive info it is ok but if it were sensitive info it would be a problem. FYI - if on a remote server you see cookies for redirect
, sid
, and testing
, these come from Webmin and not from Zurfbirb. You can try this in a different browser or device. The zb_lang
cookie is from Zurfbirb and we will learn more about it later.
Once we retrieve our cookies we can certainly store that information in the session handler if we wish to do so. In this case we will simply do it via cookies. We will work with the session handler after this section is completed.
Right now we will create a new page where we retrieve those faves cookies and tell the user what their favorite colors were.
We are not using the same template as the about
and privacy-policy
pages since our faves page depends on the content of faves cookies - although if we were determiend to do it that way, we could. If so, we would simply put the body field to an empty string in config.rb wheen we define the page, then in the template file put query logic if @plain_path == "/faves"
...[do the logic of displaying favorite colors], else display body html. However that is rather pointless and clunky.
Another option is to route the /faves
url similarly to what we did for the home page (using relate_root
array, adding "/faves" => "routed/faves.rb"
to the array). It would certainly work, but can you see quickly how this would get repetitive if you later had many urls with the same pattern (url corresponding to a .rb file of the same name)?
To help when you have this pattern, Zurfbirb has a folder called public_auto_urls
. Any .rb
file you put into there automatically is shown at that url minus the .rb. So the example we will do is with faves, where we will put a faves.rb
file in public_auto_urls
, and when we navigate the browser to /faves
it will display the content from the faves.rb
file. In addition to pages such as the one we are about to create, I also find it useful for creating quick mock-ups or for troubleshooting and debugging in Ruby beyond the info that you get in the Apache error file.
So create a file called faves.rb
inside public_auto_urls
and put this in it:
top("Your favorites")
open_body
menu
h1("Your favorite colors", :class =>"text-3xl text-violet-500")
pg("Here are your favorite colors that you selected using the checkboxes")
close_body
We will put the logic in shortly, but first lets make sure we can see the page. Navigate to /faves
on your browser and you will see the page. Before putting in the logic for displaying favorites, what else is missing? A link to the faves page on the menu! Lets make this easier on ourselves for this quick example by adding a second line to the menu just for the faves page. Go to the html
directoryt, then menu.html
and place these lines just before closing the </nav>
tag:
...
<div class="pr-15 bg-stone-800 text-right text-white">
<a href="/faves"><span style="color:red"> ♥ </span>Your favorite colors</a>
</div>
OK, now we can access the faves page easily from any other page.
In the public_auto_urls
directory, modify your faves.rb
to include the code for accessing the faves cookie and displaying favorite colors. Note, the faves cookie is unencrypted so we are using the unencrypted cookie methods of Zurfbirb:
top("Your favorites")
open_body
menu
h1("Your favorite colors", :class =>"text-3xl text-violet-500")
pg("Here are your favorite colors that you selected using the checkboxes:")
if unencrypted_cookie_exist?("faves")
# the cookie at least exists, may have empty content
favestr = unencrypted_cookie_value("faves")
if favestr.match?(/[0-9A-F]/)
favearr = favestr.split("^")
open_div(:style => "display:grid; grid-template-columns: auto auto auto; ")
favearr.each{|colcode|
hexcol = "#" << colcode
bkgstyle = "background-color: " + hexcol
open_div(:style => bkgstyle)
pg(span_v(hexcol, :style => "background-color:white"), :style => "text-align:center")
close_div
}
close_div
else
# we have a cookie but it is empty
pg("No current favorites. It looks like you may have had some previously, but may have unfavorited them")
end
else
# cookie does not exist
pg("No favorites found! To get favorites, click the checkbox of colors you like on the home page")
end
pg(" ")
hr
close_body
Now when you look at the /faves page you can see which colors were your favorites.
Notice that we have done this without the file session handler - we simply used javascript to set unencrypted cookies, and on the faves page we used Zurfbirb's cookie module methods to access these cookies.
Now you can favorite and unfavorite colors, and you should see these reflected at /faves
But what if we want to do something that requires the file session handler? For this example, let’s tell the user what pages he or she browsed, in the order they were browsed. Yes, it’s a bit big-brother-ish but it’s easy to implement with the data we already have. First, update your privacy policy in your templates/privacy-policy.html
to indicate this. Add this line:
<p>We tell you what pages on this site you browsed, but we forget about you when you close your browser, or after 15 minutes of inactivity on our site. No-one else sees the list of pages you browsed, only you.</p>
Note: the 15 minutes comes from the default session expiry time of zurfbirb's file session handler class, seen in core/ZBFileSession.rb
OK, now that this is in our privacy policy, we will move on. The session handler allows you to store any number of variables, although they are all converted to string. You cannot store an object variable, the session handler expects a string (no serializer/deserializer). It is up to you to convert to and from strings.
The good news is that the session handler is designed to "just work" with a minimum of info supplied by you - in other words, sensible defaults are assumed (one of which is encryption of the session file contents by default). If you know what you are doing you may override these. The encryption is set in config/sess-file-vars.rb
in the line
ZB_ENCRYPT_FILE_SESS = true
You may set it to false, but that is only recommended for debugging purposes and not for production. I cannot think of any other use case for unencrypted file sessions.
Let’s dive in! While there are many methods in the ZBFileSession class, only a few are expected to be called by you. The most useful one is get_session(id ...)
, which will return either false if there is no session associated with that ID, or a session object if there is a session associated with that ID.
Hold on! How do we know the current user's session ID? The session ID is stored in a cookie. We therefore want to know the session cookie name, since in that cookie the ID is stored. Some of the methods we want will therefore be in the ZBcookies module. Since cookies are also used in csrf, some of the cookie methods we want are in the ZBcsrf module. Those modules are mixins so we can just use their methods as is.
The method we will need is find_sess_cookie_name
(from ZBcsrf). Alternatively, we could also check in config/sess-file-vars.rb
and see what SESS_NAME
is set to. But to be certain, find_sess_cookie_name
will tell you what Zurfbirb thinks the session cookie name is called.
But before we get too far ahead into methods and variables, we will make a new page where the user will view their navigation. But for now we will use that page to learn a bit more about how the session handler works.
So, make a page called viewed.rb
in public_auto_urls
since we plan to show the user viewed pages there. Put the following code in it so it looks similar to other pages, with a menu etc. We won’t put viewed itself in the menu.
top("Your viewed pages")
open_body
menu
h1("The pages you viewed", :class =>"text-3xl text-violet-500")
pg("Here are the pages you viewed in the order you viewed them:")
hr
br
hr
close_body
NOTE: any pages you wish to be invisible, add their path e.g. /my-debugging-stuff
to the unpublished array in config/config.rb
- of course, this will make them invisible to you too! But it is a good way to "turn off" certain pages while you’re not using them. When you want to work on a page, you can turn it on by removing that particular path from the unpublished array. So you can turn pages off and on as needed by adding or removing them from the unpublished array.
Look at your /viewed page in the browser. We will use the space between the horizontal lines to check on some variables to help us learn, if you are not sure what your session cookie name is. You don't need to go through this process normally - you should be able to figure out the session cookie name from config/sess-file-vars.rb
in the line SESS_NAME = ...
But since this is a tutorial we will look at all the nuts and bolts.
By default the session cookie name will be zb_lang
(which is purposely vague). You can call it anything you want in config/sess-file-vars.rb
. In my example here, it is zb_lang
However, let us check for ourselves that this is the case with our setup. In the file public_auto_urls/viewed.rb
which you just created, replace the br
with
puts find_sess_cookie_name
Then go to /viewed
to see the session cookie name. Mine said zb_lang
and yours should too, unless you changed the value of SESS_NAME
in your config.rb
file.
Whatever it is, let’s now access the session ID (if there is one). First we will see if a client session cookie actually exists for this session. We can do this with client_sess_cookie_exist?
method in ZBcsrf. Alternatively, we could also have done it using the ZBcookie method cookie_exist?
applied with the argument find_sess_cookie_name
. In fact, this is exactly how the client_sess_cookie_exist?
method is defined! So we will use it because it is shorter and more convenient. Again, this visual inspection of values is simply for teaching purposes - typically you would just go ahead and use the methods normally.
In viewed.rb
, below puts find_sess_cookie_name
add:
puts client_sess_cookie_exist?
Before viewing the page, you should be able to figure out if you expect true or false by (on your site) pressing F12 and looking at the "Application section on Chrome-based broswers, or the "Storage" section in Firefox. Then go to "Cookies". If you see a cookie called zb_lang
(or whatever name your session cookie is - we will assume zb_lang
here), then you expect to see a value of true, otherwise false.
Yes, when viewing the /viewed
page, it showed true, which is what I predicted from inspecting the cookie storage with F12. If you do not see zb_lang
in your storage cookie, that's fine. You will only see it if you were on the comments page previously, since the csrf protection uses the session handler, while other pages do not use it.
OK - what is the value of our session cookie? This will give us our session ID. NOTE - if you got false in the previous step, go ahead to your home page to ensure you ahve a zb_lang
cookie set.
Assuming you have a zb_lang
cookie, We can access this via the method sess_cookie_value
method conveniently defined in ZBcsrf. So add the line
puts sess_cookie_value
into the viewed.rb
file you were working on before. Again, you wont have to do any of this when you are using the session handler. This is just to illustrate examples of method calls to it.
Now look at the page at /viewed
and you will see that your session cookie value (the session ID) is something like:
fc2af036-c28d-4a94-b2ce-ab159a8908bb
This is the session ID. It is by default generated as a uuid via SecureRandom, but if you prefer, you can opt for it to be as a hex(27) or hex(40), also via SecureRandom. You would do this by defining a variable in config/sess-file-names.rb
called $zb_sess_id_format
and setting it to either :hex27
or :hex40
depending which one you want. For example $zb_sess_id_format = :hex27
If the variable $zb_sess_id_format
is undefined (which it is by default - I don’t expect most people want to change that), the file session hndler falls back to uuid. uuid is supposed to be used for generating unique IDs, so that is why Zurfbirb prefers that format for the session ID.
If you wish, you could try making that change temporarily in config/sess-file-names.rb
and put $zb_sess_id_format = :hex27
You would need to view your /viewed
page on another browser or device to see the new session id format (rememer to view the home page before /viewed). The other browser is needed bcause you already have a session open in your current browser, so it already has your session ID previously stored there in uuid format. Alternatively close all windows in your current browser and then look at the home page, then the /viewed
page.
However, you can shortcut all of this and just stick with the last step we did: using the ZBcsrf method sess_cookie_value.
It will either return the session cookie value, or false if there isn’t one. That is your session ID.
The session ID is important because if your user has a session ID, you will want to store variables (in this case, pages they have browsed) associated with this ID. But you also want to cover the case where there is no session running so that you can create a new session to store the pages they have browsed.
We will put this logic in a partial. Go to the component_fns
directory and create a new file browsed.rb
where we will write the methods we need to handle it. In browsed.rb
put these methods:
def add_viewed
# Adds current plain path to the variable viewed in session file
# if no session exists yet, a new one is created and path is added
# does not return a value
delim = "["
if client_sess_cookie_exist? and ZBFileSession.sess_id_exist?(sess_cookie_value)
# there is an existing session, retrieve it
curr_sess = ZBFileSession.get_session(sess_cookie_value)
# check if the viewed var exists
if curr_sess.var_exist?("viewed")
newval = curr_sess.get_one_var("viewed") + @plain_path + delim
else
newval = @plain_path + delim
end
# update the variable
curr_sess.update_one_var("viewed", newval)
else
# no session exists - create one, also set sess cookie
curr_sess = ZBFileSession.new
newval = @plain_path + delim
curr_sess.update_one_var("viewed", newval)
set_cookie_js(find_sess_cookie_name, curr_sess.sess_id)
end
end
def get_viewed
# returns the array representing the viewed path
delim = "["
if client_sess_cookie_exist? and ZBFileSession.sess_id_exist?(sess_cookie_value)
curr_sess = ZBFileSession.get_session(sess_cookie_value)
# check if the viewed var exists
if curr_sess.var_exist?("viewed")
return curr_sess.get_one_var("viewed").split(delim)
end
end
# fallback to empty array if sess, cookie or viewed is non-existent
Array.new
end
OK, now you want every page to call add_viewed
, so since every page calls top
anyway, we can put in the top
function in component_fns/partials.rb
. Put it just before closing the head tag:
def top(pagetitle)
doctype_html
open_head
title(pagetitle)
link(:rel => "stylesheet", :type => "text/css", :href => "/assets/styles/out.css")
add_viewed
close_head
end
Then in public_auto_urls
, update your viewed.rb
as follows:
top("Your viewed pages")
open_body
menu
h1("The pages you viewed", :class =>"text-3xl text-violet-500")
pg("Here are the pages you viewed in the order you viewed them. Remember, \"/\" represents the home page:")
viewed_arr = get_viewed
open_ol
viewed_arr.each{|path| li(path)}
close_ol
close_body
Now look at the /viewed
page. Browse more pages and then go back to the /viewed
page. It will tell you which pages you browsed in the order you browsed them.
This concludes the Quickstart Tutorial. Most of the tools neeed for creating a new site were in this tutorial. However, you should also take a look at the Zurfbirb docs on a different page in this Wiki for more details on methods used here, as well as other Zurfbirb methods that were not used in this tutorial.