Initial commit

This commit is contained in:
2023-08-18 21:57:16 +03:00
parent f71a710ede
commit 1734bcdea3
23 changed files with 1634 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.github.russp.bpe;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BpeApplication {
public static void main(String[] args) {
SpringApplication.run(BpeApplication.class, args);
}
}

View File

@@ -0,0 +1,52 @@
package com.github.russp.bpe.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
@EnableWebFluxSecurity
public class SecurityConfig {
@Value("${app.username}")
private String username;
@Value("${app.password}")
private String password;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http.authorizeExchange()
.pathMatchers("/login", "/*.css", "/*.js").permitAll()
.anyExchange().authenticated()
.and().formLogin().loginPage("/login")
.and().logout()
.requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))
.and()
.build();
}
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User
.withUsername(username)
.password(passwordEncoder().encode(password))
.roles("USER")
.build();
password = "";
return new MapReactiveUserDetailsService(user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,92 @@
package com.github.russp.bpe.http;
import com.github.russp.bpe.service.FilesService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.file.Paths;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
@Slf4j
@Controller
@RequiredArgsConstructor
public class BrowserController {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
private final FilesService filesService;
@GetMapping("/")
public String list(@RequestParam(defaultValue = "/") String path, Model model) {
log.info("Path: {}", path);
var files = filesService.ls(path)
.map(file -> FileDto.builder()
.dir(file.isDir())
.name(file.getName())
.fullName(file.getFullName())
.size(String.valueOf(file.getSize()))
.modifiedAt(file.getModifiedAt().format(DATE_TIME_FORMATTER))
.build())
.collectList();
var breadcrumbs = filesService.pwd(path).map(file -> FileDto.builder()
.name(file.getName())
.fullName(file.getFullName())
.build())
.collect(
ArrayList::new,
(list, value) -> list.add(0, value)
);
model.addAttribute("files", files);
model.addAttribute("breadcrumbs", breadcrumbs);
return "browse";
}
@GetMapping("edit")
public String edit(@RequestParam String path, Model model) {
var breadcrumbs = filesService.parent(path).map(file -> FileDto.builder()
.name(file.getName())
.fullName(file.getFullName())
.build())
.collect(
ArrayList::new,
(list, value) -> list.add(0, value)
);
model.addAttribute("breadcrumbs", breadcrumbs);
model.addAttribute("selectedFile", path);
model.addAttribute("selectedFileName", Paths.get(path).getFileName());
return "edit";
}
@GetMapping(value = "/content",
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public @ResponseBody Flux<DataBuffer> content(String path) {
return filesService.content(path);
}
@PostMapping(value = "/content")
public @ResponseBody Mono<Void> save(@RequestParam String path,
@RequestPart("file") Mono<FilePart> filePartMono) {
return filePartMono
.flatMap(fp -> filesService.save(path, fp::transferTo))
.then(Mono.empty());
}
}

View File

@@ -0,0 +1,21 @@
package com.github.russp.bpe.http;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileDto {
private String name;
private String size;
private String modifiedAt;
private boolean dir;
private String fullName;
}

View File

@@ -0,0 +1,16 @@
package com.github.russp.bpe.http;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/login")
public class LoginController {
@GetMapping
public String login() {
return "login";
}
}

View File

@@ -0,0 +1,19 @@
package com.github.russp.bpe.http;
import org.springframework.security.web.reactive.result.view.CsrfRequestDataValueProcessor;
import org.springframework.security.web.server.csrf.CsrfToken;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@ControllerAdvice
public class SecurityControllerAdvice {
@ModelAttribute(CsrfRequestDataValueProcessor.DEFAULT_CSRF_ATTR_NAME)
Mono<CsrfToken> csrfToken(ServerWebExchange exchange) {
return exchange.getAttribute(CsrfToken.class.getName());
}
}

View File

@@ -0,0 +1,27 @@
package com.github.russp.bpe.model;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
@Builder
public class FileEntry implements Comparable<FileEntry> {
private final boolean dir;
private final String name;
private final String fullName;
private final long size;
private final LocalDateTime modifiedAt;
@Override
public int compareTo(FileEntry o) {
if (dir && !o.isDir()) {
return -1;
}
if (!dir && o.isDir()) {
return 1;
}
return name.compareTo(o.getName());
}
}

View File

@@ -0,0 +1,126 @@
package com.github.russp.bpe.service;
import com.github.russp.bpe.model.FileEntry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.function.Function;
@Slf4j
@Service
@RequiredArgsConstructor
public class FilesService {
@Value("${app.fs.root}")
private String root;
public Flux<FileEntry> ls(String path) {
try {
var rootPath = Paths.get(root);
return Flux.fromStream(Files.list(Path.of(root, path))
.map(p -> FileEntry.builder()
.name(p.getFileName().toString())
.fullName(p.toString().replace(root, ""))
.dir(Files.isDirectory(p))
.modifiedAt(getLastModifiedTime(p))
.size(size(p))
.build()
)
.sorted()
);
} catch (IOException e) {
log.debug("Listing error for path {}: {}", path, e.getMessage());
return Flux.error(e);
}
}
public Flux<FileEntry> parent(String path) {
var parent = Path.of(path).getParent();
if (parent == null) {
return Flux.empty();
}
return pwd(Path.of(path).getParent().toString());
}
public Flux<FileEntry> pwd(String path) {
if (!StringUtils.hasText(path)) {
return Flux.empty();
}
var rootPath = Path.of(root);
var end = Path.of(root, path);
return Flux.<Path, Path>generate(() -> end, (state, sink) -> {
if (rootPath.equals(state)) {
sink.complete();
}
sink.next(state);
return state.getParent();
})
.map(p -> FileEntry.builder()
.name(p.getFileName().toString())
.fullName(p.toString().replace(root, ""))
.dir(Files.isDirectory(p))
.build()
);
}
public Flux<DataBuffer> content(String path) {
var file = Path.of(root, path);
return DataBufferUtils.read(file, DefaultDataBufferFactory.sharedInstance, 1024, StandardOpenOption.READ);
}
public Mono<Void> save(String path, Function<Path, Mono<Void>> transferTo) {
return Mono.just(Path.of(root, path))
.doOnNext(fn -> {
var file = fn.toFile();
try {
FileUtils.moveFile(file, new File(fn.toString() + ".bak"), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
log.error("Can not create backup file for {}", path, e);
// throw new RuntimeException(e);
}
})
.flatMap(transferTo);
}
private static LocalDateTime getLastModifiedTime(Path path) {
try {
return LocalDateTime.ofInstant(
Files.getLastModifiedTime(path).toInstant(),
ZoneId.systemDefault());
} catch (IOException e) {
return LocalDateTime.MIN;
}
}
private static long size(Path path) {
try {
if (Files.isRegularFile(path)) {
return Files.size(path);
}
} catch (IOException e) {
log.debug("Size error for {}: {}", path, e.getMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,13 @@
app:
fs:
root: ${APP_FS_ROOT:/app/fs}
bak:
count: 5
username: ${APP_USERNAME:admin}
password: ${APP_PASSWORD:admin}
server:
port: 8080
logging:
level:
org.springframework.web: DEBUG

View File

@@ -0,0 +1,89 @@
/*!
* Minimal theme switcher
*
* Pico.css - https://picocss.com
* Copyright 2019-2022 - Licensed under MIT
*/
const themeSwitcher = {
// Config
_scheme: "auto",
menuTarget: "details[role='list']",
buttonsTarget: "a[data-theme-switcher]",
buttonAttribute: "data-theme-switcher",
rootAttribute: "data-theme",
localStorageKey: "picoPreferedColorScheme",
// Init
init() {
this.scheme = this.schemeFromLocalStorage;
this.initSwitchers();
},
// Get color scheme from local storage
get schemeFromLocalStorage() {
if (typeof window.localStorage !== "undefined") {
if (window.localStorage.getItem(this.localStorageKey) !== null) {
return window.localStorage.getItem(this.localStorageKey);
}
}
return this._scheme;
},
// Prefered color scheme
get preferedColorScheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
},
// Init switchers
initSwitchers() {
const buttons = document.querySelectorAll(this.buttonsTarget);
buttons.forEach((button) => {
button.addEventListener("click", event => {
event.preventDefault();
// Set scheme
this.scheme = button.getAttribute(this.buttonAttribute);
// Close dropdown
document.querySelector(this.menuTarget).removeAttribute("open");
}, false);
});
},
// Set scheme
set scheme(scheme) {
if (scheme == "auto") {
this.preferedColorScheme == "dark"
? (this._scheme = "dark")
: (this._scheme = "light");
} else if (scheme == "dark" || scheme == "light") {
this._scheme = scheme;
}
this.applyScheme();
this.schemeToLocalStorage();
},
// Get scheme
get scheme() {
return this._scheme;
},
// Apply scheme
applyScheme() {
document
.querySelector("html")
.setAttribute(this.rootAttribute, this.scheme);
},
// Store scheme to local storage
schemeToLocalStorage() {
if (typeof window.localStorage !== "undefined") {
window.localStorage.setItem(this.localStorageKey, this.scheme);
}
},
};
// Init
themeSwitcher.init();

View File

@@ -0,0 +1,38 @@
/* Global CSS variables */
:root {
--spacing: 0.1em;
--t-ypography-spacing-vertical: 0px;
}
#editor {
font-size: 1.2em;
position: relative;
width: 100%;
height: 75vh;
}
ul.breadcrumbs > li {
padding-left: 2px;
padding-right: 2px;
}
ul.breadcrumbs > li:after {
content: "/";
text-indent: 4px;
color: var(--primary);
}
ul.breadcrumbs > li:last-child:after {
content: "";
}
/*body > header {*/
/* padding-top: 0px;*/
/* padding-bottom: 0px;*/
/*}*/
/*body > main {*/
/* padding-top: 16px;*/
/* padding-bottom: 16px;*/
/*}*/

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Browser">
<title>Index of </title>
<link rel="shortcut icon" href="https://picocss.com/favicon.ico">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css"
integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous"/>
<!-- Pico.css -->
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<!-- Custom template-->
<link rel="stylesheet" href="/stylesheet.css">
<style></style>
</head>
<body>
<header class="container">
<h5>Browse</h5>
<nav>
<ul class="breadcrumbs">
<li><a href="/"><i class="fa fa-home"></i></a></li>
{{#breadcrumbs}}
<li><a href="/?path={{fullName}}">{{name}}</a></li>
{{/breadcrumbs}}
</ul>
</nav>
</header>
<!-- Main -->
<main class="container">
<!-- Tables -->
<section id="tables">
<figure>
<table role="grid">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Date</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{{#files}}
<tr>
<th scope="row">
{{#dir}}
<a href="/?path={{fullName}}">
<i class="fa fa-folder"></i>
{{name}}
</a>
{{/dir}}
{{^dir}}
<a href="/edit?path={{fullName}}">
<i class="fa fa-file"></i>
{{name}}
</a>
{{/dir}}
</th>
<td>{{modifiedAt}}</td>
<td>{{size}}</td>
</tr>
{{/files}}
</tbody>
</table>
</figure>
</section><!-- ./ Tables -->
</main><!-- ./ Main -->
<!-- Footer -->
<footer class="container">
<details role="list">
<summary aria-haspopup="listbox" role="button" class="secondary">Theme</summary>
<ul role="listbox">
<li><a href="#" data-theme-switcher="auto">Auto</a></li>
<li><a href="#" data-theme-switcher="light">Light</a></li>
<li><a href="#" data-theme-switcher="dark">Dark</a></li>
</ul>
</details>
<small>Built with <a href="https://picocss.com">Pico</a> • <a
href="https://github.com/picocss/examples/blob/master/basic-template/">Source code</a></small>
</footer><!-- ./ Footer -->
<!-- Minimal theme switcher -->
<script src="../js/minimal-theme-switcher.js"></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<header class="container">
<hgroup>
<h1>Basic template</h1>
<h2>A basic custom template for Pico using only CSS custom properties (variables).</h2>
</hgroup>
<nav>
<ul>
<li>
<details role="list">
<summary aria-haspopup="listbox" role="button" class="secondary">Theme</summary>
<ul role="listbox">
<li><a href="#" data-theme-switcher="auto">Auto</a></li>
<li><a href="#" data-theme-switcher="light">Light</a></li>
<li><a href="#" data-theme-switcher="dark">Dark</a></li>
</ul>
</details>
</li>
<li>
<details role="list">
<summary aria-haspopup="listbox">Examples</summary>
<ul role="listbox">
<li><a href="../preview/">Preview</a></li>
<li><a href="../preview-rtl/">Right-to-left</a></li>
<li><a href="../classless/">Class-less</a></li>
<li><a href="../basic-template/">Basic template</a></li>
<li><a href="../company/">Company</a></li>
<li><a href="../google-amp/">Google Amp</a></li>
<li><a href="../sign-in/">Sign in</a></li>
<li><a href="../bootstrap-grid/">Bootstrap grid</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</header>

View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Browser">
<meta name="_csrf" content="{{_csrf.token}}"/>
<meta name="_csrf_header" content="{{_csrf.headerName}}"/>
<title>Edit {{selectedFileName}}</title>
<link rel="shortcut icon" href="https://picocss.com/favicon.ico">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css"
integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous"/>
<!-- Pico.css -->
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<!-- Custom template-->
<link rel="stylesheet" href="/stylesheet.css">
</head>
<body>
<header class="container">
<h5>Edit {{selectedFileName}}</h5>
<nav>
<ul class="breadcrumbs">
<li><a href="/"><i class="fa fa-home"></i></a></li>
{{#breadcrumbs}}
<li><a href="/?path={{fullName}}">{{name}}</a></li>
{{/breadcrumbs}}
<li>{{selectedFileName}}</li>
</ul>
<ul>
<li><a href="javascript:save()" role="button">Save</a></li>
</ul>
</nav>
</header>
<!-- Main -->
<main class="container">
<section id="editor">
<figure>
<div id="editor"></div>
</figure>
</section>
</main><!-- ./ Main -->
<!-- Footer -->
<footer class="container">
<small>Built with <a href="https://picocss.com">Pico</a> • <a
href="https://github.com/picocss/examples/blob/master/basic-template/">Source code</a></small>
</footer><!-- ./ Footer -->
<!-- Minimal theme switcher -->
<script src="../js/minimal-theme-switcher.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.12.5/ace.js"
integrity="sha512-gLQA+KKlMRzGRNhdvGX+3F5UHojWkIIKvG2lNQk0ZM5QUbdG17/hDobp06zXMthrJrd4U1+boOEACntTGlPjJQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.12.5/ext-modelist.min.js"
integrity="sha512-gluOZJrbb4P8hFk1M2HREivZlXwZd0Uf1i5LLt6NRHjjnu7+4eE/yaYH/m82kMTU557+Q2xJhMmqtzMBBh8o/g=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fetch/3.6.2/fetch.min.js"
integrity="sha512-1Gn7//DzfuF67BGkg97Oc6jPN6hqxuZXnaTpC9P5uw8C6W4yUNj5hoS/APga4g1nO2X6USBb/rXtGzADdaVDeA=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script>
var modelist = require("ace/ext/modelist");
var mode = modelist.getModeForPath("{{selectedFileName}}");
var editor = ace.edit("editor",
{
mode: mode.mode,
selectionStyle: "text",
theme: "ace/theme/dracula"
});
document.getElementById('editor').style.fontSize = '1.2em';
fetch('/content?path={{selectedFile}}')
.then(function (response) {
return response.text()
})
.then(function (body) {
editor.setValue(body);
editor.clearSelection();
editor.gotoLine(1);
});
function save() {
var blob = new Blob([editor.getValue()]);
var formData = new FormData();
formData.set("file", blob);
fetch('/content?path={{selectedFile}}', {
method: 'POST',
credentials: 'include',
headers: {
"{{_csrf.headerName}}": "{{_csrf.token}}"
},
body: formData,
})
.then(function(ok) {
Toastify({
text: "Saved",
duration: 3000,
close: true,
gravity: "top",
position: "right",
stopOnFocus: true,
style: {
background: "green",
},
onClick: function(){} // Callback after click
}).showToast();
}).catch(function(ex) {
Toastify({
text: "Error",
duration: 3000,
close: true,
gravity: "top",
position: "right",
stopOnFocus: true,
style: {
background: "red",
},
onClick: function(){} // Callback after click
}).showToast();
})
}
</script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
</body>
</html>

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Browser">
<title>Login</title>
<link rel="shortcut icon" href="https://picocss.com/favicon.ico">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css"
integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous"/>
<!-- Pico.css -->
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<!-- Custom template-->
<!-- <link rel="stylesheet" href="/stylesheet.css">-->
<style>
/* Grid */
body > main {
display: flex;
flex-direction: column;
justify-content: center;
min-height: calc(100vh - 7rem);
padding: 1rem 0;
}
article {
padding: 0;
overflow: hidden;
}
article div {
padding: 1rem;
}
@media (min-width: 576px) {
body > main {
padding: 1.25rem 0;
}
article div {
padding: 1.25rem;
}
}
@media (min-width: 768px) {
body > main {
padding: 1.5rem 0;
}
article div {
padding: 1.5rem;
}
}
@media (min-width: 992px) {
body > main {
padding: 1.75rem 0;
}
article div {
padding: 1.75rem;
}
}
@media (min-width: 1200px) {
body > main {
padding: 2rem 0;
}
article div {
padding: 2rem;
}
}
/* Nav */
summary[role="link"].secondary:is([aria-current],:hover,:active,:focus) {
background-color: transparent;
color: var(--secondary-hover);
}
@media (min-width: 992px) {
.grid > div:nth-of-type(2) {
display: block;
}
}
/* Footer */
body > footer {
padding: 1rem 0;
}
</style>
</head>
<body>
<!-- Main -->
<main class="container">
<article class="grid">
<div>
<hgroup>
<h1>Sign in</h1>
</hgroup>
<form method="post">
<input type="text" name="username" placeholder="Username" aria-label="Login" autocomplete="nickname" required>
<input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required>
<input type="hidden" name="{{_csrf.parameterName}}" value="{{_csrf.token}}"/>
<button type="submit" class="contrast">Login</button>
</form>
</div>
<div></div>
</article>
</main><!-- ./ Main -->
<!-- Footer -->
<footer class="container">
<small>Built with <a href="https://picocss.com">Pico</a> • <a
href="https://github.com/picocss/examples/blob/master/basic-template/">Source code</a></small>
</footer><!-- ./ Footer -->
</body>
</html>

View File

@@ -0,0 +1,13 @@
package com.github.russp.bpe;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BpeApplicationTests {
@Test
void contextLoads() {
}
}