The server
A complete server in four languages: it checks the login and password, writes the punch to a file and only then answers 200, and recognises duplicates and punches made with an unset clock. Each is a few dozen lines with no libraries.
py
#!/usr/bin/env python3
# Minimal RAZ-RCP-1 punch receiver (Python 3.9+, standard library only).
# Reader: Settings > Integrations > Synchronization, format http3,
# URL http://<this computer>:8080/odbicia, login rcp, password = RCP_HASLO.
import base64, hmac, os, re
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Lock
from urllib.parse import parse_qs
LOGIN, HASLO = "rcp", os.environ["RCP_HASLO"]
FILE, lock, seen = "odbicia.tsv", Lock(), set()
class Handler(BaseHTTPRequestHandler):
def answer(self, code, body):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body.encode())
def do_POST(self):
auth = "Basic " + base64.b64encode(f"{LOGIN}:{HASLO}".encode()).decode()
if not hmac.compare_digest(self.headers.get("Authorization", ""), auth):
return self.answer(401, '{"error":"unauthorized"}') # punch stays in the reader
n = int(self.headers.get("Content-Length") or 0)
if n > 4096:
self.close_connection = True
return self.answer(400, '{"error":"too large"}')
form = parse_qs(self.rfile.read(n).decode("utf-8", "replace"))
rfid = form.get("rfidID", [""])[0]
machine = re.sub(r"[\t\r\n]", " ", form.get("machineId", [""])[0])
try:
ts = datetime.strptime(form.get("timestamp", [""])[0], "%Y-%m-%d %H:%M:%S")
except ValueError:
ts = None
if not re.fullmatch(r"[0-9a-f]{1,20}", rfid) or ts is None:
return self.answer(400, '{"error":"bad request"}') # dropped by the reader
epoch = int(ts.replace(tzinfo=timezone.utc).timestamp()) # http3: UTC
if ts.year < 2026:
return self.answer(200, '{"stored":false,"reason":"clock"}')
key = (epoch, rfid, machine)
with lock:
if key in seen:
return self.answer(200, '{"stored":false,"reason":"duplicate"}')
with open(FILE, "a", encoding="utf-8") as f: # store first...
f.write(f"{epoch}\t{rfid}\t{machine}\n")
f.flush()
os.fsync(f.fileno())
seen.add(key)
self.answer(200, '{"stored":true}') # ...then answer 200
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()js
// Minimal RAZ-RCP-1 punch receiver (Node.js 18+, no dependencies).
// Reader: Settings > Integrations > Synchronization, format http3,
// URL http://<this computer>:8080/odbicia, login rcp, password = RCP_HASLO.
const http = require("node:http");
const fs = require("node:fs");
const crypto = require("node:crypto");
const LOGIN = "rcp", HASLO = process.env.RCP_HASLO, FILE = "odbicia.tsv";
const seen = new Set();
const expected = Buffer.from("Basic " + Buffer.from(`${LOGIN}:${HASLO}`).toString("base64"));
function answer(res, code, body) {
res.writeHead(code, { "Content-Type": "application/json" });
res.end(body);
}
http.createServer((req, res) => {
const got = Buffer.from(req.headers.authorization || "");
if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) {
return answer(res, 401, '{"error":"unauthorized"}'); // punch stays in the reader
}
let raw = "";
req.on("data", (chunk) => { if (raw.length < 4096) raw += chunk; });
req.on("end", () => {
const form = new URLSearchParams(raw);
const rfid = form.get("rfidID") || "", machine = form.get("machineId") || "";
const m = /^(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)$/.exec(form.get("timestamp") || "");
if (!/^[0-9a-f]{1,20}$/.test(rfid) || !m) {
return answer(res, 400, '{"error":"bad request"}'); // dropped by the reader
}
const epoch = Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5], m[6]) / 1000; // http3: UTC
if (Number(m[1]) < 2026) return answer(res, 200, '{"stored":false,"reason":"clock"}');
const key = `${epoch}\t${rfid}\t${machine}`;
if (seen.has(key)) return answer(res, 200, '{"stored":false,"reason":"duplicate"}');
const fd = fs.openSync(FILE, "a"); // store first...
fs.writeSync(fd, key.replace(/[\r\n]/g, " ") + "\n");
fs.fsyncSync(fd);
fs.closeSync(fd);
seen.add(key);
answer(res, 200, '{"stored":true}'); // ...then answer 200
});
}).listen(8080);go
// Minimal RAZ-RCP-1 punch receiver (Go 1.21+, standard library only).
// Reader: Settings > Integrations > Synchronization, format http3,
// URL http://<this computer>:8080/odbicia, login rcp, password = RCP_HASLO.
package main
import (
"crypto/subtle"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
)
var (
login, haslo = "rcp", os.Getenv("RCP_HASLO")
rfidRe = regexp.MustCompile(`^[0-9a-f]{1,20}$`)
mu sync.Mutex
seen = map[string]bool{}
)
func answer(w http.ResponseWriter, code int, body string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, body)
}
func punch(w http.ResponseWriter, r *http.Request) {
user, pass, _ := r.BasicAuth()
if subtle.ConstantTimeCompare([]byte(user+":"+pass), []byte(login+":"+haslo)) != 1 {
answer(w, 401, `{"error":"unauthorized"}`) // punch stays in the reader
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
rfid, machine := r.FormValue("rfidID"), r.FormValue("machineId")
ts, err := time.Parse("2006-01-02 15:04:05", r.FormValue("timestamp")) // http3: UTC
if !rfidRe.MatchString(rfid) || err != nil {
answer(w, 400, `{"error":"bad request"}`) // dropped by the reader
return
}
if ts.Year() < 2026 {
answer(w, 200, `{"stored":false,"reason":"clock"}`)
return
}
key := fmt.Sprintf("%d\t%s\t%s", ts.Unix(), rfid, strings.NewReplacer("\r", " ", "\n", " ").Replace(machine))
mu.Lock()
defer mu.Unlock()
if seen[key] {
answer(w, 200, `{"stored":false,"reason":"duplicate"}`)
return
}
f, err := os.OpenFile("odbicia.tsv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) // store first...
if err == nil {
_, err = f.WriteString(key + "\n")
if err == nil {
err = f.Sync()
}
f.Close()
}
if err != nil {
answer(w, 500, `{"error":"storage"}`) // the reader retries later
return
}
seen[key] = true
answer(w, 200, `{"stored":true}`) // ...then answer 200
}
func main() {
http.HandleFunc("POST /odbicia", punch)
http.ListenAndServe(":8080", nil)
}php
<?php
// Minimal RAZ-RCP-1 punch receiver (PHP 8.1+). Run: RCP_HASLO=secret php -S 0.0.0.0:8080 index.php
// Reader: Settings > Integrations > Synchronization, format http3,
// URL http://<this computer>:8080/odbicia, login rcp, password = RCP_HASLO.
// Under Apache/nginx keep odbicia.tsv outside the web root.
$login = 'rcp';
$haslo = getenv('RCP_HASLO');
$file = __DIR__ . '/odbicia.tsv';
function answer(int $code, string $body): never {
http_response_code($code);
header('Content-Type: application/json');
exit($body);
}
$given = ($_SERVER['PHP_AUTH_USER'] ?? '') . ':' . ($_SERVER['PHP_AUTH_PW'] ?? '');
if (!hash_equals("$login:$haslo", $given)) {
answer(401, '{"error":"unauthorized"}'); // punch stays in the reader
}
$rfid = $_POST['rfidID'] ?? '';
$machine = str_replace(["\r", "\n", "\t"], ' ', $_POST['machineId'] ?? '');
$ts = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $_POST['timestamp'] ?? '', new DateTimeZone('UTC'));
if (!preg_match('/^[0-9a-f]{1,20}$/', $rfid) || !$ts) {
answer(400, '{"error":"bad request"}'); // dropped by the reader
}
if ((int)$ts->format('Y') < 2026) {
answer(200, '{"stored":false,"reason":"clock"}');
}
$line = $ts->getTimestamp() . "\t$rfid\t$machine\n";
$f = fopen($file, 'c+');
flock($f, LOCK_EX); // one request at a time
while (($l = fgets($f)) !== false) {
if ($l === $line) {
answer(200, '{"stored":false,"reason":"duplicate"}');
}
}
fwrite($f, $line); // store first...
fflush($f);
fsync($f);
answer(200, '{"stored":true}'); // ...then answer 200 (exit releases the lock)Run it (with the same password as in the reader):
bash
RCP_HASLO=secret python3 server.pybash
RCP_HASLO=secret node server.jsbash
RCP_HASLO=secret go run main.gobash
RCP_HASLO=secret php -S 0.0.0.0:8080 index.phpThe server listens on port 8080 and appends punches to odbicia.tsv (epoch<TAB>rfidID<TAB>machineId).
Full examples in six languages (Python, Go, Node.js, Java, C#, PHP), with a working time report and an employee list, are in examples/RAZ-RCP-1/http.