Make your own blog
Tidy up
We've got a nice set of features working, but there are a good number of improvements and refactoring changes that can be made. Doing these periodically can make for easier and happier development, which in turn can improve your development efficiency.
The first change is two-fold: pages were replicating some information in the header, and various items (the top menu bar and system messages) were using inline style rules rather than using class rules that need only be written once.
- assets/main.css assets/main.css
- index.php index.php
- install.php install.php
- login.php login.php
- templates/comment-form.php templates/comment-form.php
- templates/head.php templates/head.php
- templates/title.php templates/title.php
- view-post.php view-post.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* Success/error message boxes */
.box {
border: 1px dotted silver;
border-radius: 5px;
padding: 4px;
}
.error {
background-color: #ff6666;
}
.success {
background-color: #88ff88;
}
.install-password {
font-size: 1.2em;
}
.top-menu {
border: 1px dotted silver;
min-height: 18px;
padding: 4px;
margin-bottom: 4px;
}
.menu-options {
float: right;
}
h1, h2, h3 {
margin-top: 0;
margin-bottom: 8px;
}
body {
font-family: sans-serif;
}
25
26
27
28
29
30
31
32
33
34
35
36
37
<html>
<head>
<title>A blog application</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
<?php if ($notFound): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
Error: cannot find the requested blog post
</div>
<?php endif ?>
25
26
27
28
29
30
31
32
33
34
35
36
37
<html>
<head>
<title>A blog application</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>
<?php if ($notFound): ?>
<div class="error box">
Error: cannot find the requested blog post
</div>
<?php endif ?>
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
78
79
80
81
82
83
84
<html>
<head>
<title>Blog installer</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<style type="text/css">
.box {
border: 1px dotted silver;
border-radius: 5px;
padding: 4px;
}
.error {
background-color: #ff6666;
}
.success {
background-color: #88ff88;
}
</style>
</head>
<body>
<?php if ($attempted): ?>
<?php // Report the new password ?>
The new '<?php echo htmlEscape($username) ?>' password is
<span style="font-size: 1.2em;"><?php echo htmlEscape($password) ?></span>
(copy it to clipboard if you wish).
</div>
52
53
54
55
56
57
58
78
79
80
81
82
83
84
<html>
<head>
<title>Blog installer</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php if ($attempted): ?>
<?php // Report the new password ?>
The new '<?php echo htmlEscape($username) ?>' password is
<span class="install-password"><?php echo htmlEscape($password) ?></span>
(copy it to clipboard if you wish).
</div>
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<title>
A blog application | Login
</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
<?php // If we have a username, then the user got something wrong, so let's have an error ?>
<?php if ($username): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
The username or password is incorrect, try again
</div>
<?php endif ?>
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<title>
A blog application | Login
</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>
<?php // If we have a username, then the user got something wrong, so let's have an error ?>
<?php if ($username): ?>
<div class="error box">
The username or password is incorrect, try again
</div>
<?php endif ?>
10
11
12
13
14
15
16
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
10
11
12
13
14
15
16
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
1
2
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<link rel="stylesheet" type="text/css" href="assets/main.css" />
1
2
3
4
5
6
7
8
9
10
<div style="float: right;">
<?php if (isLoggedIn()): ?>
Hello <?php echo htmlEscape(getAuthUser()) ?>.
<a href="logout.php">Log out</a>
<?php else: ?>
<a href="login.php">Log in</a>
<?php endif ?>
</div>
<a href="index.php">
1
2
3
4
5
6
7
8
9
10
11
12
<div class="top-menu">
<div class="menu-options">
<?php if (isLoggedIn()): ?>
Hello <?php echo htmlEscape(getAuthUser()) ?>.
<a href="logout.php">Log out</a>
<?php else: ?>
<a href="login.php">Log in</a>
<?php endif ?>
</div>
</div>
<a href="index.php">
62
63
64
65
66
67
68
A blog application |
<?php echo htmlEscape($row['title']) ?>
</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
62
63
64
65
66
67
68
A blog application |
<?php echo htmlEscape($row['title']) ?>
</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>
Let's take a better look at the second change. Some of the original code was written like this:
<div style="border: 1px solid #ff6666; padding: 6px;"> … </div>
The purpose of the style
attribute to specify CSS rules (otherwise known as
style rules) to the HTML within. Firstly, we have the border
rule, which says
the content (an error message) should have a red border rendered in an unbroken line, and
the padding
means that there should be six pixels of gap between the border and
an invisible box around the content.
However, having to write that for every error message takes a bit of effort, and it's a pain if we decide that error messages should have a magenta border and not a red one. Thus, it makes life easier if we write this instead:
<div class="error box"> … </div>
That applies two rules to the block: one called error
and another called
box
. That's much easier to remember, easier to read, and — since we
centralise the definitions in assets/main.css — easier
to change if we have to.
While we are dealing with CSS changes, let's add a few more. Here we add some rules for article synopses, titles and dates on the home page, and comments on individual blog posts.
- assets/main.css assets/main.css
- index.php index.php
- templates/comment-form.php templates/comment-form.php
- view-post.php view-post.php
33
34
35
36
body {
font-family: sans-serif;
}
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
body {
font-family: sans-serif;
}
.post-synopsis {
padding-bottom: 8px;
border-bottom: 1px dotted silver;
margin-bottom: 20px;
}
.post-synopsis h2, .post h2 {
color: darkblue;
}
.post .date, .post-synopsis .meta {
color: white;
background-color: grey;
border-radius: 7px;
padding: 2px;
display: inline;
font-size: 0.95em;
}
.comment .comment-meta {
font-size: 0.85em;
color: grey;
border-top: 1px dotted silver;
padding-top: 8px;
}
.comment-body p {
margin: 8px 4px;
}
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
</div>
<?php endif ?>
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div>
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
</p>
<p>
<a
href="view-post.php?post_id=<?php echo $row['id'] ?>"
>Read more...</a>
</p>
<?php endwhile ?>
</body>
</html>
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
</div>
<?php endif ?>
<div class="post-list">
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<div class="post-synopsis">
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
</p>
<div class="read-more">
<a
href="view-post.php?post_id=<?php echo $row['id'] ?>"
>Read more...</a>
</div>
</div>
<?php endwhile ?>
</div>
</body>
</html>
5
6
7
8
9
10
11
12
13
*/
?>
<?php // We'll use a rule-off for now, to separate page sections ?>
<hr />
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
5
6
7
8
9
10
*/
?>
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<body>
<?php require 'templates/title.php' ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div>
<?php echo convertSqlDate($row['created_at']) ?>
</div>
<?php // This is already escaped, so doesn't need further escaping ?>
<?php echo convertNewlinesToParagraphs($row['body']) ?>
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php foreach (getCommentsForPost($postId) as $comment): ?>
<?php // For now, we'll use a horizontal rule-off to split it up a bit ?>
<hr />
<div class="comment">
<div class="comment-meta">
Comment from
<?php echo htmlEscape($comment['name']) ?>
on
<?php echo convertSqlDate($comment['created_at']) ?>
</div>
<div class="comment-body">
<?php // This is already escaped ?>
<?php echo convertNewlinesToParagraphs($comment['text']) ?>
</div>
</div>
<?php endforeach ?>
<?php require 'templates/comment-form.php' ?>
</body>
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<body>
<?php require 'templates/title.php' ?>
<div class="post">
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div class="date">
<?php echo convertSqlDate($row['created_at']) ?>
</div>
<?php // This is already escaped, so doesn't need further escaping ?>
<?php echo convertNewlinesToParagraphs($row['body']) ?>
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php foreach (getCommentsForPost($postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
<?php echo htmlEscape($comment['name']) ?>
on
<?php echo convertSqlDate($comment['created_at']) ?>
</div>
<div class="comment-body">
<?php // This is already escaped ?>
<?php echo convertNewlinesToParagraphs($comment['text']) ?>
</div>
</div>
<?php endforeach ?>
</div>
<?php require 'templates/comment-form.php' ?>
</body>
You may have noticed that some function calls that need to access the database create their own connection rather than use one that we've already created. For low-volume systems this might not matter a great deal, but programmers hate this sort of inefficiency, and where it is trivial to fix, we should.
- index.php index.php
- lib/common.php lib/common.php
- view-post.php view-post.php
45
46
47
48
49
50
51
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
45
46
47
48
49
50
51
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($pdo, $row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
94
95
96
97
98
99
100
101
102
103
104
105
119
120
121
122
123
124
125
126
127
128
129
/**
* Returns the number of comments for the specified post
*
* @param integer $postId
* @return integer
*/
function countCommentsForPost($postId)
{
$pdo = getPDO();
$sql = "
SELECT
COUNT(*) c
/**
* Returns all the comments for the specified post
*
* @param integer $postId
*/
function getCommentsForPost($postId)
{
$pdo = getPDO();
$sql = "
SELECT
id, name, text, created_at, website
94
95
96
97
98
99
100
101
102
103
104
105
119
120
121
122
123
124
125
126
127
128
129
130
/**
* Returns the number of comments for the specified post
*
* @param PDO $pdo
* @param integer $postId
* @return integer
*/
function countCommentsForPost(PDO $pdo, $postId)
{
$sql = "
SELECT
COUNT(*) c
/**
* Returns all the comments for the specified post
*
* @param PDO $pdo
* @param integer $postId
* return array
*/
function getCommentsForPost(PDO $pdo, $postId)
{
$sql = "
SELECT
id, name, text, created_at, website
80
81
82
83
84
85
86
87
88
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php foreach (getCommentsForPost($postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
80
81
82
83
84
85
86
87
88
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($pdo, $postId) ?> comments</h3>
<?php foreach (getCommentsForPost($pdo, $postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
Now we improve the appearance of the comment form. It's worth opening up an article page prior to making the improvements, so you can refresh it after the CSS is in place. This will allow you to see the change quickly.
- assets/main.css assets/main.css
- templates/comment-form.php templates/comment-form.php
10
11
12
13
14
15
76
77
78
79
.success {
background-color: #88ff88;
}
.install-password {
font-size: 1.2em;
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
10
11
12
13
14
15
16
17
18
19
20
21
22
23
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
.success {
background-color: #88ff88;
}
.box ul {
margin: 4px;
padding-left: 14px;
}
.box ul li {
margin-bottom: 2px;
}
.install-password {
font-size: 1.2em;
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
.comment-margin {
margin-bottom: 8px;
}
.comment-form input,
.comment-form textarea {
margin: 4px;
}
.comment-form label {
font-size: 0.95em;
margin: 6px;
width: 7em;
color: grey;
float: left;
text-align: right;
/* Some browsers make labels too tall, and as a result they incorrectly stack horizontally.
Let's reset each to the left-hand side to be sure. */
clear: left;
}
7
8
9
10
11
12
13
18
19
20
21
22
23
24
25
29
30
31
32
33
34
35
36
40
41
42
43
44
45
46
47
51
52
53
54
55
56
57
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
<h3>Add your comment</h3>
<form method="post">
<p>
<label for="comment-name">
Name:
</label>
name="comment-name"
value="<?php echo htmlEscape($commentData['name']) ?>"
/>
</p>
<p>
<label for="comment-website">
Website:
</label>
name="comment-website"
value="<?php echo htmlEscape($commentData['website']) ?>"
/>
</p>
<p>
<label for="comment-text">
Comment:
</label>
rows="8"
cols="70"
><?php echo htmlEscape($commentData['text']) ?></textarea>
</p>
<input type="submit" value="Submit comment" />
</form>
7
8
9
10
11
12
13
18
19
20
21
22
23
24
25
29
30
31
32
33
34
35
36
40
41
42
43
44
45
46
47
51
52
53
54
55
56
57
58
59
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box comment-margin">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
<h3>Add your comment</h3>
<form method="post" class="comment-form">
<div>
<label for="comment-name">
Name:
</label>
name="comment-name"
value="<?php echo htmlEscape($commentData['name']) ?>"
/>
</div>
<div>
<label for="comment-website">
Website:
</label>
name="comment-website"
value="<?php echo htmlEscape($commentData['website']) ?>"
/>
</div>
<div>
<label for="comment-text">
Comment:
</label>
rows="8"
cols="70"
><?php echo htmlEscape($commentData['text']) ?></textarea>
</div>
<div>
<input type="submit" value="Submit comment" />
</div>
</form>
We now turn our attention to an improvement to the database. This consists of the tables
post
and user
, for blog posts and authors respectively. When
creating a post record, we insert our automatically generated user.id
in
post.user_id
to store which user has authored it (of course, we only have one
user in our test data, but in practice we might have several).
However, it is possible to insert any number in post.user_id
, so if we have
an undiscovered bug in our code, it might cause a non-existent user primary key to be stored
here. Since we rely on values here always pointing to a user row, if a bad value were to get
in, it might crash our application.
To protect against that scenario, many database systems will allow the use of foreign key
constraints. These allow us to specify that values inserted into a particular column must
exist in another column belonging to another table, and that an error must occur if this
condition is not met. SQLite offers this feature, although it is unusual in that it needs to
be turned on explicitly, using the PRAGMA
command.
So, let's make these changes:
- data/init.sql data/init.sql
- lib/common.php lib/common.php
- lib/install.php lib/install.php
2
3
4
5
6
7
36
37
38
39
40
41
42
91
92
93
94
95
96
97
122
123
124
125
126
127
128
129
130
131
132
* Database creation script
*/
DROP TABLE IF EXISTS post;
CREATE TABLE post (
body VARCHAR NOT NULL,
user_id INTEGER NOT NULL,
created_at VARCHAR NOT NULL,
updated_at VARCHAR
);
INSERT INTO
created_at VARCHAR NOT NULL,
name VARCHAR NOT NULL,
website VARCHAR,
text VARCHAR NOT NULL
);
INSERT INTO
"This is a comment from Jonny"
)
;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
created_at VARCHAR NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
36
37
38
39
40
41
42
43
91
92
93
94
95
96
97
98
122
123
124
* Database creation script
*/
/* Foreign key constraints need to be explicitly enabled in SQLite */
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
created_at VARCHAR NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true
);
/* This will become user = 1. I'm creating this just to satisfy constraints here.
The password will be properly hashed in the installer */
INSERT INTO
user
(
username, password, created_at, is_enabled
)
VALUES
(
"admin", "unhashed-password", datetime('now', '-3 months'), 0
)
;
DROP TABLE IF EXISTS post;
CREATE TABLE post (
body VARCHAR NOT NULL,
user_id INTEGER NOT NULL,
created_at VARCHAR NOT NULL,
updated_at VARCHAR,
FOREIGN KEY (user_id) REFERENCES user(id)
);
INSERT INTO
created_at VARCHAR NOT NULL,
name VARCHAR NOT NULL,
website VARCHAR,
text VARCHAR NOT NULL,
FOREIGN KEY (post_id) REFERENCES post(id)
);
INSERT INTO
"This is a comment from Jonny"
)
;
37
38
39
40
41
42
43
*/
function getPDO()
{
return new PDO(getDsn());
}
/**
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
*/
function getPDO()
{
$pdo = new PDO(getDsn());
// Foreign key constraints need to be enabled manually in SQLite
$result = $pdo->query('PRAGMA foreign_keys = ON');
if ($result === false)
{
throw new Exception('Could not turn on foreign key constraints');
}
return $pdo;
}
/**
74
75
76
77
78
79
80
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
133
134
135
136
137
138
139
}
/**
* Creates a new user in the database
*
* @param PDO $pdo
* @param string $username
// Insert the credentials into the database
$sql = "
INSERT INTO
user
(username, password, created_at)
VALUES (
:username, :password, :created_at
)
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
$error = 'Could not prepare the user creation';
}
if (!$error)
);
if ($result === false)
{
$error = 'Could not run the user creation';
}
}
74
75
76
77
78
79
80
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
133
134
135
136
137
138
139
}
/**
* Updates the admin user in the database
*
* @param PDO $pdo
* @param string $username
// Insert the credentials into the database
$sql = "
UPDATE
user
SET
password = :password, created_at = :created_at, is_enabled = 1
WHERE
username = :username
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
$error = 'Could not prepare the user update';
}
if (!$error)
);
if ($result === false)
{
$error = 'Could not run the user password update';
}
}
Here's what's new:
- Since we want posts to reference users, we need a user first. Thus, I've swapped the order around, so the test user is created before the test post
-
We now need the test user to be created immediately in order to satisfy the foreign key
constraint, so we
INSERT
that in the script with a dummy hash value, andUPDATE
it afterwards with the real hash generated in PHP - Now when we make database connections in common.php, the first thing we do is to switch on foreign key constraints
As usual: delete your database file, re-run the installer, and check all the changes appear to work.
The last change is related to security. Since all of our files are in the web server's public directory, a user who knows our directory structure would be able to download files that we didn't intend to make accessible. Since SQLite uses a single file to store its data, and since this file is often stored in a web-accessible location, it is of particular importance to lock this down.
- .htaccess .htaccess
1
2
3
4
RewriteEngine on
RewriteCond %{REQUEST_URI} ^/(data|lib|templates|vendor)/
RewriteRule ^ - [L,R=404]
The file you've just added is used to set options for the web server, and in this case
for a module known as mod_rewrite
. This module allows the creation of special rules
for various web addresses, and in our case to refuse to serve ones that are private.
I'll provide a little information about these rules below, but if you don't fully follow them,
don't worry. They are rather out of scope for a beginners' course, and we won't need to
consider them again.
What do the mod_rewrite commands do?
The first command is simple:
RewriteEngine on
enables the module for the site. After that, theRewriteCond
sets up a condition (i.e. when the rule will apply) and theRewriteRule
does the action.The condition is looking at a placeholder known as
%{REQUEST_URI}
. This is the URL of the page, relative to the domain. So it will always start with / and then it will contain the address, e.g. /styles/assets/main.css (our stylesheet) or /install.php (a PHP page). However there are some resources we want users specifically not to access directly.These ones are in the data, lib, templates and vendor folders, so we add those into the second part of this command. The bar character means "or" and the
^
means "begins with". This format is known as a regular expression, and is a popular and flexible method of testing strings for certain conditions.The
RewriteRule
basically says if you get a match, then this is the (L
) last rule (i.e. don't process any more rules) and the server should (R
) redirect to a 404 page (a special server code for "not found").