paint-brush
How to Build SaaS Multi-Tenant Applications: Multi-Tenant SaaS in 200 Lines of Codeby@gliimlang
316 reads
316 reads

How to Build SaaS Multi-Tenant Applications: Multi-Tenant SaaS in 200 Lines of Code

by GliimlyNovember 21st, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Use PostgreSQL, Nginx and Gliimly to build SaaS application with user authentication, in only 200 lines of code.
featured image - How to Build SaaS Multi-Tenant Applications: Multi-Tenant SaaS in 200 Lines of Code
Gliimly HackerNoon profile picture

This is a complete SaaS example (Software-as-a-Service) using PostgreSQL as a database and Gliimly as a web service engine; it includes user signup/login/logout with an email and password, separate user accounts and data, and a notes application. All in about 200 lines of code!


First, create a directory for your application where the source code will be:

mkdir -p notes
cd notes

Setup Postgres Database

Create a PostgreSQL user (with the same name as your logged-on Linux user, so no password needed), and the database "db_app":

echo "create user $(whoami);
create database db_app with owner=$(whoami);
grant all on database db_app to $(whoami);
\q"  | sudo -u postgres psql


Create a database configuration file to describe your PostgreSQL database above:

echo "user=$(whoami) dbname=db_app" > db_app


Create database objects we'll need - a users table for application users, and a notes table to hold their notes:

echo "create table if not exists notes (dateOf timestamp, noteId bigserial primary key, userId bigint, note varchar(1000));
create table if not exists users (userId bigserial primary key, email varchar(100), hashed_pwd varchar(100), verified smallint, verify_token varchar(30), session varchar(100));
create unique index if not exists users1 on users (email);" | psql -d db_app 

Create A Gliimly Application

Create the application "notes" owned by your Linux user:

sudo mgrg -i -u $(whoami) notes

Source Code

This executes before any other handler in an application, making sure all requests are authorized, file "before-handler.gliim":

vi before-handler.gliim


Copy and paste:

before-handler
    set-param displayed_logout = false, is_logged_in = false
    call-handler "/session/check"
end-before-handler

Signup users, login, logout


This is a generic session management web service that handles user creation, verification, login, and logout. Create file "session.gliim":

vi session.gliim


Copy and paste:

// Display link to login or signup
%% /session/login-or-signup private
    @<a href="<<p-path "/session/user/login">>">Login</a> &nbsp; &nbsp; <a href="<<p-path "/session/user/new/form">>">Sign Up</a><hr/>
%%
// Login with email and password, and create a new session, then display home pag
%% /session/login public
    get-param pwd, email
    hash-string pwd to hashed_pwd
    random-string to sess_id length 30
    run-query @db_app = "select userId from users where email='%s' and hashed_pwd='%s'" output sess_user_id : email, hashed_pwd
        run-query @db_app no-loop = "update users set session='%s' where userId='%s'" input sess_id, sess_user_id affected-rows arows
        if-true arows not-equal 1
            @Could not create a session. Please try again. <<call-handler "/session/login-or-signup">> <hr/>
            exit-handler
        end-if
        set-cookie "sess_user_id" = sess_user_id path "/", "sess_id" = sess_id path "/"
        call-handler "/session/check"
        call-handler "/session/show-home"
        exit-handler
    end-query
    @Email or password are not correct. <<call-handler "/session/login-or-signup">><hr/>
%%
// Starting point of the application. Either display login form or a home page:
%% /session/start public
    get-param action, is_logged_in type bool
    if-true is_logged_in equal true
        if-true action not-equal "logout"
            call-handler "/session/show-home"
            exit-handler
        end-if
    end-if
    call-handler "/session/user/login"
%%
// Generic home page, you can call anything from here, in this case a list of note
%% /session/show-home private
    call-handler "/notes/list"
%%
// Logout user and display home, which will ask to either login or signup
%% /session/logout public
    get-param is_logged_in type bool
    if-true is_logged_in equal true
        get-param sess_user_id
        run-query @db_app = "update users set session='' where userId='%s'" input sess_user_id no-loop affected-rows arows
        if-true arows equal 1
            set-param is_logged_in = false 
            @You have been logged out.<hr/>
            commit-transaction @db_app
        end-if
    end-if
    call-handler "/session/show-home"
