initial commit

This commit is contained in:
emilis 2022-09-02 15:42:48 +01:00
commit 10847ca28a
14 changed files with 853 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
target/
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log
__debug_bin
*.wasm
fupie
trash/
fupie_wasm.js

15
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go"
}
]
}

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module sectorinf.com/emilis/fupie
go 1.19
require github.com/stretchr/testify v1.8.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

19
go.sum Normal file
View File

@ -0,0 +1,19 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

246
logie/logie.go Normal file
View File

@ -0,0 +1,246 @@
package logie
import (
"fmt"
"os"
"strings"
"time"
)
const (
printFmt = "%s%s %s\n"
escape byte = 0x1B
csi byte = '['
separator byte = ';'
csiEnd byte = 'm'
fgColor byte = '3'
bgColor byte = '4'
reset byte = '0'
)
type Maybe[T any] struct {
exists bool
value T
}
func Exists[T any](t T) Maybe[T] {
return Maybe[T]{
exists: true,
value: t,
}
}
func Empty[T any]() Maybe[T] {
return Maybe[T]{}
}
func (m Maybe[T]) Exists() bool {
return m.exists
}
func (m Maybe[T]) Value() T {
return m.value
}
type Color uint8
const (
ColorBlack = '0'
ColorRed = '1'
ColorGreen = '2'
ColorYellow = '3'
ColorBlue = '4'
ColorMagenta = '5'
ColorCyan = '6'
ColorWhite = '7'
)
type ColorSet struct {
fg Maybe[Color]
bg Maybe[Color]
font Maybe[Font]
}
func Colors() ColorSet {
return ColorSet{}
}
func (c ColorSet) Foreground(fg Color) ColorSet {
c.fg = Exists(fg)
return c
}
func (c ColorSet) Background(bg Color) ColorSet {
c.bg = Exists(bg)
return c
}
func (c ColorSet) Font(f Font) ColorSet {
c.font = Exists(f)
return c
}
func (c ColorSet) String(v string) string {
var hasFg, hasBg, hasFont = c.fg.Exists(), c.bg.Exists(), c.font.Exists()
if !hasFg && !hasBg && !hasFont {
return v
}
// Build format
formatBytes := []byte{escape, csi}
if hasFg {
formatBytes = append(formatBytes, fgColor, byte(c.fg.Value()))
}
if hasBg {
if len(formatBytes) > 2 {
formatBytes = append(formatBytes, separator)
}
formatBytes = append(formatBytes, bgColor, byte(c.bg.Value()))
}
if hasFont {
if len(formatBytes) > 2 {
formatBytes = append(formatBytes, separator)
}
formatBytes = append(formatBytes, byte(c.font.Value()))
}
formatBytes = append(formatBytes, csiEnd)
formatBytes = append(append(formatBytes, []byte(v)...), escape, csi, reset, csiEnd)
return string(formatBytes)
}
type Font uint8
const (
FontBold = '1'
FontFaint = '2'
FontUnderlined = '4'
FontSlowBlink = '5'
)
var (
info = Colors().Font(FontFaint).String("[INFO]")
warn = Colors().Background(ColorYellow).String("[WARN]")
error_ = Colors().Foreground(ColorWhite).Background(ColorRed).Font(FontBold).String("[ERROR]")
fatal = Colors().Foreground(ColorRed).Background(ColorYellow).Font(FontBold).String("[FATAL]")
)
type Log struct {
output *os.File
timeFmt string
color map[int]ColorSet
timeColor ColorSet
}
func New() Log {
return Log{
output: os.Stdout,
timeFmt: time.RFC822,
timeColor: Colors().Font(FontFaint),
color: map[int]ColorSet{},
}
}
func (l Log) Clone() Log {
colorClone := map[int]ColorSet{}
for k, cs := range l.color {
colorClone[k] = cs
}
l.color = colorClone
return l
}
// ColorBracket returns a Log with the coloring information for the bracket index.
//
// The way coloring works in Logie is that you can only color the parts of your
// message that are within closed brackets `[]` in its format string
// (therefore non-f calls cannot color). The [index] being passed in is a zero-indexed
// index of which bracket set to color.
func (l Log) ColorBracket(index int, set ColorSet) Log {
l = l.Clone()
l.color[index] = set
return l
}
func (l Log) time() string {
return l.timeColor.String("[" + time.Now().Format(l.timeFmt) + "]")
}
func (l Log) Logf(level, format string, args ...any) {
line := fmt.Sprintf(colorize(format, l.color), args...)
fmt.Fprintf(l.output, printFmt, l.time(), level, line)
}
func colorize(format string, colors map[int]ColorSet) string {
var (
parts = []string{}
bracketParts = []int{}
last int
)
outer:
for index := 0; index < len(format); index++ {
if format[index] == '[' {
parts = append(parts, format[last:index])
for inner := index + 1; index < len(format); inner++ {
if format[inner] == ']' {
current := format[index : inner+1]
parts = append(parts, current)
bracketParts = append(bracketParts, len(parts)-1)
last = inner + 1
continue outer
}
}
parts = append(parts, format[index:])
}
}
parts = append(parts, format[last:])
for bracketIndex, color := range colors {
if bracketIndex >= len(bracketParts) {
continue
}
parts[bracketParts[bracketIndex]] = color.String(parts[bracketParts[bracketIndex]])
}
return strings.Join(parts, "")
}
func (l Log) Log(level string, args ...any) {
line := fmt.Sprint(args...)
fmt.Fprintf(l.output, printFmt, l.time(), level, line)
}
func (l Log) Infof(format string, args ...any) {
l.Logf(info, format, args...)
}
func (l Log) Info(args ...any) {
l.Log(info, args...)
}
func (l Log) Warnf(format string, args ...any) {
l.Logf(warn, format, args...)
}
func (l Log) Warn(args ...any) {
l.Log(warn, args...)
}
func (l Log) Errorf(format string, args ...any) {
l.Logf(error_, format, args...)
}
func (l Log) Error(args ...any) {
l.Log(error_, args...)
}
func (l Log) Fatalf(format string, args ...any) {
l.Logf(fatal, format, args...)
os.Exit(1)
}
func (l Log) Fatal(args ...any) {
l.Log(fatal, args...)
os.Exit(1)
}

36
main.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"flag"
"net/http"
"sectorinf.com/emilis/fupie/logie"
"sectorinf.com/emilis/fupie/ws"
"sectorinf.com/emilis/fupie/ws/html"
"sectorinf.com/emilis/fupie/ws/route"
)
func main() {
var (
uploadsPath = flag.String("path", ".", "Path to upload files to")
redirectPath = flag.String("redirect", "http://localhost:8008", "URI part to add the uploaded filename to for redirects")
host = flag.String("host", "127.0.0.1:8008", "Hostname to listen to")
)
flag.Parse()
log := logie.New()
router := route.New(log).Group("v1", func(r route.Router) route.Router {
upload, err := ws.Upload(*uploadsPath, *redirectPath, 1<<20)
if err != nil {
log.Fatal(err.Error())
}
r.POST("upload", upload)
return r
}).
GET("/", html.Index).
Middleware(ws.LoggingMiddleware(log))
log.ColorBracket(0, logie.Colors().Background(logie.ColorWhite).Foreground(logie.ColorBlack)).Infof("Starting server on [%s]", host)
log.Fatal(http.ListenAndServe(*host, router))
}

24
ws/html/index.go Normal file
View File

@ -0,0 +1,24 @@
package html
import (
"embed"
"html/template"
"net/http"
"sectorinf.com/emilis/fupie/ws/route"
)
//go:embed templates
var templates embed.FS
func Index(ctx route.Context) {
tmpl, err := template.ParseFS(templates, "templates/index.html")
if err != nil {
panic(err)
}
if err := tmpl.Execute(ctx.Writer, struct{}{}); err != nil {
ctx.Log.Errorf("index.html template: %s", err.Error())
ctx.Status(http.StatusInternalServerError)
return
}
}

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<title>File Upload</title>
<link rel="icon" type="image/gif" href="https://jigglypuff.zone/pufftaunt.gif">
<style>
body {
background-color: black;
color: rebeccapurple;
}
h1 {
text-align: center;
}
#main-upload {
/* margin: auto; */
margin-left: auto;
margin-right: auto;
margin-top: 10%;
margin-bottom: 30%;
width: 30%;
border: 5px solid rebeccapurple;
height: 30vw;
padding: 10px;
background-color: rgba(13, 6, 19, 0.4);
font-size: large;
font-weight: bold;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<h1>file upload</h1>
<div id="main-upload">
<!-- <div id="upload-box"> -->
<form id="upload-form" method="post" action="/v1/upload" enctype="multipart/form-data">
<label for="upload">Upload a file</label>
<input type="file" id="upload" name="upload" accept="*" value="upload">
</form>
<!-- </div> -->
</div>
<script>
window.addEventListener('paste', e => {
document.getElementById("upload").files = e.clipboardData.files;
document.getElementById("upload-form").submit();
});
</script>
</body>
</html>

44
ws/logmware.go Normal file
View File

@ -0,0 +1,44 @@
package ws
import (
"net/http"
"sectorinf.com/emilis/fupie/logie"
"sectorinf.com/emilis/fupie/ws/route"
)
type LoggingSet struct {
log1XX logie.Log
log2XX logie.Log
log3XX logie.Log
log4XX logie.Log
log5XX logie.Log
}
func LoggingMiddleware(l logie.Log) route.Middleware {
l = l.ColorBracket(1, logie.Colors().Font(logie.FontFaint))
log := LoggingSet{
log1XX: l.ColorBracket(0, logie.Colors().Font(logie.FontFaint)),
log2XX: l.ColorBracket(0, logie.Colors().Foreground(logie.ColorWhite).Background(logie.ColorGreen)),
log3XX: l.ColorBracket(0, logie.Colors().Foreground(logie.ColorYellow).Font(logie.FontFaint)),
log4XX: l.ColorBracket(0, logie.Colors().Foreground(logie.ColorWhite).Background(logie.ColorMagenta)),
log5XX: l.ColorBracket(0, logie.Colors().Foreground(logie.ColorWhite).Background(logie.ColorRed).Font(logie.FontBold)),
}
return func(req http.Request, keep route.Keeper) {
var logger logie.Log
switch status := keep.Status; {
case status > 199 && status < 300:
logger = log.log2XX
case status > 299 && status < 400:
logger = log.log3XX
case status > 399 && status < 500:
logger = log.log4XX
case status > 499 && status < 600:
logger = log.log5XX
default:
logger = log.log1XX
}
logger.Infof("[%d]: [%s]", keep.Status, req.URL.Path)
}
}

79
ws/route/context.go Normal file
View File

@ -0,0 +1,79 @@
package route
import (
"context"
"encoding/json"
"net/http"
"sectorinf.com/emilis/fupie/logie"
)
type Keeper struct {
Status int
}
type ResponseWriterKeeper struct {
http.ResponseWriter
Keeper
}
func (r *ResponseWriterKeeper) WriteHeader(status int) {
r.Status = status
r.ResponseWriter.WriteHeader(status)
}
type Context struct {
Writer http.ResponseWriter
Request *http.Request
Context context.Context
Log logie.Log
}
func (c Context) JSON(v any) {
c.JSONStatus(http.StatusOK, v)
}
func (c Context) JSONStatus(status int, v any) {
data, err := json.Marshal(v)
if err != nil {
c.Log.Errorf("json marshal: %s", err.Error())
c.Writer.WriteHeader(http.StatusInternalServerError)
return
}
c.Writer.WriteHeader(status)
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.Write(data)
}
func (c Context) Status(status int) {
c.Writer.WriteHeader(status)
c.Writer.Write([]byte(http.StatusText(status)))
}
func (c Context) InternalServerError(args ...any) {
c.Log.Error(args...)
c.Writer.WriteHeader(http.StatusInternalServerError)
}
func (c Context) InternalServerErrorf(format string, args ...any) {
c.Log.Errorf(format, args...)
c.Writer.WriteHeader(http.StatusInternalServerError)
}
func (c Context) DataStatus(status int, data []byte) {
c.DataStatusType(status, http.DetectContentType(data), data)
}
func (c Context) Data(data []byte) {
c.DataStatus(http.StatusOK, data)
}
func (c Context) DataStatusType(status int, contentType string, data []byte) {
c.Writer.Header().Set("Content-Type", contentType)
c.Writer.WriteHeader(status)
c.Writer.Write(data)
}
func (c Context) SeeOther(uri string) {
http.Redirect(c.Writer, c.Request, uri, http.StatusSeeOther)
}

