Updated static
This commit is contained in:
parent
89d710fe28
commit
cadbc1c4f7
6 changed files with 160 additions and 83 deletions
|
@ -1,80 +1,110 @@
|
|||
# 4.0.0
|
||||
# Change Log
|
||||
|
||||
## 4.0.1
|
||||
|
||||
* Fixed `push_state_test` unit test failure on Windows
|
||||
* Fixed NNBD related issues
|
||||
* Added logging to `VirtualDirectory` and `CachingVirtualDirectory` to log exception
|
||||
|
||||
## 4.0.0
|
||||
|
||||
* Migrated to support Dart SDK 2.12.x NNBD
|
||||
|
||||
# 3.0.0
|
||||
## 3.0.0
|
||||
|
||||
* Migrated to work with Dart SDK 2.12.x Non NNBD
|
||||
|
||||
# 2.1.3+2
|
||||
## 2.1.3+2
|
||||
|
||||
* Prepare for upcoming change to File.openRead()
|
||||
|
||||
# 2.1.3+1
|
||||
## 2.1.3+1
|
||||
|
||||
* Apply control flow lints.
|
||||
|
||||
# 2.1.3
|
||||
## 2.1.3
|
||||
|
||||
* Apply lints.
|
||||
* Pin to Dart `>=2.0.0 <3.0.0`.
|
||||
* Use at least version `2.0.0-rc.0` of `angel_framework`.
|
||||
|
||||
# 2.1.2+1
|
||||
## 2.1.2+1
|
||||
|
||||
* Fix a typo that prevented `Range` requests from working.
|
||||
|
||||
# 2.1.2
|
||||
## 2.1.2
|
||||
|
||||
* Patch support for range+streaming in Caching server.
|
||||
|
||||
# 2.1.1
|
||||
## 2.1.1
|
||||
|
||||
* URI-encode paths in directory listing. This produces correct URL's, always.
|
||||
|
||||
# 2.1.0
|
||||
## 2.1.0
|
||||
|
||||
* Include support for the `Range` header.
|
||||
* Use MD5 for etags, instead of a weak ETag.
|
||||
|
||||
# 2.0.2
|
||||
## 2.0.2
|
||||
|
||||
* Fixed invalid HTML for directory listings.
|
||||
|
||||
# 2.0.1
|
||||
## 2.0.1
|
||||
|
||||
* Remove use of `sendFile`.
|
||||
* Add a `p.isWithin` check to ensure that paths do not escape the `source` directory.
|
||||
* Handle `HEAD` requests.
|
||||
|
||||
# 2.0.0
|
||||
## 2.0.0
|
||||
|
||||
* Upgrade dependencies to Angel 2 + file@5.
|
||||
* Replace `useStream` with `useBuffer`.
|
||||
* Remove `package:intl`, just use `HttpDate` instead.
|
||||
|
||||
# 1.3.0+1
|
||||
## 1.3.0+1
|
||||
|
||||
* Dart 2 fixes.
|
||||
* Enable optionally writing responses to the buffer instead of streaming.
|
||||
|
||||
# 1.3.0
|
||||
## 1.3.0
|
||||
|
||||
* `pushState` uses `strict` mode when `accepts` is passed.
|
||||
|
||||
# 1.3.0-alpha+2
|
||||
## 1.3.0-alpha+2
|
||||
|
||||
* Added an `accepts` option to `pushState`.
|
||||
* Added optional directory listings.
|
||||
|
||||
# 1.3.0-alpha+1
|
||||
## 1.3.0-alpha+1
|
||||
|
||||
* ETags once again only encode the first 50 bytes of files. Resolves [#27](https://github.com/angel-dart/static/issues/27).
|
||||
|
||||
# 1.3.0-alpha
|
||||
## 1.3.0-alpha
|
||||
|
||||
* Removed file transformers.
|
||||
* `VirtualDirectory` is no longer an `AngelPlugin`, and instead exposes a `handleRequest` middleware.
|
||||
* Added `pushState` to `VirtualDirectory`.
|
||||
|
||||
# 1.2.5
|
||||
## 1.2.5
|
||||
|
||||
* Fixed a bug where `onlyInProduction` was not properly adhered to.
|
||||
* Fixed another bug where `Accept-Encoding` was not properly adhered to.
|
||||
* Setting `maxAge` to `null` will now prevent a `CachingVirtualDirectory` from sending an `Expires` header.
|
||||
* Pre-built assets can now be mass-deleted with `VirtualDirectory.cleanFromDisk()`.
|
||||
Resolves [#22](https://github.com/angel-dart/static/issues/22).
|
||||
|
||||
# 1.2.4+1
|
||||
## 1.2.4+1
|
||||
|
||||
Fixed a bug where `Accept-Encoding` was not properly adhered to.
|
||||
|
||||
# 1.2.4
|
||||
Fixes https://github.com/angel-dart/angel/issues/44.
|
||||
## 1.2.4
|
||||
|
||||
Fixes <https://github.com/angel-dart/angel/issues/44>.
|
||||
|
||||
* MIME types will now default to `application/octet-stream`.
|
||||
* When `streamToIO` is `true`, the body will only be sent gzipped if the request explicitly allows it.
|
||||
|
||||
# 1.2.3
|
||||
## 1.2.3
|
||||
|
||||
Fixed #40 and #41, which dealt with paths being improperly served when using a
|
||||
`publicPath`.
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
# angel3_static
|
||||
# Angel3 Static Files Service
|
||||
|
||||
[![version](https://img.shields.io/badge/pub-v4.0.1-brightgreen)](https://pub.dartlang.org/packages/angel3_static)
|
||||
[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety)
|
||||
[![Gitter](https://img.shields.io/gitter/room/angel_dart/discussion)](https://gitter.im/angel_dart/discussion)
|
||||
|
||||
[![License](https://img.shields.io/github/license/dukefirehawk/angel)](https://github.com/dukefirehawk/angel/tree/angel3/packages/static/LICENSE)
|
||||
|
||||
|
||||
Static server infrastructure for Angel.
|
||||
This package supports serving static files such as html, css and js for [Angel3 framework](https://pub.dartlang.org/packages/angel3).
|
||||
|
||||
*Can also handle `Range` requests now, making it suitable for media streaming, ex. music, video, etc.*
|
||||
|
||||
# Installation
|
||||
## Installation
|
||||
|
||||
In `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
|
@ -18,9 +19,9 @@ dependencies:
|
|||
angel3_static: ^4.0.0
|
||||
```
|
||||
|
||||
# Usage
|
||||
To serve files from a directory, you need to create a `VirtualDirectory`.
|
||||
Keep in mind that `angel3_static` uses `package:file` instead of `dart:io`.
|
||||
## Usage
|
||||
|
||||
To serve files from a directory, you need to create a `VirtualDirectory`. Keep in mind that `angel3_static` uses `package:file` instead of `dart:io`.
|
||||
|
||||
```dart
|
||||
import 'package:angel3_framework/angel3_framework.dart';
|
||||
|
@ -46,10 +47,9 @@ void main() async {
|
|||
}
|
||||
```
|
||||
|
||||
# Push State
|
||||
`VirtualDirectory` also exposes a `pushState` method that returns a
|
||||
request handler that serves the file at a given path as a fallback, unless
|
||||
the user is requesting that file. This can be very useful for SPA's.
|
||||
## Push State
|
||||
|
||||
`VirtualDirectory` also exposes a `pushState` method that returns a request handler that serves the file at a given path as a fallback, unless the user is requesting that file. This can be very useful for SPA's.
|
||||
|
||||
```dart
|
||||
// Create VirtualDirectory as well
|
||||
|
@ -62,8 +62,10 @@ app.fallback(vDir.handleRequest);
|
|||
app.fallback(vDir.pushState('index.html'));
|
||||
```
|
||||
|
||||
# Options
|
||||
## Options
|
||||
|
||||
The `VirtualDirectory` API accepts a few named parameters:
|
||||
|
||||
- **source**: A `Directory` containing the files to be served. If left null, then Angel will serve either from `web` (in development) or
|
||||
`build/web` (in production), depending on your `ANGEL_ENV`.
|
||||
- **indexFileNames**: A `List<String>` of filenames that should be served as index pages. Default is `['index.html']`.
|
||||
|
@ -71,4 +73,4 @@ The `VirtualDirectory` API accepts a few named parameters:
|
|||
angel_static is serving your files. If you are not serving static files at the site root,
|
||||
please include this.
|
||||
- **callback**: Runs before sending a file to a client. Use this to set headers, etc. If it returns anything other than `null` or `true`,
|
||||
then the callback's result will be sent to the user, instead of the file contents.
|
||||
then the callback's result will be sent to the user, instead of the file contents.
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:io' show HttpDate;
|
||||
import 'package:angel3_framework/angel3_framework.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'virtual_directory.dart';
|
||||
|
||||
/// Returns a string representation of the given [CacheAccessLevel].
|
||||
|
@ -18,6 +19,8 @@ String accessLevelToString(CacheAccessLevel accessLevel) {
|
|||
|
||||
/// A `VirtualDirectory` that also sets `Cache-Control` headers.
|
||||
class CachingVirtualDirectory extends VirtualDirectory {
|
||||
final _log = Logger('CachingVirtualDirectory');
|
||||
|
||||
final Map<String, String> _etags = {};
|
||||
|
||||
/// Either `PUBLIC` or `PRIVATE`.
|
||||
|
@ -40,20 +43,20 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
CachingVirtualDirectory(Angel app, FileSystem fileSystem,
|
||||
{this.accessLevel = CacheAccessLevel.PUBLIC,
|
||||
Directory? source,
|
||||
bool? debug,
|
||||
Iterable<String>? indexFileNames,
|
||||
bool debug = false,
|
||||
Iterable<String> indexFileNames = const ['index.html'],
|
||||
this.maxAge = 0,
|
||||
this.noCache = false,
|
||||
this.onlyInProduction = false,
|
||||
this.useEtags = true,
|
||||
bool? allowDirectoryListing,
|
||||
bool allowDirectoryListing = false,
|
||||
bool useBuffer = false,
|
||||
String? publicPath,
|
||||
String publicPath = '/',
|
||||
Function(File file, RequestContext req, ResponseContext res)? callback})
|
||||
: super(app, fileSystem,
|
||||
source: source,
|
||||
indexFileNames: indexFileNames ?? ['index.html'],
|
||||
publicPath: publicPath ?? '/',
|
||||
indexFileNames: indexFileNames,
|
||||
publicPath: publicPath,
|
||||
callback: callback,
|
||||
allowDirectoryListing: allowDirectoryListing,
|
||||
useBuffer: useBuffer);
|
||||
|
@ -63,27 +66,35 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
File file, FileStat stat, RequestContext req, ResponseContext res) {
|
||||
res.headers['accept-ranges'] = 'bytes';
|
||||
|
||||
if (onlyInProduction == true && req.app!.environment.isProduction != true) {
|
||||
if (onlyInProduction == true && req.app?.environment.isProduction != true) {
|
||||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
|
||||
if (req.headers == null) {
|
||||
_log.severe('Missing headers in the RequestContext');
|
||||
throw ArgumentError('Missing headers in the RequestContext');
|
||||
}
|
||||
var reqHeaders = req.headers!;
|
||||
|
||||
var shouldNotCache = noCache == true;
|
||||
|
||||
if (!shouldNotCache) {
|
||||
shouldNotCache = req.headers!.value('cache-control') == 'no-cache' ||
|
||||
req.headers!.value('pragma') == 'no-cache';
|
||||
shouldNotCache = reqHeaders.value('cache-control') == 'no-cache' ||
|
||||
reqHeaders.value('pragma') == 'no-cache';
|
||||
}
|
||||
|
||||
if (shouldNotCache) {
|
||||
res.headers['cache-control'] = 'private, max-age=0, no-cache';
|
||||
return super.serveFile(file, stat, req, res);
|
||||
} else {
|
||||
var ifModified = req.headers!.ifModifiedSince;
|
||||
var ifModified = reqHeaders.ifModifiedSince;
|
||||
var ifRange = false;
|
||||
|
||||
try {
|
||||
ifModified = HttpDate.parse(req.headers!.value('if-range')!);
|
||||
ifRange = true;
|
||||
if (reqHeaders.value('if-range') != null) {
|
||||
ifModified = HttpDate.parse(reqHeaders.value('if-range')!);
|
||||
ifRange = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fail silently...
|
||||
}
|
||||
|
@ -97,7 +108,9 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
setCachedHeaders(stat.modified, req, res);
|
||||
|
||||
if (useEtags && _etags.containsKey(file.absolute.path)) {
|
||||
res.headers['ETag'] = _etags[file.absolute.path]!;
|
||||
if (_etags[file.absolute.path] != null) {
|
||||
res.headers['ETag'] = _etags[file.absolute.path]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (ifRange) {
|
||||
|
@ -111,6 +124,8 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
} catch (_) {
|
||||
_log.severe(
|
||||
'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.');
|
||||
throw AngelHttpException.badRequest(
|
||||
message:
|
||||
'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.');
|
||||
|
@ -120,18 +135,18 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
// If-modified didn't work; try etags
|
||||
|
||||
if (useEtags == true) {
|
||||
var etagsToMatchAgainst = req.headers!['if-none-match'];
|
||||
var etagsToMatchAgainst = reqHeaders['if-none-match'] ?? [];
|
||||
ifRange = false;
|
||||
|
||||
if (etagsToMatchAgainst?.isNotEmpty != true) {
|
||||
etagsToMatchAgainst = req.headers!['if-range'];
|
||||
ifRange = etagsToMatchAgainst?.isNotEmpty == true;
|
||||
if (etagsToMatchAgainst.isEmpty) {
|
||||
etagsToMatchAgainst = reqHeaders['if-range'] ?? [];
|
||||
ifRange = etagsToMatchAgainst.isNotEmpty;
|
||||
}
|
||||
|
||||
if (etagsToMatchAgainst?.isNotEmpty == true) {
|
||||
if (etagsToMatchAgainst.isNotEmpty) {
|
||||
var hasBeenModified = false;
|
||||
|
||||
for (var etag in etagsToMatchAgainst!) {
|
||||
for (var etag in etagsToMatchAgainst) {
|
||||
if (etag == '*') {
|
||||
hasBeenModified = true;
|
||||
} else {
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:angel3_framework/angel3_framework.dart';
|
|||
import 'package:file/file.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:angel3_range_header/angel3_range_header.dart';
|
||||
|
||||
final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
|
||||
|
@ -28,11 +29,13 @@ String _pathify(String path) {
|
|||
|
||||
/// A static server plug-in.
|
||||
class VirtualDirectory {
|
||||
String? _prefix;
|
||||
Directory? _source;
|
||||
final _log = Logger('VirtualDirectory');
|
||||
|
||||
late String _prefix;
|
||||
late Directory _source;
|
||||
|
||||
/// The directory to serve files from.
|
||||
Directory? get source => _source;
|
||||
Directory get source => _source;
|
||||
|
||||
/// An optional callback to run before serving files.
|
||||
final Function(File file, RequestContext req, ResponseContext res)? callback;
|
||||
|
@ -47,7 +50,7 @@ class VirtualDirectory {
|
|||
final String publicPath;
|
||||
|
||||
/// If `true` (default: `false`), then if a directory does not contain any of the specific [indexFileNames], a default directory listing will be served.
|
||||
final bool? allowDirectoryListing;
|
||||
final bool allowDirectoryListing;
|
||||
|
||||
/// If `true` (default: `true`), then files will be opened as streams and piped into the request.
|
||||
///
|
||||
|
@ -77,7 +80,7 @@ class VirtualDirectory {
|
|||
}
|
||||
var path = req.uri!.path.replaceAll(_straySlashes, '');
|
||||
|
||||
if (_prefix?.isNotEmpty == true && !path.startsWith(_prefix!)) {
|
||||
if (_prefix.isNotEmpty == true && !path.startsWith(_prefix)) {
|
||||
return Future<bool>.value(true);
|
||||
}
|
||||
|
||||
|
@ -91,7 +94,7 @@ class VirtualDirectory {
|
|||
/// the view will be served.
|
||||
RequestHandler pushState(String path, {Iterable? accepts}) {
|
||||
var vPath = path.replaceAll(_straySlashes, '');
|
||||
if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath';
|
||||
if (_prefix.isNotEmpty == true) vPath = '$_prefix/$vPath';
|
||||
|
||||
return (RequestContext req, ResponseContext res) {
|
||||
var path = req.path.replaceAll(_straySlashes, '');
|
||||
|
@ -110,22 +113,28 @@ class VirtualDirectory {
|
|||
/// Writes the file at the given virtual [path] to a response.
|
||||
Future<bool> servePath(
|
||||
String path, RequestContext req, ResponseContext res) async {
|
||||
if (_prefix!.isNotEmpty) {
|
||||
if (_prefix.isNotEmpty) {
|
||||
// Only replace the *first* incidence
|
||||
// Resolve: https://github.com/angel-dart/angel/issues/41
|
||||
path = path.replaceFirst(RegExp('^' + _pathify(_prefix!)), '');
|
||||
path = path.replaceFirst(RegExp('^' + _pathify(_prefix)), '');
|
||||
}
|
||||
|
||||
if (path.isEmpty) path = '.';
|
||||
path = path.replaceAll(_straySlashes, '');
|
||||
|
||||
var absolute = source!.absolute.uri.resolve(path).toFilePath();
|
||||
var parent = source!.absolute.uri.toFilePath();
|
||||
var absolute = source.absolute.uri.resolve(path).toFilePath();
|
||||
var parent = source.absolute.uri.toFilePath();
|
||||
|
||||
if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Replace the separator when running on Windows with file system
|
||||
// detected as Linux
|
||||
if (absolute.contains('\\') && fileSystem.path.separator == '/') {
|
||||
absolute = absolute.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
var stat = await fileSystem.stat(absolute);
|
||||
return await serveStat(absolute, path, stat, req, res);
|
||||
}
|
||||
|
@ -199,8 +208,8 @@ class VirtualDirectory {
|
|||
});
|
||||
|
||||
for (var entity in entities) {
|
||||
String? stub;
|
||||
String? type;
|
||||
String stub;
|
||||
String type;
|
||||
|
||||
if (entity is File) {
|
||||
type = '[File]';
|
||||
|
@ -213,23 +222,24 @@ class VirtualDirectory {
|
|||
stub = p.basename(entity.path);
|
||||
} else {
|
||||
//TODO: Handle unknown type
|
||||
|
||||
_log.severe('Unknown file entity. Not a file, directory or link.');
|
||||
type = '[]';
|
||||
stub = '';
|
||||
}
|
||||
var href = stub;
|
||||
|
||||
if (relative.isNotEmpty) {
|
||||
stub ??= '';
|
||||
href = '/' + relative + '/' + stub;
|
||||
}
|
||||
|
||||
if (entity is Directory) {
|
||||
if (href == null) {
|
||||
if (href == '') {
|
||||
href = '/';
|
||||
} else {
|
||||
href += '/';
|
||||
}
|
||||
}
|
||||
href = Uri.encodeFull(href!);
|
||||
href = Uri.encodeFull(href);
|
||||
|
||||
res.write('<li><a href="$href">$type $stub</a></li>');
|
||||
}
|
||||
|
@ -242,12 +252,13 @@ class VirtualDirectory {
|
|||
}
|
||||
|
||||
void _ensureContentTypeAllowed(String mimeType, RequestContext req) {
|
||||
var value = req.headers!.value('accept');
|
||||
var value = req.headers?.value('accept');
|
||||
var acceptable = value == null ||
|
||||
value.isNotEmpty != true ||
|
||||
(mimeType.isNotEmpty == true && value.contains(mimeType) == true) ||
|
||||
value.contains('*/*') == true;
|
||||
if (!acceptable) {
|
||||
_log.severe('Mime type [$value] is not supported');
|
||||
throw AngelHttpException(
|
||||
UnsupportedError(
|
||||
'Client requested $value, but server wanted to send $mimeType.'),
|
||||
|
@ -262,11 +273,12 @@ class VirtualDirectory {
|
|||
res.headers['accept-ranges'] = 'bytes';
|
||||
|
||||
if (callback != null) {
|
||||
return await req.app!.executeHandler(
|
||||
(RequestContext req, ResponseContext res) =>
|
||||
callback!(file, req, res),
|
||||
req,
|
||||
res);
|
||||
return await req.app?.executeHandler(
|
||||
(RequestContext req, ResponseContext res) =>
|
||||
callback!(file, req, res),
|
||||
req,
|
||||
res) ??
|
||||
true;
|
||||
}
|
||||
|
||||
var type =
|
||||
|
@ -277,14 +289,21 @@ class VirtualDirectory {
|
|||
res.contentType = MediaType.parse(type);
|
||||
if (useBuffer == true) res.useBuffer();
|
||||
|
||||
if (req.headers!.value('range')?.startsWith('bytes=') != true) {
|
||||
if (req.headers == null) {
|
||||
_log.severe('Missing headers in the RequestContext');
|
||||
throw ArgumentError('Missing headers in the RequestContext');
|
||||
}
|
||||
var reqHeaders = req.headers!;
|
||||
|
||||
if (reqHeaders.value('range')?.startsWith('bytes=') != true) {
|
||||
await res.streamFile(file);
|
||||
} else {
|
||||
var header = RangeHeader.parse(req.headers!.value('range')!);
|
||||
var header = RangeHeader.parse(reqHeaders.value('range')!);
|
||||
var items = RangeHeader.foldItems(header.items);
|
||||
var totalFileSize = await file.length();
|
||||
header = RangeHeader(items);
|
||||
|
||||
var totalFileSize = await file.length();
|
||||
|
||||
for (var item in header.items) {
|
||||
var invalid = false;
|
||||
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
name: angel3_static
|
||||
description: Static server middleware for Angel. Also capable of serving Range responses.
|
||||
version: 4.0.0
|
||||
homepage: https://github.com/dukefirehawk/angel/tree/angel3/packages/static
|
||||
version: 4.0.1
|
||||
homepage: https://github.com/dukefirehawk/angel
|
||||
repository: https://github.com/dukefirehawk/angel/tree/angel3/packages/static
|
||||
environment:
|
||||
sdk: '>=2.12.0 <3.0.0'
|
||||
dependencies:
|
||||
angel3_framework: ^4.0.0
|
||||
angel3_framework: ^4.1.0
|
||||
angel3_range_header: ^3.0.0
|
||||
convert: ^3.0.0
|
||||
crypto: ^3.0.1
|
||||
file: ^6.1.0
|
||||
http_parser: ^4.0.0
|
||||
path: ^1.8.0
|
||||
logging: ^1.0.1
|
||||
dev_dependencies:
|
||||
angel3_test: ^4.0.0
|
||||
http: ^0.13.2
|
||||
logging: ^1.0.1
|
||||
matcher: ^0.12.10
|
||||
pedantic: ^1.11.0
|
||||
test: ^1.17.4
|
||||
|
|
10
packages/static/test/web/index.html
Normal file
10
packages/static/test/web/index.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Push Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello!</h1>
|
||||
<i>Hooray for testing...</i>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue