Index: .fossil-settings/clean-glob ================================================================== --- .fossil-settings/clean-glob +++ .fossil-settings/clean-glob @@ -52,5 +52,6 @@ utils/objfw-new/objfw-new utils/ofarc/ofarc utils/ofdns/ofdns utils/ofhash/ofhash utils/ofhttp/ofhttp +utils/ofhttpd/ofhttpd Index: .fossil-settings/ignore-glob ================================================================== --- .fossil-settings/ignore-glob +++ .fossil-settings/ignore-glob @@ -57,5 +57,6 @@ utils/objfw-new/objfw-new utils/ofarc/ofarc utils/ofdns/ofdns utils/ofhash/ofhash utils/ofhttp/ofhttp +utils/ofhttpd/ofhttpd Index: .gitignore ================================================================== --- .gitignore +++ .gitignore @@ -57,5 +57,6 @@ utils/objfw-new/objfw-new utils/ofarc/ofarc utils/ofdns/ofdns utils/ofhash/ofhash utils/ofhttp/ofhttp +utils/ofhttpd/ofhttpd Index: configure.ac ================================================================== --- configure.ac +++ configure.ac @@ -1873,10 +1873,11 @@ AC_SUBST(OFDNS, "ofdns") AS_IF([test x"$enable_files" != x"no"], [ AC_SUBST(OFHTTP, "ofhttp") AC_SUBST(OFHTTP_LIBS) + AC_SUBST(OFHTTPD, "ofhttpd") ]) ]) AC_DEFUN([CHECK_BUILTIN_BSWAP], [ AC_MSG_CHECKING(for __builtin_bswap$1) Index: extra.mk.in ================================================================== --- extra.mk.in +++ extra.mk.in @@ -51,10 +51,11 @@ OFARC = @OFARC@ OFDNS = @OFDNS@ OFHASH = @OFHASH@ OFHTTP = @OFHTTP@ OFHTTP_LIBS = @OFHTTP_LIBS@ +OFHTTPD = @OFHTTPD@ OF_BLOCK_TESTS_M = @OF_BLOCK_TESTS_M@ OF_EPOLL_KERNEL_EVENT_OBSERVER_M = @OF_EPOLL_KERNEL_EVENT_OBSERVER_M@ OF_GNUTLS_TLS_STREAM_M = @OF_GNUTLS_TLS_STREAM_M@ OF_HTTP_CLIENT_TESTS_M = @OF_HTTP_CLIENT_TESTS_M@ OF_KQUEUE_KERNEL_EVENT_OBSERVER_M = @OF_KQUEUE_KERNEL_EVENT_OBSERVER_M@ Index: utils/Makefile ================================================================== --- utils/Makefile +++ utils/Makefile @@ -2,11 +2,12 @@ SUBDIRS += ${OBJFW_NEW} \ ${OFARC} \ ${OFDNS} \ ${OFHASH} \ - ${OFHTTP} + ${OFHTTP} \ + ${OFHTTPD} include ../buildsys.mk DISTCLEAN = objfw-config ADDED utils/ofhttpd/Makefile Index: utils/ofhttpd/Makefile ================================================================== --- utils/ofhttpd/Makefile +++ utils/ofhttpd/Makefile @@ -0,0 +1,18 @@ +include ../../extra.mk + +PROG = ofhttpd${PROG_SUFFIX} +SRCS = OFHTTPD.m + +include ../../buildsys.mk + +PACKAGE_NAME = ofhttpd + +${PROG}: ${LIBOBJFW_DEP_LVL2} ${LIBOBJFWRT_DEP_LVL2} + +CPPFLAGS += -I../../src \ + -I../../src/runtime \ + -I../../src/exceptions \ + -I../.. +LIBS := -L../../src -lobjfw -L../../src/runtime ${RUNTIME_LIBS} ${LIBS} +LD = ${OBJC} +LDFLAGS += ${LDFLAGS_RPATH} ADDED utils/ofhttpd/OFHTTPD.m Index: utils/ofhttpd/OFHTTPD.m ================================================================== --- utils/ofhttpd/OFHTTPD.m +++ utils/ofhttpd/OFHTTPD.m @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2008-2023 Jonathan Schleifer + * + * All rights reserved. + * + * This file is part of ObjFW. It may be distributed under the terms of the + * Q Public License 1.0, which can be found in the file LICENSE.QPL included in + * the packaging of this file. + * + * Alternatively, it may be distributed under the terms of the GNU General + * Public License, either version 2 or 3, which can be found in the file + * LICENSE.GPLv2 or LICENSE.GPLv3 respectively included in the packaging of this + * file. + */ + +#include "config.h" + +#include + +#import "OFApplication.h" +#import "OFFile.h" +#import "OFFileManager.h" +#import "OFHTTPRequest.h" +#import "OFHTTPResponse.h" +#import "OFHTTPServer.h" +#import "OFIRI.h" +#import "OFLocale.h" +#import "OFNumber.h" +#import "OFOptionsParser.h" +#import "OFStdIOStream.h" + +#import "OFInvalidFormatException.h" +#import "OFOpenItemFailedException.h" + +@interface OFHTTPD: OFObject +{ + OFHTTPServer *_server; +} +@end + +OF_APPLICATION_DELEGATE(OFHTTPD) + +static OFString * +safeLocalPathForIRI(OFIRI *IRI) +{ + OFString *path = IRI.IRIByStandardizingPath.path; + + if (![path hasPrefix: @"/"]) + return nil; + + path = [path substringWithRange: OFMakeRange(1, path.length - 1)]; + +#if defined(OF_WINDOWS) || defined(OF_MSDOS) + if ([path containsString: @":"] || [path hasPrefix: @"\\"]) +#elif defined(OF_AMIGAOS) + if ([path containsString: @":"] || [path hasPrefix: @"/"]) +#else + /* Shouldn't even be possible after standardization, but just in case */ + if ([path hasPrefix: @"/"]) +#endif + return nil; + + /* + * After -[IRIByStandardizingPath], everything representing parent + * directory should be at the beginning, so in theory checking the + * first component should be enough. But it does not hurt being + * paranoid and checking all components, just in case. + */ + for (OFString *component in [path componentsSeparatedByString: @"/"]) + if ([component isEqual: @".."]) + return nil; + + return path; +} + +@implementation OFHTTPD +- (void)applicationDidFinishLaunching: (OFNotification *)notification +{ + OFString *directory, *host; + unsigned long long port = 0; + const OFOptionsParserOption options[] = { + { 'd', @"directory", 1, NULL, &directory }, + { 'H', @"host", 1, NULL, &host }, + { 'p', @"port", 1, NULL, NULL }, + { '\0', nil, 0, NULL, NULL } + }; + OFFileManager *fileManager = [OFFileManager defaultManager]; + OFOptionsParser *optionsParser; + OFUnichar option; + OFMutableIRI *serverIRI; + + optionsParser = [OFOptionsParser parserWithOptions: options]; + while ((option = [optionsParser nextOption]) != '\0') { + switch (option) { + case 'd': + [fileManager changeCurrentDirectoryPath: + optionsParser.argument]; + OFLog(@"Serving directory %@", + fileManager.currentDirectoryPath); + break; + case 'p': + @try { + port = optionsParser.argument.longLongValue; + + if (port > UINT16_MAX) + @throw [OFInvalidFormatException + exception]; + } @catch (OFInvalidFormatException *e) { + [OFStdErr writeLine: OF_LOCALIZED( + @"invalid_port", + @"%[prog]: Port must be between 0 and " + @"65536!", + @"prog", [OFApplication programName])]; + [OFApplication terminateWithStatus: 1]; + } + break; + case ':': + if (optionsParser.lastLongOption != nil) + [OFStdErr writeLine: OF_LOCALIZED( + @"long_argument_missing", + @"%[prog]: Argument for option --%[opt] " + @"missing", + @"prog", [OFApplication programName], + @"opt", optionsParser.lastLongOption)]; + else { + OFString *optStr = [OFString + stringWithFormat: @"%C", + optionsParser.lastOption]; + [OFStdErr writeLine: OF_LOCALIZED( + @"argument_missing", + @"%[prog]: Argument for option -%[opt] " + @"missing", + @"prog", [OFApplication programName], + @"opt", optStr)]; + } + + [OFApplication terminateWithStatus: 1]; + break; + /* case '=': */ + case '?': + if (optionsParser.lastLongOption != nil) + [OFStdErr writeLine: OF_LOCALIZED( + @"unknown_long_option", + @"%[prog]: Unknown option: --%[opt]", + @"prog", [OFApplication programName], + @"opt", optionsParser.lastLongOption)]; + else { + OFString *optStr = [OFString + stringWithFormat: @"%C", + optionsParser.lastOption]; + [OFStdErr writeLine: OF_LOCALIZED( + @"unknown_option", + @"%[prog]: Unknown option: -%[opt]", + @"prog", [OFApplication programName], + @"opt", optStr)]; + } + + [OFApplication terminateWithStatus: 1]; + break; + } + } + + if (host == nil) + host = @"127.0.0.1"; + + _server = [[OFHTTPServer alloc] init]; + _server.host = host; + _server.port = (uint16_t)port; + _server.delegate = self; + [_server start]; + + serverIRI = [OFMutableIRI IRIWithScheme: @"http"]; + serverIRI.host = _server.host; + serverIRI.port = [OFNumber numberWithUnsignedShort: _server.port]; + OFLog(@"Started server on %@", serverIRI.string); +} + +- (void)server: (OFHTTPServer *)server + didReceiveRequest: (OFHTTPRequest *)request + requestBody: (OFStream *)requestBody + response: (OFHTTPResponse *)response +{ + OFString *path; + + OFLog(@"Handling request %@", request); + + path = safeLocalPathForIRI(request.IRI); + if (path == nil) { + response.statusCode = 403; + return; + } + + if ([[OFFileManager defaultManager] directoryExistsAtPath: path]) + path = [path stringByAppendingPathComponent: @"index.html"]; + + OFLog(@"Sending file %@", path); + + @try { + OFFile *file = [OFFile fileWithPath: path mode: @"r"]; + + response.statusCode = 200; + + /* TODO: Async stream copy */ + + while (!file.atEndOfStream) { + char buffer[4096]; + size_t length; + + length = [file readIntoBuffer: buffer length: 4096]; + [response writeBuffer: buffer length: length]; + } + } @catch (OFOpenItemFailedException *e) { + switch (e.errNo) { + case EACCES: + response.statusCode = 403; + return; + case ENOENT: + case ENOTDIR: + response.statusCode = 404; + return; + } + } +} +@end