48
ws/route/pattern.go Normal file
View File

@ -0,0 +1,48 @@
package route
import "strings"
func Pattern(v string) PartPattern {
return PartPattern(splitWithoutEmpty(v))
}
type PartPattern []string
func (p PartPattern) String() string {
return strings.Join(p, "/")
}
func (p PartPattern) Match(path string) bool {
pathParts := splitWithoutEmpty(path)
for i, part := range p {
if len(pathParts)-1 <= i {
return part == "*" || part == pathParts[i]
}
pathPart := pathParts[i]
if wildcardIndex := strings.IndexByte(part, '*'); wildcardIndex >= 0 {
if len(pathPart) < wildcardIndex || pathPart[:wildcardIndex] != part[:wildcardIndex] {
return false
}
} else if pathPart != part {
return false
}
}
return false
}
func splitWithoutEmpty(v string) []string {
out := []string{}
start := 0
for index, char := range v {
if char == '/' {
sub := v[start:index]
if len(sub) == 0 {
start++
continue
}
out = append(out, sub)
start = index + 1
}
}
return append(out, v[start:])
}

56
ws/route/pattern_test.go Normal file
View File

@ -0,0 +1,56 @@
package route_test
import (
"testing"
"github.com/stretchr/testify/assert"
"sectorinf.com/emilis/fupie/ws/route"
)
func TestPattern(t *testing.T) {
tests := map[string]struct {
patternInput string
testPaths map[string]bool
}{
"wildcard in middle of path": {
patternInput: "/hello/*/bye",
testPaths: map[string]bool{
"/hello/hi/bye": true,
"/hello/no/bye": true,
"/hello/something/bye/something_else": false,
"/random/nothing/bye": false,
},
},
"wildcard within word": {
patternInput: "/hello/seeya-*/bye",
testPaths: map[string]bool{
"/hello/seeya-hi/bye": true,
"/hello/seeya-no/bye": true,
"/hello/hi/bye": false,
"/hello/no/bye": false,
"/hello/something/bye/something_else": false,
"/random/nothing/bye": false,
"/hello/seeya-/bye": true,
},
},
"root /": {
patternInput: "/",
testPaths: map[string]bool{
"": true,
"/": true,
"/hello/something/bye/something_else": false,
"/random/nothing/bye": false,
},
},
}
for name, test := range tests {
test := test
t.Run(name, func(tt *testing.T) {
p := route.Pattern(test.patternInput)
for path, expected := range test.testPaths {
assert.Equal(t, expected, p.Match(path), path)
}
})
}
}

129
ws/route/router.go Normal file
View File

