-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathindex.php
More file actions
191 lines (159 loc) · 8.3 KB
/
index.php
File metadata and controls
191 lines (159 loc) · 8.3 KB
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
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
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
<?php
/**
* Simple Chat v2.0.2 by Stephan Soller
* http://arkanis.de/projects/simple-chat/
*/
// Name of the message buffer file. You have to create it manually with read and write permissions for the webserver.
$messages_buffer_file = "messages.json";
// Number of most recent messages kept in the buffer.
// Note that message list on clients only shows 1000 messages to avoid slowdown (see JavaScript code below).
$messages_buffer_size = 50;
// Disabled by default, set to true to enable. Appends each chat messages to a chatlog.txt text file.
// This log file is uncapped, so you have to clean it form time to time or it can get very large.
$enable_chatlog = false;
if ( isset($_POST["content"]) and isset($_POST["name"]) ) {
// Create the message buffer file if it doesn't exist yet. That way we don't need a setup and it's writable since it
// was created by the process executing PHP (usually the webserver).
if ( ! file_exists($messages_buffer_file) )
touch($messages_buffer_file);
// Open, lock and read the message buffer file
$buffer = fopen($messages_buffer_file, "r+b");
flock($buffer, LOCK_EX);
$buffer_data = stream_get_contents($buffer);
// Append new message to the buffer data or start with a message id of 0 if the buffer is empty
$messages = $buffer_data ? json_decode($buffer_data, true) : [];
$next_id = (count($messages) > 0) ? $messages[count($messages) - 1]["id"] + 1 : 0;
$messages[] = [ "id" => $next_id, "time" => time(), "name" => $_POST["name"], "content" => $_POST["content"] ];
// Remove old messages if necessary to keep the buffer size
if (count($messages) > $messages_buffer_size)
$messages = array_slice($messages, count($messages) - $messages_buffer_size);
// Rewrite and unlock the message file
ftruncate($buffer, 0);
rewind($buffer);
fwrite($buffer, json_encode($messages));
flock($buffer, LOCK_UN);
fclose($buffer);
// Optional: Append message to log file (file appends are atomic)
if ($enable_chatlog)
file_put_contents("chatlog.txt", date("Y-m-d H:i:s") . "\t" . strtr($_POST["name"], "\t", " ") . "\t" . strtr($_POST["content"], "\t", " ") . "\n", FILE_APPEND);
exit();
}
?>
<!DOCTYPE html>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1.0">
<meta name=author content="Stephan Soller">
<title>Simple Chat</title>
<script type=module>
// Remove the "loading…" list entry
document.querySelector("ul#messages > li").remove()
document.querySelector("form").addEventListener("submit", async event => {
const form = event.target
const name = form.name.value
const content = form.content.value
// Prevent the browsers default action (send form data and show the result page). We just want to send the message without reloading the page.
event.preventDefault()
// Only send a new message if it's not empty (also it's ok for the server we don't need to send senseless messages)
if (name == "" || content == "")
return
// Append a "pending" message (a message not yet confirmed from the server) as soon as the POST request is send. The
// textContent property automatically escapes HTML so no one can harm the client by injecting JavaSript code.
await fetch(form.action, { method: "POST", body: new URLSearchParams({name, content}) })
const messageList = document.querySelector("ul#messages")
const messageElement = messageList.querySelector("template").content.cloneNode(true)
messageElement.querySelector("small").textContent = name
messageElement.querySelector("span").textContent = content
messageList.append(messageElement)
messageList.scrollTop = messageList.scrollHeight
form.content.value = ""
form.content.focus()
})
// Poll-function that looks for new messages
async function poll_for_new_messages() {
// We want the browser to revalidate the cached messages.json file every time. That is it should send a
// conditional request with an If-Modified-Since header. This is the default behaviour in Firefox 115.
// In Chrome 114 it's not. It just uses the cached response without revalidation, thus missing new messages.
// Hence we explicitly tell fetch to revalidate via a conditional request. Because naming things is hard the
// option to do just that is { cache: "no-cache" }. See https://javascript.info/fetch-api#cache
// or https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#force_revalidation
const response = await fetch("messages.json", { cache: "no-cache" })
// Do nothing if messages.json wasn't found (doesn't exist yet probably)
if (!response.ok)
return
const messages = await response.json()
const messageList = document.querySelector("ul#messages")
const messageTemplate = messageList.querySelector("template").content.querySelector("li")
// Determine if we should scroll the message list down to the bottom once we inserted all new messages.
// Only do that if the user already is almost at the bottom (50px at max from it). Otherwise it gets really
// annoying when the list scrolls down every 2 seconds while you want to read old messages further up. Check the
// pixel distance before changing the message list. Otherwise the check gets thrown off by removed or new messages.
const pixelDistanceFromListeBottom = messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight
const scrollToBottom = (pixelDistanceFromListeBottom < 50)
// Remove the pending messages from the list (they are replaced by the ones from the server later)
for (const li of messageList.querySelectorAll("li.pending"))
li.remove()
// Get the ID of the last inserted message or start with -1 (so the first message from the server with 0 will
// automatically be shown).
const lastMessageId = parseInt(messageList.dataset.lastMessageId ?? "-1")
// Add a list entry for every incomming message, but only if we not already inserted it (hence the check for
// the newer ID than the last inserted message).
for (const msg of messages) {
if (msg.id > lastMessageId) {
const date = new Date(msg.time * 1000);
const messageElement = messageTemplate.cloneNode(true)
messageElement.classList.remove("pending")
messageElement.querySelector("small").textContent = Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date) + ": " + msg.name
messageElement.querySelector("span").textContent = msg.content
messageList.append(messageElement)
messageList.dataset.lastMessageId = msg.id
}
}
// Remove all but the last 1000 messages in the list to prevent browser slowdown with extremely large lists
for (const li of Array.from(messageList.querySelectorAll("li")).slice(0, -1000))
li.remove()
// Finally scroll down to the newes messages
if (scrollToBottom)
messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight
}
// Kick of the poll function and repeat it every two seconds
poll_for_new_messages()
setInterval(poll_for_new_messages, 2000)
</script>
<style>
html { margin: 0em; padding: 0; }
body { height: 100vh; box-sizing: border-box; margin: 0; padding: 2em;
font-family: sans-serif; font-size: medium; color: #333;
display: flex; flex-direction: column; gap: 1em; }
body > h1 { flex: 0 0 auto; }
body > ul#messages { flex: 1 1 auto; }
body > form { flex: 0 0 auto; }
h1 { margin: 0; padding: 0; font-size: 2em; }
ul#messages { overflow: auto; margin: 0; padding: 0 3px; list-style: none; border: 1px solid gray; }
ul#messages li { margin: 0.35em 0; padding: 0; }
ul#messages li small { display: block; font-size: 0.59em; color: gray; }
ul#messages li.pending { color: #aaa; }
form { font-size: 1em; margin: 0; padding: 0; }
form p { margin: 0; padding: 0; display: flex; gap: 0.5em; }
form p input { font-size: 1em; min-width: 0; }
form p input[name=name] { flex: 0 1 10em; }
form p input[name=content] { flex: 1 1 auto; }
form p button {}
h1, ul#messages, form { width: 100%; max-width: 40rem; box-sizing: border-box; margin: 0 auto; }
</style>
<h1>Simple Chat</h1>
<ul id=messages>
<li>loading…</li>
<template>
<li class=pending>
<small>…</small>
<span>…</span>
</li>
</template>
</ul>
<form method=post action="<?= htmlentities($_SERVER["PHP_SELF"], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, "UTF-8") ?>">
<p>
<input type=text name=name placeholder="Name" value="Anonymous">
<input type=text name=content placeholder="Message" autofocus>
<button>Send</button>
</p>
</form>