%%
// Check session based on session cookie. If session cookie corresponds to the email address, the request is a part of an authorized session
%% /session/check private
    get-cookie sess_user_id="sess_user_id", sess_id="sess_id"
    set-param sess_id, sess_user_id
    if-true sess_id not-equal ""
        set-param is_logged_in = false
        run-query @db_app = "select email from users where userId='%s' and session='%s'" output email input sess_user_id, sess_id row-count rcount 
            set-param is_logged_in = true
            get-param displayed_logout type bool
            if-true displayed_logout equal false
                get-param action
                if-true action not-equal "logout"
                    @Hi <<p-out email>>! <a href="<<p-path "/session/logout">>">Logout</a><br/>
                end-if
                set-param displayed_logout = true
            end-if
        end-query
        if-true rcount not-equal 1
            set-param is_logged_in = false
        end-if
    end-if
%%
// Check that email verification token is the one actually sent to the email address
%% /session/verify-signup public
    get-param code, email
    run-query @db_app = "select verify_token from users where email='%s'" output db_verify : email
        if-true  code equal db_verify
            @Your email has been verifed. Please <a href="<<p-path "/session/user/login">>">Login</a>.
            run-query @db_app no-loop = "update users set verified=1 where email='%s'" : email
            exit-handler
        end-if
    end-query
    @Could not verify the code. Please try <a href="<<p-path "/session/user/new/verify-form">>">again</a>.
    exit-handler
%%
// Display login form that asks for email and password
%% /session/user/login public
    call-handler "/session/login-or-signup"
    @Please Login:<hr/>
    @<form action="<<p-path "/session/login">>" method="POST">
    @<input name="email" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Email">
    @<input name="pwd" type="password" value="" size="50" maxlength="50" required placeholder="Password">
    @<button type="submit">Go</button>
    @</form>
%%
// Display form for a new user, asking for an email and password
%% /session/user/new/form public
    @Create New User<hr/>
    @<form action="<<p-path "/session/user/new/create">>" method="POST">
    @<input name="email" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Email">
    @<input name="pwd" type="password" value="" size="50" maxlength="50" required placeholder="Password">
    @<input type="submit" value="Sign Up">
    @</form>
%%
// Send verification email
%% /session/user/new/send-verify private
    get-param email, verify
    write-string msg
        @From: [email protected]
        @To: <<p-out email>>
        @Subject: verify your account
        @
        @Your verification code is: <<p-out verify>>
    end-write-string
    exec-program "/usr/sbin/sendmail" args "-i", "-t" input msg status st
    if-true st not-equal 0 or true equal false
        @Could not send email to <<p-out email>>, code is <<p-out verify>>
        set-param verify_sent = false
    else-if
        set-param verify_sent = true
    end-if
%%
// Create new user from email and password
%% /session/user/new/create public
    get-param email, pwd
    hash-string pwd to hashed_pwd
    random-string to verify length 5 number
    begin-transaction @db_app
    run-query @db_app no-loop = "insert into users (email, hashed_pwd, verified, verify_token, session) values ('%s', '%s', '0', '%s', '')" input email, hashed_pwd, verify affected-rows arows error err on-error-continue
    if-true err not-equal "0" or arows not-equal 1
        call-handler "/session/login-or-signup"
        @User with this email already exists.
        rollback-transaction @db_app
    else-if
        set-param email, verify 
        call-handler "/session/user/new/send-verify"
        get-param verify_sent type bool
        if-true verify_sent equal false
            rollback-transaction @db_app
            exit-handler
        end-if
        commit-transaction @db_app
        call-handler "/session/user/new/verify-form"
    end-if