@ -0,0 +1,129 @@
package route
import (
"context"
"fmt"
"net/http"
"strings"
"sectorinf.com/emilis/fupie/logie"
)
type Method string
type Route struct {
Pattern PartPattern
Handler HandlerFunc
}
type Routes []Route
type Middleware func(http.Request, Keeper)
var (
NotFoundHandler = func(ctx Context) {
ctx.Status(http.StatusNotFound)
}
)
func (r Routes) Match(path string) HandlerFunc {
for _, route := range r {
if route.Pattern.Match(path) {
return route.Handler
}
}
return NotFoundHandler
}
type HandlerFunc func(Context)
type Router struct {
fnMatcher map[Method]Routes
middleware []Middleware
logger logie.Log
}
func New(logger logie.Log) Router {
return Router{
fnMatcher: map[Method]Routes{
http.MethodGet: {},
http.MethodConnect: {},
http.MethodPatch: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodDelete: {},
http.MethodHead: {},
http.MethodOptions: {},
http.MethodTrace: {},
},
logger: logger,
middleware: []Middleware{},
}
}
func (r Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
byMethod, exists := r.fnMatcher[Method(req.Method)]
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
fn := byMethod.Match(req.URL.Path)
rwk := &ResponseWriterKeeper{
ResponseWriter: w,
Keeper: Keeper{
// Default 200
Status: http.StatusOK,
},
}
fn(Context{
Request: req,
Writer: rwk,
Context: context.Background(),
Log: r.logger,
})
for _, mware := range r.middleware {
mware(*req, rwk.Keeper)
}
}
func (r Router) With(method, pathExpr string, handler HandlerFunc) Router {
r.fnMatcher[Method(method)] = append(r.fnMatcher[Method(method)], Route{
Pattern: Pattern(pathExpr),
Handler: handler,
})
return r
}
func (r Router) GET(pathExpr string, handler HandlerFunc) Router {
return r.With(http.MethodGet, pathExpr, handler)
}
func (r Router) POST(pathExpr string, handler HandlerFunc) Router {
return r.With(http.MethodPost, pathExpr, handler)
}
func (r Router) PATCH(pathExpr string, handler HandlerFunc) Router {
return r.With(http.MethodPatch, pathExpr, handler)
}
func (r Router) PUT(pathExpr string, handler HandlerFunc) Router {
return r.With(http.MethodPut, pathExpr, handler)
}
func (r Router) DELETE(pathExpr string, handler HandlerFunc) Router {
return r.With(http.MethodDelete, pathExpr, handler)
}
func (r Router) Middleware(mware ...Middleware) Router {
r.middleware = mware
return r
}
func (r Router) Group(prefix string, fn func(Router) Router) Router {
prefix = strings.TrimSuffix(prefix, "/")
group := fn(New(r.logger))
for method, byRoute := range group.fnMatcher {
for _, route := range byRoute {
r = r.With(string(method), fmt.Sprintf("%s/%s", prefix, route.Pattern.String()), route.Handler)
}
}
return r
}

78
ws/upload.go Normal file
View File

@ -0,0 +1,78 @@
package ws
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sectorinf.com/emilis/fupie/ws/route"
)
var (
base64Stripper = strings.NewReplacer("/", "", "+", "", "=", "")
)
func Upload(rootDir, redirect string, maxBytes int64) (route.HandlerFunc, error) {
stat, err := os.Stat(rootDir)
if err != nil {
return nil, fmt.Errorf("stat [%s]: %w", rootDir, err)
}
if !stat.IsDir() {
return nil, fmt.Errorf("root path [%s] is not a directory", rootDir)
}
rootDir, err = filepath.Abs(rootDir)
if err != nil {
return nil, fmt.Errorf("abs filepath [%s]: %w", rootDir, err)
}
redirect = strings.TrimRight(redirect, "/")
return func(ctx route.Context) {
if err := ctx.Request.ParseMultipartForm(maxBytes); err != nil {
ctx.Status(http.StatusBadRequest)
return
}
uploads := ctx.Request.MultipartForm.File["upload"]
if len(uploads) != 1 {
ctx.Status(http.StatusBadRequest)
return
}
header := uploads[0]
file, err := header.Open()
if err != nil {
ctx.InternalServerErrorf("header.Open: %s", err.Error())
return
}
newFilename := rename(header.Filename)
path := filepath.Join(rootDir, newFilename)
fsFile, err := os.Create(path)
if err != nil {
ctx.InternalServerErrorf("creating file at [%s]: %s", path, err.Error())
return
}
if _, err := io.CopyBuffer(fsFile, file, nil); err != nil {
ctx.InternalServerErrorf("writing file at [%s]: %s", path, err.Error())
return
}
fsFile.Close()
ctx.SeeOther(fmt.Sprintf("%s/%s", redirect, newFilename))
}, nil
}
func rename(name string) string {
return fmt.Sprintf("%s_%s", randName(4), name)
}
func randName(bytes int) string {
buffer := make([]byte, bytes)
_, err := rand.Read(buffer)
if err != nil {
panic(err)
}
return base64Stripper.Replace(base64.StdEncoding.EncodeToString(buffer))
}