From ca1662c4037b7bfb9314a65d0ee23fd7159e5fe9 Mon Sep 17 00:00:00 2001 From: lost+skunk Date: Fri, 7 Feb 2025 01:45:14 +0300 Subject: [PATCH] ALPHA: URL parser --- .gitignore | 8 +- dub.sdl | 6 + lib.d | 172 --------------------------- main.d | 23 ---- source/uselesshttpd/package.d | 168 ++++++++++++++++++++++++++ util.d => source/uselesshttpd/util.d | 87 +++++++++++++- 6 files changed, 264 insertions(+), 200 deletions(-) create mode 100644 dub.sdl delete mode 100644 lib.d delete mode 100644 main.d create mode 100644 source/uselesshttpd/package.d rename util.d => source/uselesshttpd/util.d (61%) diff --git a/.gitignore b/.gitignore index 1530978..d675534 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -*.o \ No newline at end of file +tst +*.o +*.a +*.exe +*.o +*.obj +*.lst \ No newline at end of file diff --git a/dub.sdl b/dub.sdl new file mode 100644 index 0000000..5ac66b0 --- /dev/null +++ b/dub.sdl @@ -0,0 +1,6 @@ +name "uselesshttpd" +description "Dead simple web server library for lost+skunk's projects, powered by Linux epoll" +authors "lost+skunk" +copyright "Copyright © 2025, lost+skunk" +license "WTFPL" +dflags "-i" diff --git a/lib.d b/lib.d deleted file mode 100644 index acd1243..0000000 --- a/lib.d +++ /dev/null @@ -1,172 +0,0 @@ -module lib; - -/* - lost+skunk , 2025; - Licensed under WTFPL -*/ - -import core.stdc.stdio; -import core.sys.linux.epoll; -import core.sys.linux.netinet.tcp; -import core.sys.posix.netinet.in_; -import core.sys.posix.unistd; -import core.sys.posix.sys.socket; - -import util; - -struct Response { - short status = 200; - ubyte[] body; - string[string] headers; - string mimetype = "application/octet-stream"; -} - -struct Request { - enum Methods { - GET = "GET", - PUT = "PUT", - POST = "POST", - DELETE = "DELETE", - OPTIONS = "OPTIONS", - // остальное лень реализовывать, да и не трэба.. - } - - Methods method; - string path, body; - string[string] headers; -} - -struct Server { - string address; - short port; - - private shared int epfd, sock; - void start(MD...)() { // реализовать прокидывания структуры/класса для роутинга - sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, cast(void*)(new int), int.sizeof); - setsockopt(sock, IPPROTO_TCP, SO_REUSEADDR, cast(void*)(new int), int.sizeof); - - sockaddr_in sockt; - sockt.sin_family = AF_INET; - sockt.sin_port = htons(port); - sockt.sin_addr.s_addr = inet_addr(cast(char*)address); - - err(bind(sock, cast(sockaddr*)&sockt, sockt.sizeof), "bind"); - err(listen(sock, 512), "listen"); - serve!MD; - } - - void shutdown() { - epfd.close(); - sock.close(); - } - - void serve(T...)() { - epfd = epoll_create(MAX_CLIENTS); - epoll_event ev; - epoll_event[MAX_EVENTS] evts; - - ev.events = EPOLLIN | EPOLLOUT | EPOLLET; - ev.data.fd = sock; - epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); - - for (;;) { - auto w = epoll_wait(epfd, &evts[0], MAX_EVENTS, -1); - for (int i = 0; i < w; ++i) { - auto fd = evts[i].data.fd; - if (fd == sock) { - sockaddr_in addr; - socklen_t al = sockaddr_in.sizeof; - - ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP | EPOLLHUP; - ev.data.fd = accept4(sock, cast(sockaddr*)&addr, &al, SOCK_NONBLOCK); - epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); - } else if (evts[i].events & EPOLLIN) { rd: - ubyte[1024] buf; - for (;;) { // обработчик запросов - if (read(fd, cast(void*)buf, buf.sizeof) > 0) { - auto rqst = parseReq(cast(string)buf); - static foreach (mm; __traits(allMembers, T)) { - static if (__traits(isStaticFunction, __traits(getMember, T, mm))) { - foreach(attr; __traits(getAttributes, __traits(getMember, T, mm))) { - static if (is(typeof(attr) == Location)) { - if (rqst.path == attr.path) { - Response rsp; - __traits(getMember, T, mm)(&rsp, &rqst); - - char[] headers; - foreach(header,content;rsp.headers) - headers ~= "\r\n" ~ header ~ ": " ~ content; - auto head = - "HTTP/1.1 " ~ intToStr(rsp.status) ~ ' ' ~ getStatus(rsp.status) // временно - ~ "\r\nContent-length: " ~ intToStr(rsp.body.length) - ~ "\r\nContent-Type: " ~ rsp.mimetype - ~ headers - ~ "\r\n\r\n"; - - write(fd, cast(void*)head, head.length); - write(fd, cast(void*)rsp.body, rsp.body.length); - goto rd; - } else continue; - } - } - } - } - static auto resp="HTTP/1.1 404 Not Found\r\nContent-length: 0\r\n\r\n"; - write(fd, cast(void*)resp, resp.length); - } else break; - } - } - if (evts[i].events & (EPOLLRDHUP | EPOLLHUP)) { - epoll_ctl(epfd, EPOLL_CTL_DEL, fd, null); - close(fd); - break; - } - } - } - } - - Request parseReq(string body) { // TODO: реализовать парсинг заголовков, оформленных хер пойми как - int prev; - short[] xxx; - Request req; - - for (short i; i < body.length; ++i) { - if (body[i] == '\r') { - auto splitted = body[prev..i]; - for (short x = 1; x < splitted.length; ++x) { - if (prev == 0) { // для прочего говна (метода, пути и протокола) - if (splitted[x] == ' ') - xxx ~= x; - else if (xxx.length == 2) { - req.method = splitted[0..xxx[0]]; - req.path = splitted[xxx[0]+1..xxx[1]]; - // if (splitted[xxx[1]..$] != " HTTP/1.1") - // throw new Exception("Unsupported HTTP version"); - } else continue; - } else if (splitted[x-1] == ':') { // для заголовков - req.headers[splitted[0..x-1]] = splitted[x+1..$]; - break; - } - } - - prev = i+2; - if (body[prev] == '\r') { - req.body = body[prev+2..$]; - break; - } - } - } - return req; - } -} - -private: -enum BACKLOG = 512; -enum MAX_EVENTS = 512; -enum MAX_CLIENTS = 512; -enum MAX_MESSAGE_LEN = 2048; -enum SOCK_NONBLOCK = 0x800; -enum MAX_RESPONSES = 512; - -extern (C) int accept4(int, sockaddr*, socklen_t*, int); \ No newline at end of file diff --git a/main.d b/main.d deleted file mode 100644 index af63654..0000000 --- a/main.d +++ /dev/null @@ -1,23 +0,0 @@ -module app; // обязательно -import lib, util: Location; -void main() { - auto srv = Server("127.0.0.1", 3003); - srv.start!app; - // srv.shutdown; -} - -@Location("/skunk") -void huy(Response* w, Request* r) { - w.body = cast(ubyte[])"skunk"; - return; -} - -@Location("/skunks") -void z(Response* w, Request* r) { - w.headers["X-Powered-By"] = "D"; - w.headers["Lang"] = "D"; - w.status = 410; - w.mimetype = "text/plain;charset=utf-8"; - w.body = cast(ubyte[])"скунсы"; - return; -} \ No newline at end of file diff --git a/source/uselesshttpd/package.d b/source/uselesshttpd/package.d new file mode 100644 index 0000000..3092894 --- /dev/null +++ b/source/uselesshttpd/package.d @@ -0,0 +1,168 @@ +// module source; + +/* + lost+skunk , 2025; + Licensed under WTFPL +*/ + +import core.stdc.stdio; +import core.sys.linux.epoll; +import core.sys.linux.netinet.tcp; +import core.sys.posix.netinet.in_; +import core.sys.posix.unistd; +import core.sys.posix.sys.socket; + +import uselesshttpd.util; + +struct Response { + short status = 200; + void[] body; + string[string] headers; + string mimetype = "application/octet-stream"; +} + +struct Server { + string address; + short port; + + private shared int epfd, sock; + void start(MD...)() { // TODO: реализовать "слушальщика" хрюникс сокетов (AF_UNIX) + bool ipv6; + for (short i; i < address.length; ++i) + if (address[i] == ':') {ipv6=true;break;} + + if (ipv6) { + sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP); + sockaddr_in6 sockt; + sockt.sin6_family = AF_INET6; + sockt.sin6_port = htons(port); + inet_pton(AF_INET6, cast(char*)address, &sockt.sin6_addr); + err(bind(sock, cast(sockaddr*)&sockt, sockt.sizeof), "bind"); + } else { + sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + sockaddr_in sockt; + sockt.sin_family = AF_INET; + sockt.sin_port = htons(port); + sockt.sin_addr.s_addr = inet_addr(cast(char*)address); + err(bind(sock, cast(sockaddr*)&sockt, sockt.sizeof), "bind"); + } + + setsockopt(sock, + IPPROTO_TCP, TCP_NODELAY | SO_REUSEADDR | SO_REUSEPORT, + cast(void*)(new int), int.sizeof); + + err(listen(sock, 512), "listen"); + serve!MD; + } + + void stop() { + epfd.close(); + sock.close(); + } + + void serve(T...)() { + epfd = epoll_create(MAX_CLIENTS); + epoll_event ev; + epoll_event[MAX_EVENTS] evts; + + ev.data.fd = sock; + ev.events = EPOLLIN | EPOLLEXCLUSIVE; + epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); + + for (;;) { + auto w = epoll_wait(epfd, &evts[0], MAX_EVENTS, -1); + for (int i = 0; i < w; ++i) { + auto fd = evts[i].data.fd; + if (fd == sock) { // TODO: добавить поддержку ipv6 + // sockaddr_in addr; + // socklen_t al = sockaddr_in.sizeof; + // ev.data.fd = accept4(sock, cast(sockaddr*)&addr, &al, SOCK_NONBLOCK); + ev.data.fd = accept4(sock, null, null, SOCK_NONBLOCK); + epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); + // ip = cast(string)inet_ntoa(addr.sin_addr); + } + + // if (ev.data.fd == -1) { + rd: + ubyte[128] buf; + for (;;) { // обработчик запросов + auto rd = recv(fd, cast(void*)buf, buf.sizeof, 0); + if (rd < 1) break; + auto rqst = parseReq(buf); + static foreach (mm; __traits(allMembers, T)) { + static if (__traits(isStaticFunction, __traits(getMember, T, mm))) { + foreach(attr; __traits(getAttributes, __traits(getMember, T, mm))) { + static if (is(typeof(attr) == Location)) { + parseAndValidateURL(rqst.path, &rqst); + if (rqst.path == attr.path) { + Response rsp; + __traits(getMember, T, mm)(&rsp, &rqst); + + char[] headers; + foreach(header,content;rsp.headers) + headers ~= "\r\n" ~ header ~ ": " ~ content; + auto response = + "HTTP/1.1 " ~ intToStr(rsp.status) ~ ' ' ~ getStatus(rsp.status) + ~ "\r\nContent-length: " ~ intToStr(rsp.body.length) + ~ "\r\nContent-Type: " ~ rsp.mimetype + ~ headers + ~ "\r\n\r\n" + ~ rsp.body; + + write(fd, cast(void*)response, response.length); + goto rd; + } else continue; + } + } + } + } + static auto resp = "HTTP/1.1 404 Not Found\r\nContent-length: 13\r\n\r\n404 Not Found"; + write(fd, cast(void*)resp, resp.length); + } + } + } + } + + Request parseReq(ubyte[] body) { // TODO: реализовать парсинг заголовков, оформленных хер пойми как + int prev; + short[] xxx; + Request req; + + for (short i; i < body.length; ++i) { + if (body[i] == '\r') { + auto splitted = body[prev..i]; + for (short x = 1; x < splitted.length; ++x) { + if (prev == 0) { // для прочего говна (метода, пути и протокола) + if (splitted[x] == ' ') xxx ~= x; + else if (xxx.length == 2) { + req.method = cast(req.Methods)splitted[0..xxx[0]]; + req.path = cast(char[])splitted[xxx[0]+1..xxx[1]]; + // if (splitted[xxx[1]..$] != " HTTP/1.1") + // throw new Exception("Unsupported HTTP version"); + } else continue; + } else if (splitted[x-1] == ':') { // для заголовков + req.headers[cast(string)splitted[0..x-1]] = cast(string)splitted[x+1..$]; + break; + } + } + + prev = i+2; + if ((prev < body.length && prev + 2 <= body.length) && body[prev] == '\r') { + req.body = body[prev+2..$]; + break; + } + } + } + return req; + } +} + +private: +enum BACKLOG = 512; +enum MAX_EVENTS = 512; +enum MAX_CLIENTS = 512; +enum MAX_MESSAGE_LEN = 2048; +enum SOCK_NONBLOCK = 0x800; +enum MAX_RESPONSES = 512; + +extern (C) int accept4(int, sockaddr*, socklen_t*, int); \ No newline at end of file diff --git a/util.d b/source/uselesshttpd/util.d similarity index 61% rename from util.d rename to source/uselesshttpd/util.d index e9ee022..674b8ce 100644 --- a/util.d +++ b/source/uselesshttpd/util.d @@ -1,4 +1,9 @@ -module util; +module uselesshttpd.util; + +/* + lost+skunk , 2025; + Licensed under WTFPL +*/ char[] intToStr(T)(T num) { char[] buf; @@ -22,7 +27,82 @@ string getStatus(short status) { static foreach(mmbr; __traits(allMembers, Statuses)) if (__traits(getMember, Statuses, mmbr) == status) return __traits(getAttributes, __traits(getMember, Statuses, mmbr))[0]; - return ""; + return "WTF"; +} + +// FIXME: memory leak +void append(char[]* src, char symb) @nogc { + import core.memory: pureMalloc, pureFree; + auto arr = + cast (char[]) + pureMalloc(src.length + 1) + [0..src.length + 1]; + + arr[$-1..$] = symb; + arr[0..$-1] = *src; + *src = arr; + + arr = null; + pureFree(arr.ptr); +} + +void parseAndValidateURL(char[] url, Request* rqst) { + if (url.length > 2048) throw new Exception("Too long URL"); + rqst.path = null; + bool notArgumentPart; + scope (exit) append(&rqst.args, '&'); + for (short i; i < url.length; ++i) { + switch (url[i]) { + case '?': + if (notArgumentPart) goto default; + notArgumentPart = true; + break; + case '/': + if (url.length > i+1 && url[i+1] != '/') append(&rqst.path, '/'); + break; + case '=', '&': + if (notArgumentPart && url[i-1] != url[i]) append(&rqst.args, url[i]); + break; + + case 'A': .. case 'Z': + case 'a': .. case 'z': + case '0': .. case '9': + case '-', '_', '.', '~', '!', '$', '\'', '(', ')', '*', '+', ',', ';', '@', '[', ']', '|', '%': + if (notArgumentPart) append(&rqst.args, url[i]); + else append(&rqst.path, url[i]); + break; + default: throw new Exception("Malformed URL"); + } + } +} + +struct Request { + enum Methods { + GET = "GET", + PUT = "PUT", + POST = "POST", + DELETE = "DELETE", + OPTIONS = "OPTIONS", + // остальное лень реализовывать, да и не трэба.. + } + + Methods method; + void[] body; + string[string] headers; + + char[] path; + char[] args; + char[] getArgument(string arg) @nogc nothrow { + short split, prev; + for (short i; i < args.length; ++i) { + if (args[i] == '=') split = i; + else if (args[i] == '&') { + if (arg == args[prev..split]) return args[split+1..i]; + prev = ++i; + } + } + return null; + } } private static enum Statuses: short { // спизженно с https://github.com/zigzap/zap/blob/675c65b509d48c21a8d1fa4c5ec53fc407643a3b/src/http.zig#L6 @@ -93,6 +173,5 @@ private static enum Statuses: short { // спизженно с https://github.co @("Service Unavailable") service_unavailable = 503, @("Gateway Timeout") gateway_timeout = 504, @("HTTP Version Not Supported") http_version_not_supported = 505, - @("Variant Also Negotiates") variant_also_negoti - + @("Variant Also Negotiates") variant_also_negotiates = 506 } \ No newline at end of file