%%
// Display form to enter the code emailed to user to verify the email address
%% /session/user/new/verify-form public
    get-param email
    @Please check your email and enter verification code here:
    @<form action="<<p-path "/session/verify-signup">>" method="POST">
    @<input name="email" type="hidden" value="<<p-out email>>">
    @<input name="code" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Verification code">
    @<button type="submit">Verify</button>
    @</form>
%%

Notes application

This is the actual application that uses the above session management services. Create file "notes.gliim":

vi notes.gliim


Copy and paste:

// Delete a note
%% /notes/delete public
    call-handler "/notes/header"
    get-param sess_user_id, note_id
    run-query @db_app = "delete from notes where noteId='%s' and userId='%s'" : note_id, sess_user_id \
            affected-rows arows no-loop error errnote
    if-true arows equal 1
        @Note deleted
    else-if
        @Could not delete note (<<p-out errnote>>)
    end-if
%%
// Display a form to add a note
%% /notes/form-add  public
    call-handler "/notes/header"
    @Add New Note
    @<form action="<<p-path "/notes/add">>" method="POST">
    @<textarea name="note" rows="5" cols="50" required autofocus placeholder="Enter Note"></textarea>
    @<button type="submit">Create</button>
    @</form>
%%
// Add a note
%% /notes/add public
    call-handler "/notes/header"
    get-param note, sess_user_id
    run-query @db_app = "insert into notes (dateOf, userId, note) values (now(), '%s', '%s')" : sess_user_id, note \
            affected-rows arows no-loop error errnote
    if-true arows equal 1
        @Note added
    else-if
        @Could not add note (<<p-out errnote>>)
    end-if
%%
// List all notes
%% /notes/list public
    call-handler "/notes/header"
    get-param sess_user_id
    run-query @db_app = "select dateOf, note, noteId from notes where userId='%s' order by dateOf desc" \
            input sess_user_id output dateOf, note, noteId
        match-regex "\n" in note replace-with "<br/>\n" result with_breaks status st cache
        if-true st equal 0
            set-string with_breaks = note 
        end-if
        @Date: <<p-out dateOf>> (<a href="<<p-path "/notes/ask-delete">>?note_id=<<p-out noteId>>">delete note</a>)<br/>
        @Note: <<p-out with_breaks>><br/>
        @<hr/>
    end-query
%%
// Display a question whether to delete a note or not
%% /notes/ask-delete public
    call-handler "/notes/header"
    get-param note_id
    @Are you sure you want to delete a note? Use Back button to go back,\
       or <a href="<<p-path "/notes/delete">>?note_id=<<p-out note_id>>">delete note now</a>.
%%
// Check if session is authorized, and display an appropriate header
%% /notes/header private
    get-param is_logged_in type bool
    if-true is_logged_in equal false
        call-handler "/session/login-or-signup"
    end-if
    @<h1>Welcome to Notes!</h1><hr/>
    if-true is_logged_in equal false
        exit-handler
    end-if
    @<a href="<<p-path "/notes/form-add">>">Add Note</a> <a href="<<p-path "/notes/list">>">List Notes</a><hr/>
%%

Build Application

gg -q --db=postgres:db_app

Run Web Services Application Server

mgrg notes

Emailing

In order to use this example, you need to be able to email local users, which means email addresses such as \"myuser@localhost\". To do that, install Postfix (or sendmail). On Debian systems (like Ubuntu):

sudo apt install postfix
sudo systemctl start postfix


and on Fedora systems (like RedHat):

sudo dnf install postfix
sudo systemctl start postfix


When the application sends an email to a local user, such as <OS user>@localhost, then you can see the email sent at:

sudo vi /var/mail/<OS user>

Setup Nginx

A web server sits in front of the Gliimly application server, so it needs to be set up. This example is for Ubuntu, so edit the Nginx config file there:

sudo vi /etc/nginx/sites-enabled/default


Add this in the "server {}" section:

location /notes/ { include /etc/nginx/fastcgi_params; fastcgi_pass  unix:///var/lib/gg/notes/sock/sock; }


Restart Nginx:

sudo systemctl restart nginx

You're Done; Run It!

Go to your web browser, and enter:

http://127.0.0.1/notes/session/start