33 Commits
Author SHA1 Message Date
redtea 8eb2bbd35d feat: close buttons as a red cross in a square; static files revalidated by content, so a release reaches the browser at once 2026-10-14 19:24:12 +03:00
redtea 55db8f03b3 chore: keep the version at 0.0.7 2026-10-14 19:07:29 +03:00
redtea e9a845a6ea feat: files go in 4 MB pieces, several at once and each retried on its own, so they pass a proxy like Cloudflare and survive a bad link (0.0.8) 2026-10-14 19:06:59 +03:00
redtea 23e382bf7f fix: the terminal opens in the user's home directory, not in / 2026-10-14 18:53:07 +03:00
redtea 438b20dae0 chore: log where and after how many bytes a file transfer breaks 2026-10-14 18:49:07 +03:00
redtea 19f8b5291e fix: no arrow on the sorted column 2026-10-14 18:46:56 +03:00
redtea c911143eb2 feat: SQLite preview read straight from the file, and sorting of the file table by column, kept per folder 2026-10-14 18:46:32 +03:00
redtea 1a90af7800 feat: a spinner on the save button while a file saves 2026-10-14 18:36:48 +03:00
redtea 92c9616826 feat: toasts show over the file viewer; icon buttons in the terminal's copy menu 2026-10-14 18:31:45 +03:00
redtea 2db44e53f7 feat: red text and border on every close button 2026-10-14 16:15:03 +03:00
redtea 68ede6ab25 feat: previews of PDF, Word, spreadsheets, CSV and HTML in the file manager; the find bar's buttons back in one row 2026-10-14 16:09:02 +03:00
redtea d448c1e3a4 fix: find and replace fields of one width, a red close button 2026-10-14 16:01:59 +03:00
redtea 3d6b04e78a fix: binary editing only for binary files and the ones shown as a preview 2026-10-14 16:00:20 +03:00
redtea e1f5595afc feat: files up to 512 MB as streams, a code editor with highlighting and find/replace, media preview, hex editing; touch selection in the terminal (0.0.7) 2026-10-14 15:52:38 +03:00
redtea 3e46116a9b feat: upload progress by the file manager spinner, and a list of the uploads behind it 2026-10-14 15:28:19 +03:00
redtea 10b8995041 feat: rename a file in place from its row in the file manager 2026-10-14 15:25:50 +03:00
redtea 225b7ecfcc fix: terminal input reaches the shell in the order it was typed 2026-10-14 15:18:14 +03:00
redtea cb2673d941 Revert "feat: DEL in place of PGDN among the terminal's extra keys"
This reverts commit cf855fdc23.
2026-10-14 14:59:18 +03:00
redtea cf855fdc23 feat: DEL in place of PGDN among the terminal's extra keys 2026-10-14 14:58:06 +03:00
redtea d456ffe757 feat: phone layout for the panel, and Termux-style extra keys in the terminal 2026-10-14 14:43:06 +03:00
redtea 81a2c96e0b fix: centre the ufw switch under the create button 2026-10-13 23:23:03 +03:00
redtea e0dc15740d feat: the ufw switch of a new forward rule goes under the button 2026-10-13 23:22:07 +03:00
redtea fbd8f30de3 feat: the new forward rule form on one line, with short type names 2026-10-13 23:18:49 +03:00
redtea 67c02ad6b4 chore: keep the version at 0.0.6 2026-10-13 23:13:15 +03:00
redtea 5155bc36cd feat: measure each direction of the speed test for 7 s (0.0.7) 2026-10-13 23:12:30 +03:00
redtea ab8751d436 feat: live reading during the speed test, and a 15 s limit that keeps what was measured 2026-10-13 23:06:56 +03:00
redtea 6441b4329d fix: don't let a slow browser link time out the relayed speed test 2026-10-13 22:49:19 +03:00
redtea 189050b2b6 fix: end the browser speed test feed without a 410 or 502 2026-10-13 22:33:42 +03:00
redtea fd75f324b8 fix: keep the speed results hidden until there is a measurement 2026-10-13 21:02:39 +03:00
redteaandClaude Opus 5.5 789e1da93b feat: open a port in ufw by hand from the panel settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:09 +03:00
redteaandClaude Opus 5.5 87921a1381 feat: speed test through the browser, client - panel - browser (0.0.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
redteaandClaude Opus 5.5 b51363bf72 feat: optional label on a forward rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
redteaandClaude Opus 5.5 201d63ee1e feat: open the file manager in the user's home directory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
30 changed files with 3621 additions and 218 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.0.5
0.0.7
+7 -3
View File
@@ -21,7 +21,8 @@ server {
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
# Agent binaries can be large; allow the download.
# Files go through the panel in 4 MB pieces; 64m leaves room for a client
# that predates the pieces, which takes a file of up to 32 MB in one request.
client_max_body_size 64m;
location / {
@@ -36,7 +37,10 @@ server {
# Long-poll behaviour: no buffering, generous read timeout.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# Pass uploads on as they arrive: the speed test through the browser
# times them on the client.
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
+3
View File
@@ -48,6 +48,7 @@ type state struct {
type Agent struct {
cfg Config
http *http.Client
stream *http.Client
state state
pollCount int
@@ -81,6 +82,8 @@ func New(cfg Config) *Agent {
cfg: cfg,
// Timeout must exceed the server's long-poll hold (25s) plus slack.
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
// File streams take as long as the file needs.
stream: &http.Client{Transport: tr},
}
}
+39
View File
@@ -9,7 +9,9 @@ import (
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"sort"
"strings"
"unicode/utf8"
@@ -79,8 +81,18 @@ func fsList(path string) (proto.FSListResult, error) {
if path == "" {
path = "/"
}
// "~" / "~name" is where the file manager starts: the home directory, or
// the root when there is none to open.
home := strings.HasPrefix(path, "~") && !strings.Contains(path, "/")
if home {
path = homeDir(path[1:])
}
abs, _ := filepath.Abs(path)
entries, err := os.ReadDir(abs)
if err != nil && home && abs != "/" {
abs = "/"
entries, err = os.ReadDir(abs)
}
if err != nil {
return proto.FSListResult{}, err
}
@@ -222,3 +234,30 @@ func fsZip(paths []string, dest string) (any, error) {
}
return map[string]string{"dest": dest}, nil
}
// homeDir returns the home directory of the named user ("" = the user the
// agent runs as), or "/" when it cannot be told.
func homeDir(name string) string {
if name != "" {
if validUserName(name) {
if u, err := user.Lookup(name); err == nil && u.HomeDir != "" {
return u.HomeDir
}
}
return "/"
}
if u, err := user.Current(); err == nil && u.HomeDir != "" {
return u.HomeDir
}
if h := os.Getenv("HOME"); h != "" {
return h
}
// A root service often has neither a passwd entry we can read nor $HOME.
if os.Geteuid() == 0 {
if runtime.GOOS == "darwin" {
return "/var/root"
}
return "/root"
}
return "/"
}
+291
View File
@@ -0,0 +1,291 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Files go to and from the browser in pieces, each a request of its own
// through the server: small enough for any proxy in front, and sent again
// alone when the link drops it. A few go at once, so a good link is kept as
// busy as by one long stream.
const (
// xferWorkers is how many pieces are in flight at once.
xferWorkers = 4
// xferPieceTimeout bounds one piece's request: a link that slow, or
// stuck, gets the piece sent again.
xferPieceTimeout = 5 * time.Minute
// xferTries bounds how often a piece is tried over a failing link.
xferTries = 30
)
// errXferGone is the server saying the transfer is over: the browser left
// or gave up.
type errXferGone string
func (e errXferGone) Error() string { return string(e) }
func (a *Agent) xferURL(id string, i int) string {
return a.cfg.Host + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
}
// xferRequest makes one request for a piece, trying again over a failing link.
// A 503 is the server waiting on the other side: asked again at once.
func (a *Agent) xferRequest(ctx context.Context, newReq func(context.Context) (*http.Request, error), keep func(*http.Response) error) error {
fails := 0
for {
if err := ctx.Err(); err != nil {
return err
}
rctx, cancel := context.WithTimeout(ctx, xferPieceTimeout)
req, err := newReq(rctx)
if err != nil {
cancel()
return err
}
a.authHeaders(req)
resp, err := a.stream.Do(req)
if err == nil {
switch {
case resp.StatusCode == http.StatusGone:
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
cancel()
return errXferGone(strings.TrimSpace(string(msg)))
case resp.StatusCode == http.StatusServiceUnavailable:
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
cancel()
continue
case resp.StatusCode < 300:
err = keep(resp)
resp.Body.Close()
cancel()
if err == nil {
return nil
}
default:
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
err = errStatus(resp.StatusCode)
}
}
cancel()
if fails++; fails >= xferTries {
return fmt.Errorf("сеть не держит передачу: %w", err)
}
select {
case <-time.After(min(time.Duration(fails)*time.Second, 10*time.Second)):
case <-ctx.Done():
return ctx.Err()
}
}
}
// pieces runs fn for every piece, xferWorkers at a time, until one fails.
func pieces(ctx context.Context, n int, fn func(ctx context.Context, i int) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
mu sync.Mutex
next int
first error
wg sync.WaitGroup
)
for w := 0; w < min(xferWorkers, n); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
mu.Lock()
if first != nil || next >= n {
mu.Unlock()
return
}
i := next
next++
mu.Unlock()
if err := fn(ctx, i); err != nil {
mu.Lock()
if first == nil {
first = err
cancel()
}
mu.Unlock()
return
}
}
}()
}
wg.Wait()
return first
}
func pieceCount(size int64, chunk int) int {
if size <= 0 {
return 1
}
return int((size + int64(chunk) - 1) / int64(chunk))
}
// doFSPut writes the file the browser is uploading. It lands in a temporary
// file next to the target first, so a broken upload leaves the old file as it
// was.
func (a *Agent) doFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.FSXferArgs
if err := json.Unmarshal(env.Args, &args); err != nil {
return proto.TaskResult{Error: err.Error()}
}
if args.Size < 0 || args.Size > proto.FSFileCap || args.Chunk <= 0 {
return proto.TaskResult{Error: "файл больше 512 МБ"}
}
dir, base := filepath.Split(args.Path)
if dir == "" {
dir = "."
}
if info, err := os.Stat(args.Path); err == nil && info.IsDir() {
return proto.TaskResult{Error: "это каталог"}
}
tmp, err := os.CreateTemp(dir, "."+base+".lpra-*")
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
done := false
defer func() {
if !done {
tmp.Close()
os.Remove(tmp.Name())
}
}()
// Pieces written so far, to tell the server which it can let go.
n := pieceCount(args.Size, args.Chunk)
var (
mu sync.Mutex
got = make([]bool, n)
contig int
)
err = pieces(ctx, n, func(ctx context.Context, i int) error {
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
mu.Lock()
ack := contig
mu.Unlock()
var data []byte
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.Itoa(ack), nil)
}, func(resp *http.Response) error {
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
if err == nil && int64(len(b)) != want {
err = errors.New("кусок не той длины")
}
data = b
return err
})
if err != nil {
return err
}
if _, err := tmp.WriteAt(data, int64(i)*int64(args.Chunk)); err != nil {
return err
}
mu.Lock()
got[i] = true
for contig < n && got[contig] {
contig++
}
mu.Unlock()
return nil
})
if err != nil {
var gone errXferGone
if errors.As(err, &gone) {
return proto.TaskResult{Error: "загрузка прервана: " + string(gone)}
}
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
}
if err := tmp.Truncate(args.Size); err != nil {
return proto.TaskResult{Error: err.Error()}
}
if err := tmp.Close(); err != nil {
return proto.TaskResult{Error: err.Error()}
}
// A file that is being replaced keeps its mode and owner.
mode := os.FileMode(0o644)
if info, err := os.Stat(args.Path); err == nil {
mode = info.Mode().Perm()
if st, ok := info.Sys().(*syscall.Stat_t); ok {
_ = os.Chown(tmp.Name(), int(st.Uid), int(st.Gid))
}
}
if err := os.Chmod(tmp.Name(), mode); err != nil {
return proto.TaskResult{Error: err.Error()}
}
if err := os.Rename(tmp.Name(), args.Path); err != nil {
return proto.TaskResult{Error: err.Error()}
}
done = true
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
return proto.TaskResult{OK: true, Data: data}
}
// doFSSend sends a file to the browser that asked for it.
func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.FSXferArgs
if err := json.Unmarshal(env.Args, &args); err != nil {
return proto.TaskResult{Error: err.Error()}
}
if args.Chunk <= 0 {
return proto.TaskResult{Error: "не задан размер куска"}
}
f, err := os.Open(args.Path)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
if info.IsDir() {
return proto.TaskResult{Error: "это каталог"}
}
size := info.Size()
if size > proto.FSFileCap {
return proto.TaskResult{Error: "файл больше 512 МБ"}
}
sizeHdr := strconv.FormatInt(size, 10)
err = pieces(ctx, pieceCount(size, args.Chunk), func(ctx context.Context, i int) error {
off := int64(i) * int64(args.Chunk)
buf := make([]byte, min(int64(args.Chunk), size-off))
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
return err
}
return a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
if err == nil {
req.Header.Set("X-Size", sizeHdr)
req.Header.Set("Content-Type", "application/octet-stream")
}
return req, err
}, func(*http.Response) error { return nil })
})
if err != nil {
return proto.TaskResult{Error: "передача прервана: " + err.Error()}
}
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
return proto.TaskResult{OK: true, Data: data}
}
+66 -21
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"net/http"
"time"
@@ -12,16 +13,30 @@ import (
)
const (
// speedWindow is how long each direction is measured for.
speedWindow = 3 * time.Second
// speedUpCap bounds the upload, whatever the link does in speedWindow.
// speedWindow is how long each direction is measured for, unless the
// server asks for another window; speedWindowMax bounds what it may ask.
speedWindow = 3 * time.Second
speedWindowMax = 30 * time.Second
// speedWait is how long the download may take to start: in a relayed test
// its bytes come from the browser.
speedWait = 10 * time.Second
// speedUpCap bounds the upload, whatever the link does in the window.
speedUpCap = 48 << 20
)
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
// empty request, then how much flows each way in speedWindow.
func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
res, err := a.measureSpeed(ctx)
// empty request, then how much flows each way in the window. In a relayed
// test the server pipes both streams to the browser, so they run end to end.
func (a *Agent) doSpeed(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.SysSpeedArgs
if len(env.Args) > 0 {
_ = json.Unmarshal(env.Args, &args)
}
window := speedWindow
if args.WindowMs > 0 {
window = min(time.Duration(args.WindowMs)*time.Millisecond, speedWindowMax)
}
res, err := a.measureSpeed(ctx, args.Relay, window)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
@@ -29,8 +44,12 @@ func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
return proto.TaskResult{OK: true, Data: data}
}
func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error) {
var res proto.SysSpeedResult
func (a *Agent) measureSpeed(ctx context.Context, relay bool, window time.Duration) (proto.SysSpeedResult, error) {
res := proto.SysSpeedResult{Relay: relay}
streams := "/api/agent/speed"
if relay {
streams += "/relay"
}
// Ping: the first request may pay for a new connection, so keep the best.
for i := 0; i < 4; i++ {
start := time.Now()
@@ -46,24 +65,16 @@ func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error)
}
}
// Down: read what the server streams until the window closes.
dctx, cancel := context.WithTimeout(ctx, speedWindow)
resp, err := a.speedRequest(dctx, http.MethodGet, "/api/agent/speed/down", nil)
down, err := a.speedDown(ctx, streams+"/down", window)
if err != nil {
cancel()
return res, err
}
start := time.Now()
n, _ := io.Copy(io.Discard, resp.Body)
elapsed := time.Since(start)
resp.Body.Close()
cancel()
res.DownBps = bps(n, elapsed)
res.DownBps = down
// Up: send until the window closes or the cap is hit.
body := &speedSource{deadline: time.Now().Add(speedWindow), left: speedUpCap}
start = time.Now()
resp, err = a.speedRequest(ctx, http.MethodPost, "/api/agent/speed/up", body)
body := &speedSource{deadline: time.Now().Add(window), left: speedUpCap}
start := time.Now()
resp, err := a.speedRequest(ctx, http.MethodPost, streams+"/up", body)
if err != nil {
return res, err
}
@@ -73,6 +84,40 @@ func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error)
return res, nil
}
// speedDown reads what the server streams for the window, counted from the
// first byte, and returns the rate.
func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration) (float64, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
t := time.AfterFunc(speedWait, cancel)
defer t.Stop()
resp, err := a.speedRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var (
n int64
start time.Time
buf = make([]byte, 64<<10)
)
for {
k, err := resp.Body.Read(buf)
if k > 0 && start.IsZero() {
start = time.Now()
t.Reset(window)
}
n += int64(k)
if err != nil {
break
}
}
if start.IsZero() {
return 0, errors.New("нет данных для замера")
}
return bps(n, time.Since(start)), nil
}
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
if err != nil {
+5 -1
View File
@@ -21,7 +21,11 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
case proto.TaskSysUpdate:
res = a.doUpdate()
case proto.TaskSysSpeed:
res = a.doSpeed(ctx)
res = a.doSpeed(ctx, env)
case proto.TaskFSPut:
res = a.doFSPut(ctx, env)
case proto.TaskFSSend:
res = a.doFSSend(ctx, env)
default:
res = a.execTask(env)
}
+19 -1
View File
@@ -20,8 +20,16 @@ type PTY struct {
// `su -l`, which only a root agent can do without a password.
func StartPTY(user string) (*PTY, error) {
var cmd *exec.Cmd
env := append(os.Environ(), "TERM=xterm-256color")
if user == "" || user == currentUser() {
cmd = exec.Command(defaultShell(), "-l")
// A login shell does not change directory by itself: it would start
// where the service runs, "/". Start it at home, and say where home
// is, as a root service often has no $HOME.
if home := homeDir(""); isDir(home) {
cmd.Dir = home
env = append(env, "HOME="+home)
}
} else {
if !validUserName(user) {
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
@@ -37,8 +45,13 @@ func StartPTY(user string) (*PTY, error) {
return nil, fmt.Errorf("su не найден: %w", err)
}
cmd = exec.Command(su, "-l", user)
// su -l goes to the user's home; starting there too keeps the shell
// out of "/" should it not.
if home := homeDir(user); isDir(home) {
cmd.Dir = home
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
cmd.Env = env
f, err := pty.Start(cmd)
if err != nil {
return nil, err
@@ -46,6 +59,11 @@ func StartPTY(user string) (*PTY, error) {
return &PTY{cmd: cmd, f: f}, nil
}
func isDir(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
// Read reads terminal output.
func (p *PTY) Read(b []byte) (int, error) { return p.f.Read(b) }
+29
View File
@@ -85,6 +85,8 @@ const (
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
TaskFSMove = "fs.move" // move/rename
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
TaskFSPut = "fs.put" // write a file the browser sends in pieces
TaskFSSend = "fs.send" // send a file to the browser in pieces
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
@@ -97,6 +99,19 @@ const (
// file be edited only when it fits, so that a save never cuts it short.
const FSReadCap = 5 * 1024 * 1024
// FSFileCap bounds a file sent either way in pieces (TaskFSPut, TaskFSSend).
const FSFileCap = 512 << 20
// FSXferArgs are the arguments of TaskFSPut and TaskFSSend: the file, and the
// transfer on the server whose pieces carry it - GET them for a put, POST them
// for a send, Chunk bytes each. Size is a put's length; -1 for a send.
type FSXferArgs struct {
Path string `json:"path"`
Xfer string `json:"xfer"`
Size int64 `json:"size"`
Chunk int `json:"chunk"`
}
// TaskEnvelope is what the agent fetches for a task id.
type TaskEnvelope struct {
Type string `json:"type"`
@@ -179,10 +194,24 @@ type SysStatResult struct {
DiskFree uint64 `json:"disk_free"`
}
// SysSpeedArgs are the arguments of TaskSysSpeed.
type SysSpeedArgs struct {
// Relay makes the agent use the relayed streams, which the server pipes
// to and from the browser.
Relay bool `json:"relay,omitempty"`
// WindowMs is how long to measure each direction for; 0 leaves it to the
// agent (3 s, all an agent that predates this argument does).
WindowMs int `json:"window_ms,omitempty"`
}
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
// server as the agent measured it. Down is server -> agent.
type SysSpeedResult struct {
PingMs float64 `json:"ping_ms"`
DownBps float64 `json:"down_bps"` // bits per second
UpBps float64 `json:"up_bps"`
Relay bool `json:"relay,omitempty"` // the streams went through the relay
// Partial marks a test the server cut short: the numbers are what it had
// counted on its own side by then.
Partial bool `json:"partial,omitempty"`
}
+24 -9
View File
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
} {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -329,7 +330,8 @@ type ForwardRule struct {
HostPort int
ListenAddr string
Proto string
NoUfw bool // the panel opens no ufw rule for this forward
NoUfw bool // the panel opens no ufw rule for this forward
Label string // optional note shown next to the rule
}
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
@@ -337,9 +339,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
r.Proto = "http"
}
res, err := db.sql.Exec(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, time.Now().Unix())
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
if err != nil {
return 0, err
}
@@ -348,7 +350,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
rows, err := db.sql.Query(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules ORDER BY id`)
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules ORDER BY id`)
if err != nil {
return nil, err
}
@@ -356,7 +358,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
var out []ForwardRule
for rows.Next() {
var r ForwardRule
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw); err != nil {
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label); err != nil {
return nil, err
}
out = append(out, r)
@@ -381,8 +383,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
r := &ForwardRule{}
err := db.sql.QueryRow(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -400,7 +402,20 @@ type UfwRule struct {
ID int64
Port int
Proto string
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. the tunnel port)
RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings
}
// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward
// owns it, so only the admin removes it.
const ufwManual = -1
// Manual reports whether the admin added the rule by hand.
func (r UfwRule) Manual() bool { return r.RuleID == ufwManual }
// SetUfwRuleManual hands a tracked rule over to the admin.
func (db *DB) SetUfwRuleManual(port int, proto string) error {
_, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto)
return err
}
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
+623
View File
@@ -0,0 +1,623 @@
package server
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Files go between the browser and the agent in pieces. Each piece is a
// request of its own, small enough for any proxy in front (Cloudflare takes
// 100 MB a request at most), and one that fails is sent again instead of the
// whole file. Several pieces are in flight at once, so a good link stays as
// busy as it would with one long stream. The server holds only a few pieces
// of a transfer: whoever sends waits while they wait to be taken.
//
// A download keeps one plain response towards the browser, which a proxy
// passes on whatever its size: links, previews and the hex editor read it.
const (
// xferChunk is the size of a piece.
xferChunk = 4 << 20
// xferBuffer is how many pieces of a transfer the server holds.
xferBuffer = 8
// xferWait is how long a request waits for its piece, or for room for it,
// before it is told to ask again: well under a proxy's timeout.
xferWait = 50 * time.Second
// xferIdle ends a transfer nobody has touched for this long.
xferIdle = 10 * time.Minute
// xferTimeout bounds one transfer, however slow the links.
xferTimeout = 2 * time.Hour
)
// fsLegacyCap is the most an agent that predates the pieces can take in or
// hand over: the file goes base64 inside a task then.
const fsLegacyCap = 32 << 20
// xfer is one file on its way.
type xfer struct {
id, agentID string
mu sync.Mutex
changed chan struct{} // closed and replaced on every change
size int64 // -1 until known
n int // pieces in all, once the size is known
chunks map[int][]byte
// low is the first piece still to go: the ones below it were written by
// the agent (upload) or to the browser (download).
low int
sized chan struct{} // closed once the size is known
err string // why the transfer failed
done bool
result proto.TaskResult
last time.Time
}
func pieces(size int64) int {
if size <= 0 {
return 1 // an empty file is one empty piece
}
return int((size + xferChunk - 1) / xferChunk)
}
// pieceLen is how long piece i of the file is.
func (x *xfer) pieceLen(i int) int64 {
return min(int64(xferChunk), x.size-int64(i)*xferChunk)
}
// notify wakes whoever waits on the transfer; x.mu is held.
func (x *xfer) notify() {
x.last = time.Now()
close(x.changed)
x.changed = make(chan struct{})
}
// await waits until ready() holds and returns true with x.mu held; false,
// with it free, when the wait ran out or ctx ended.
func (x *xfer) await(ctx context.Context, d time.Duration, ready func() bool) bool {
t := time.NewTimer(d)
defer t.Stop()
for {
x.mu.Lock()
if ready() {
return true
}
ch := x.changed
x.mu.Unlock()
select {
case <-ch:
case <-t.C:
return false
case <-ctx.Done():
return false
}
}
}
func (x *xfer) fail(msg string) {
x.mu.Lock()
if x.err == "" && !x.done {
x.err = msg
x.chunks = nil
x.notify()
}
x.mu.Unlock()
}
func (x *xfer) finish(res proto.TaskResult) {
x.mu.Lock()
x.done, x.result, x.chunks = true, res, nil
x.notify()
x.mu.Unlock()
}
// setSize records the file's size; false when it differs from the one known.
func (x *xfer) setSize(size int64) bool {
x.mu.Lock()
defer x.mu.Unlock()
if x.size >= 0 {
return x.size == size
}
x.size, x.n = size, pieces(size)
close(x.sized)
x.notify()
return true
}
// put stores piece i once there is room for it, and answers with the HTTP
// status for its sender. A piece already taken or held is accepted again: it
// is a retry.
func (x *xfer) put(ctx context.Context, i int, data []byte) (int, string) {
if !x.await(ctx, xferWait, func() bool { return x.err != "" || x.done || i < x.low+xferBuffer }) {
return http.StatusServiceUnavailable, "нет места, повторите"
}
defer x.mu.Unlock()
if x.err != "" || x.done {
return http.StatusGone, x.gone()
}
if i < 0 || i >= x.n || int64(len(data)) != x.pieceLen(i) {
return http.StatusBadRequest, "не тот кусок"
}
if i >= x.low && x.chunks[i] == nil {
x.chunks[i] = data
x.notify()
}
return http.StatusNoContent, ""
}
// take hands over piece i, the next one in order, and lets it go.
func (x *xfer) take(ctx context.Context, i int, d time.Duration) ([]byte, error) {
if !x.await(ctx, d, func() bool { return x.err != "" || x.chunks[i] != nil }) {
return nil, errors.New("кусок не пришёл вовремя")
}
defer x.mu.Unlock()
if x.err != "" {
return nil, errors.New(x.err)
}
data := x.chunks[i]
delete(x.chunks, i)
x.low = i + 1
x.notify()
return data, nil
}
func (x *xfer) gone() string {
if x.err != "" {
return x.err
}
return "передача закончена"
}
// Read makes an upload a plain reader, for an agent that takes a file whole.
type xferReader struct {
ctx context.Context
x *xfer
i int
buf []byte
}
func (r *xferReader) Read(p []byte) (int, error) {
for len(r.buf) == 0 {
if r.i >= r.x.n {
return 0, io.EOF
}
data, err := r.x.take(r.ctx, r.i, xferIdle)
if err != nil {
return 0, err
}
r.buf, r.i = data, r.i+1
}
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}
type xfers struct {
mu sync.Mutex
m map[string]*xfer
}
func (c *xfers) open(agentID string, size int64) *xfer {
x := &xfer{
id: randToken()[:24], agentID: agentID, size: -1,
changed: make(chan struct{}), chunks: map[int][]byte{},
sized: make(chan struct{}), last: time.Now(),
}
if size >= 0 {
x.size, x.n = size, pieces(size)
close(x.sized)
}
c.mu.Lock()
if c.m == nil {
c.m = map[string]*xfer{}
}
// Forget the ones left behind: a closed tab, a client gone for good.
for id, old := range c.m {
old.mu.Lock()
stale := time.Since(old.last) > xferIdle
old.mu.Unlock()
if stale {
old.fail("передача брошена")
delete(c.m, id)
}
}
c.m[x.id] = x
c.mu.Unlock()
return x
}
func (c *xfers) get(id string) *xfer {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[id]
}
// forAgent returns the transfer if it is the asking agent's.
func (c *xfers) forAgent(id, agentID string) *xfer {
if x := c.get(id); x != nil && x.agentID == agentID {
return x
}
return nil
}
func (c *xfers) drop(id string) {
c.mu.Lock()
delete(c.m, id)
c.mu.Unlock()
}
// waitTask runs a task on the agent and waits for its result, or for ctx to
// end.
func (s *Server) waitTask(ctx context.Context, agentID, typ string, args any, timeout time.Duration) (proto.TaskResult, error) {
raw, _ := json.Marshal(args)
taskID, ch := s.hub.CreateTask(agentID, typ, raw)
defer s.hub.DiscardTask(taskID)
t := time.NewTimer(timeout)
defer t.Stop()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return proto.TaskResult{}, ctx.Err()
case <-t.C:
return proto.TaskResult{}, errors.New("таймаут ожидания агента")
}
}
// unknownOp tells an agent that predates the task.
func unknownOp(res proto.TaskResult) bool {
return !res.OK && strings.Contains(res.Error, "неизвестная операция")
}
// fsAgent answers the request itself and returns false when the agent is
// unknown or offline.
func (s *Server) fsAgent(w http.ResponseWriter, agentID string, asJSON bool) bool {
a, err := s.db.GetAgent(agentID)
if err != nil || a == nil {
http.Error(w, "not found", http.StatusNotFound)
return false
}
if !s.online(a) {
if asJSON {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
} else {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
}
return false
}
return true
}
// readPiece reads a piece's body into a buffer of its own size, so pieces
// leave no trail of grown buffers behind.
func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
if r.ContentLength >= 0 && r.ContentLength <= xferChunk {
data := make([]byte, r.ContentLength)
_, err := io.ReadFull(r.Body, data)
return data, err
}
return io.ReadAll(http.MaxBytesReader(w, r.Body, xferChunk))
}
// ---------- upload: browser -> agent ----------
// POST /api/panel/agents/{id}/fs/upload?path=...&size=N - start writing a
// file. The answer names the transfer and the piece size; the browser then
// PUTs the pieces and asks for the result.
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
if !s.fsAgent(w, agentID, true) {
return
}
size, err := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
if p == "" || err != nil || size < 0 {
writeJSON(w, proto.TaskResult{Error: "не указан путь или размер"})
return
}
if size > proto.FSFileCap {
writeJSON(w, proto.TaskResult{Error: "файл больше 512 МБ"})
return
}
x := s.xfers.open(agentID, size)
go s.runUpload(x, p)
data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk})
writeJSON(w, proto.TaskResult{OK: true, Data: data})
}
// runUpload has the agent write the file and keeps the result for the
// browser to collect.
func (s *Server) runUpload(x *xfer, p string) {
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
defer cancel()
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk}
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout)
if err == nil && unknownOp(res) {
res = s.legacyUpload(ctx, x, p)
}
if err != nil {
res = proto.TaskResult{Error: err.Error()}
}
if !res.OK {
x.fail(res.Error)
}
x.finish(res)
// The result waits a while for the browser to come for it.
time.AfterFunc(xferIdle, func() { s.xfers.drop(x.id) })
}
// legacyUpload writes a file through an agent that predates the pieces.
func (s *Server) legacyUpload(ctx context.Context, x *xfer, p string) proto.TaskResult {
if x.size > fsLegacyCap {
return proto.TaskResult{Error: "клиент принимает файлы до 32 МБ - обновите его"}
}
data, err := io.ReadAll(&xferReader{ctx: ctx, x: x})
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
args := proto.FSWriteArgs{Path: p, Data: base64.StdEncoding.EncodeToString(data)}
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSWrite, args, fsTaskTimeout)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
return res
}
// PUT /api/panel/fs/xfer/{xid}/{i} - piece i of a file being uploaded. 503:
// no room yet, send it again; 410: the transfer is over.
func (s *Server) handleXferPut(w http.ResponseWriter, r *http.Request) {
x := s.xfers.get(r.PathValue("xid"))
i, err := strconv.Atoi(r.PathValue("i"))
if x == nil || err != nil {
http.Error(w, "передача не найдена", http.StatusGone)
return
}
data, err := readPiece(w, r)
if err != nil {
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
return
}
code, msg := x.put(r.Context(), i, data)
if code == http.StatusNoContent {
w.WriteHeader(code)
return
}
http.Error(w, msg, code)
}
// POST /api/panel/fs/xfer/{xid}/cancel - the browser gives the upload up.
func (s *Server) handleXferCancel(w http.ResponseWriter, r *http.Request) {
if x := s.xfers.get(r.PathValue("xid")); x != nil {
x.fail("отменено")
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/panel/fs/xfer/{xid}/result - the upload's result, once the agent
// has written the file; {"done":false} means ask again.
func (s *Server) handleXferResult(w http.ResponseWriter, r *http.Request) {
x := s.xfers.get(r.PathValue("xid"))
if x == nil {
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "передача не найдена"}})
return
}
if !x.await(r.Context(), xferWait, func() bool { return x.done }) {
writeJSON(w, map[string]any{"done": false})
return
}
res := x.result
x.mu.Unlock()
writeJSON(w, map[string]any{"done": true, "result": res})
}
// GET /api/agent/fs/xfer/{xid}/{i}?ack=K - piece i of the upload, for the
// agent to write. K says the agent has written every piece below it, so
// those are let go. 503: not here yet, ask again; 410: the transfer is over.
func (s *Server) handleAgentXferGet(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
i, err := strconv.Atoi(r.PathValue("i"))
if x == nil || err != nil {
http.Error(w, "передача не найдена", http.StatusGone)
return
}
if ack, err := strconv.Atoi(r.URL.Query().Get("ack")); err == nil {
x.mu.Lock()
if ack > x.low {
for k := x.low; k < ack; k++ {
delete(x.chunks, k)
}
x.low = ack
x.notify()
}
x.mu.Unlock()
}
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
return
}
if x.err != "" {
msg := x.err
x.mu.Unlock()
http.Error(w, msg, http.StatusGone)
return
}
data := x.chunks[i]
x.mu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// ---------- download: agent -> browser ----------
// inlineTypes are the files the panel shows in place rather than downloads.
var inlineTypes = map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
".svg": "image/svg+xml",
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg",
".pdf": "application/pdf",
}
// fileHeaders sets what the browser needs to download or show the file. A
// file shown in place runs nothing: no scripts in an SVG, whatever it holds.
// A PDF is the exception, as the browser's viewer will not open in a sandbox;
// it runs in that viewer, not in the panel's page.
func fileHeaders(w http.ResponseWriter, p string, inline bool) {
name := path.Base(p)
ctype := "application/octet-stream"
if t, ok := inlineTypes[strings.ToLower(path.Ext(name))]; ok && inline {
ctype = t
}
disp := "attachment"
if inline {
disp = "inline"
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if ctype != "application/pdf" {
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
}
w.Header().Set("Cache-Control", "no-store")
}
// GET /api/panel/agents/{id}/fs/download?path=...[&inline=1] - the file, as
// one response fed by the pieces the agent sends. inline=1 serves pictures,
// video and sound for the page to show.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
inline := r.URL.Query().Get("inline") != ""
if !s.fsAgent(w, agentID, false) {
return
}
x := s.xfers.open(agentID, -1)
defer s.xfers.drop(x.id)
defer x.fail("браузер ушёл") // stops the agent if the response ends early
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk})
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw)
defer s.hub.DiscardTask(taskID)
// The agent's result: an error ends the transfer wherever it stands.
results := make(chan proto.TaskResult, 1)
go func() {
select {
case res := <-ch:
if !res.OK && !unknownOp(res) {
x.fail(res.Error)
}
results <- res
case <-r.Context().Done():
}
}()
select {
case <-x.sized:
case res := <-results:
if unknownOp(res) {
s.legacyDownload(w, r, agentID, p, inline)
return
}
msg := res.Error
if msg == "" {
msg = "агент не отдал файл"
}
http.Error(w, msg, http.StatusBadGateway)
return
case <-r.Context().Done():
return
case <-time.After(fsTaskTimeout):
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
return
}
fileHeaders(w, p, inline)
w.Header().Set("Content-Length", strconv.FormatInt(x.size, 10))
rc := http.NewResponseController(w)
for i := 0; i < x.n; i++ {
data, err := x.take(r.Context(), i, xferIdle)
if err == nil {
_, err = w.Write(data)
}
if err != nil {
// Cut the response off, so the browser does not take a part of
// the file for all of it.
x.fail(err.Error())
panic(http.ErrAbortHandler)
}
_ = rc.Flush()
}
}
// legacyDownload gets a file from an agent that predates the pieces.
func (s *Server) legacyDownload(w http.ResponseWriter, r *http.Request, agentID, p string, inline bool) {
res, err := s.waitTask(r.Context(), agentID, proto.TaskFSDownload, proto.FSPathArgs{Path: p}, fsTaskTimeout)
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
if !res.OK {
http.Error(w, res.Error, http.StatusBadGateway)
return
}
var rr proto.FSReadResult
if err := json.Unmarshal(res.Data, &rr); err != nil {
http.Error(w, "bad result", http.StatusBadGateway)
return
}
data, _ := base64.StdEncoding.DecodeString(rr.Content)
fileHeaders(w, p, inline)
if rr.Truncated {
w.Header().Set("X-Truncated", "1")
}
_, _ = w.Write(data)
}
// POST /api/agent/fs/xfer/{xid}/{i} - piece i of the file the browser asked
// for, X-Size its whole size. 503: no room yet, send it again; 410: the
// browser is gone.
func (s *Server) handleAgentXferPost(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
i, err := strconv.Atoi(r.PathValue("i"))
if x == nil || err != nil {
http.Error(w, "передача не найдена", http.StatusGone)
return
}
size, err := strconv.ParseInt(r.Header.Get("X-Size"), 10, 64)
if err != nil || size < 0 || size > proto.FSFileCap || !x.setSize(size) {
http.Error(w, "не тот размер", http.StatusBadRequest)
return
}
data, err := readPiece(w, r)
if err != nil {
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
return
}
code, msg := x.put(r.Context(), i, data)
if code == http.StatusNoContent {
w.WriteHeader(code)
return
}
http.Error(w, msg, code)
}
+23
View File
@@ -185,6 +185,7 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
}
_ = s.db.DeleteAgent(id)
s.stats.drop(id)
s.relays.drop(id, nil)
s.hub.Forget(id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -397,6 +398,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"tunnel_port": "Некорректный порт туннеля.",
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
"ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.",
"ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.",
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
}[r.URL.Query().Get("err")],
})
@@ -524,6 +529,24 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleUfwAdd(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
proto := r.FormValue("proto")
if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") {
http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther)
return
}
target := "/settings"
if key := s.ufw.allowManual(port, proto); key != "" {
target += "?err=" + key
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
-44
View File
@@ -1,11 +1,8 @@
package server
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"path"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
@@ -73,44 +70,3 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
}
}
// GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
p := r.URL.Query().Get("path")
a, err := s.db.GetAgent(agentID)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !s.online(a) {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
return
}
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
http.Error(w, res.Error, http.StatusBadGateway)
return
}
var rr proto.FSReadResult
if err := json.Unmarshal(res.Data, &rr); err != nil {
http.Error(w, "bad result", http.StatusBadGateway)
return
}
data, _ := base64.StdEncoding.DecodeString(rr.Content)
name := path.Base(p)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
if rr.Truncated {
w.Header().Set("X-Truncated", "1")
}
_, _ = w.Write(data)
case <-time.After(fsTaskTimeout):
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
}
}
+13 -1
View File
@@ -87,7 +87,7 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
return
}
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind,
NoUfw: r.FormValue("no_ufw") != ""}
NoUfw: r.FormValue("no_ufw") != "", Label: forwardLabel(r.FormValue("label"))}
ruleID, err := s.db.CreateForwardRule(rule)
if err != nil {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
@@ -130,3 +130,15 @@ func (s *Server) removeForward(rid int64) {
_ = s.db.DeleteForwardRule(rid)
s.ufw.revokeForRule(rid)
}
// forwardLabelMax bounds a rule's label, in characters.
const forwardLabelMax = 80
// forwardLabel tidies the optional note of a rule: one line, bounded length.
func forwardLabel(v string) string {
v = strings.Join(strings.Fields(v), " ")
if r := []rune(v); len(r) > forwardLabelMax {
v = string(r[:forwardLabelMax])
}
return v
}
+367 -18
View File
@@ -2,6 +2,7 @@ package server
import (
"crypto/rand"
"encoding/json"
"errors"
"io"
"net/http"
@@ -19,13 +20,20 @@ const (
// statCacheFor is how long one sample answers every open status page, so
// several tabs cost the agent a single reading.
statCacheFor = 2 * time.Second
// speedTaskTimeout covers the agent's ping + down + up windows with slack.
speedTaskTimeout = 30 * time.Second
// speedWindow is how long the agent is asked to measure each direction
// for. An agent that predates the argument keeps to its own 3 s.
speedWindow = 7 * time.Second
// speedTaskTimeout is how long a speed test may take. One that is not done
// by then is answered with what the server has counted itself.
speedTaskTimeout = 35 * time.Second
// speedDownCap bounds one download stream; speedDownFor ends it if the
// agent does not hang up first.
speedDownCap = 256 << 20
speedDownFor = 6 * time.Second
speedDownCap = 1 << 30
speedDownFor = 2 * speedWindow
speedUpCap = 64 << 20
// speedRelayUpFor is how long the agent's upload is passed on to the
// browser: the agent's own window with slack.
speedRelayUpFor = speedWindow + time.Second
)
// statCache holds the last sample per agent and serializes its refresh.
@@ -66,12 +74,12 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
s.render(w, "status.html", map[string]any{"Agent": a})
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
}
// runTask runs a one-shot task on the agent and waits for its result.
func (s *Server) runTask(agentID, typ string, timeout time.Duration) (proto.TaskResult, error) {
taskID, ch := s.hub.CreateTask(agentID, typ, nil)
func (s *Server) runTask(agentID, typ string, args []byte, timeout time.Duration) (proto.TaskResult, error) {
taskID, ch := s.hub.CreateTask(agentID, typ, args)
defer s.hub.DiscardTask(taskID)
t := time.NewTimer(timeout)
defer t.Stop()
@@ -118,7 +126,7 @@ func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
e.mu.Lock()
defer e.mu.Unlock()
if time.Since(e.at) > statCacheFor {
res, err := s.runTask(id, proto.TaskSysStat, statTaskTimeout)
res, err := s.runTask(id, proto.TaskSysStat, nil, statTaskTimeout)
if err != nil {
// Cached too, so pages queued behind this one don't each wait it out.
res = proto.TaskResult{Error: err.Error()}
@@ -129,17 +137,188 @@ func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
}
// POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
// With relay=1 its streams run through the relay opened for it, to and from
// the browser.
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
res, err := s.runTask(id, proto.TaskSysSpeed, speedTaskTimeout)
relay := r.URL.Query().Get("relay") != ""
m := s.meters.start(id)
defer s.meters.stop(id, m)
if relay {
rl := s.relays.get(id)
if rl == nil {
writeJSON(w, proto.TaskResult{Error: "замер через браузер не открыт"})
return
}
defer s.relays.drop(id, rl)
}
args, _ := json.Marshal(proto.SysSpeedArgs{Relay: relay, WindowMs: int(speedWindow.Milliseconds())})
res, err := s.runTask(id, proto.TaskSysSpeed, args, speedTaskTimeout)
if err != nil {
writeJSON(w, proto.TaskResult{Error: err.Error()})
part, ok := m.result()
if !ok {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
part.Relay = relay
data, _ := json.Marshal(part)
res = proto.TaskResult{OK: true, Data: data}
}
res = oldAgentError(res)
if relay && res.OK {
// An agent that predates the relay ignores the argument and measures
// its own link.
var d proto.SysSpeedResult
if json.Unmarshal(res.Data, &d) != nil || !d.Relay {
res = proto.TaskResult{Error: "клиент не умеет замер через браузер - обновите его (нужен 0.0.6+)"}
}
}
writeJSON(w, res)
}
// speedMeter counts what flows through the streams of one speed test as the
// server sees it: for the live reading on the page, and for the result of a
// test that ran out of time.
type speedMeter struct {
mu sync.Mutex
start time.Time
phase string // "ping", "down", "up"
pingAt time.Time
pingMs float64
down speedCount
up speedCount
}
// speedCount is the bytes of one direction and when they went through. The
// first piece only starts the clock.
type speedCount struct {
n, skip int64
first, last time.Time
}
func (c *speedCount) add(n int) {
now := time.Now()
if c.first.IsZero() {
c.first, c.skip = now, int64(n)
}
c.n += int64(n)
c.last = now
}
func (c *speedCount) bps() float64 {
d := c.last.Sub(c.first).Seconds()
if d <= 0 {
return 0
}
return float64(c.n-c.skip) * 8 / d
}
// ping notes one of the agent's ping probes: they come one right after
// another, so the gap between two is its round trip.
func (m *speedMeter) ping() {
if m == nil {
return
}
writeJSON(w, oldAgentError(res))
m.mu.Lock()
now := time.Now()
if !m.pingAt.IsZero() {
if ms := float64(now.Sub(m.pingAt).Microseconds()) / 1000; m.pingMs == 0 || ms < m.pingMs {
m.pingMs = ms
}
}
m.pingAt = now
m.mu.Unlock()
}
func (m *speedMeter) add(up bool, n int) {
if m == nil || n <= 0 {
return
}
m.mu.Lock()
if up {
m.phase = "up"
m.up.add(n)
} else {
m.phase = "down"
m.down.add(n)
}
m.mu.Unlock()
}
// result is the test as counted so far; false with nothing measured yet.
func (m *speedMeter) result() (proto.SysSpeedResult, bool) {
m.mu.Lock()
defer m.mu.Unlock()
res := proto.SysSpeedResult{PingMs: m.pingMs, DownBps: m.down.bps(), UpBps: m.up.bps(), Partial: true}
return res, res.DownBps > 0
}
// meterWriter counts what it passes on; a nil meter counts nothing.
type meterWriter struct {
w io.Writer
m *speedMeter
up bool
}
func (c meterWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.m.add(c.up, n)
return n, err
}
// speedMeters holds the meter of each agent under test.
type speedMeters struct {
mu sync.Mutex
m map[string]*speedMeter
}
func (c *speedMeters) start(agentID string) *speedMeter {
m := &speedMeter{start: time.Now(), phase: "ping"}
c.mu.Lock()
if c.m == nil {
c.m = map[string]*speedMeter{}
}
c.m[agentID] = m
c.mu.Unlock()
return m
}
func (c *speedMeters) get(agentID string) *speedMeter {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[agentID]
}
// stop forgets the agent's meter if it is still m.
func (c *speedMeters) stop(agentID string, m *speedMeter) {
c.mu.Lock()
if c.m[agentID] == m {
delete(c.m, agentID)
}
c.mu.Unlock()
}
// GET /api/panel/agents/{id}/speed/progress - the bytes counted so far in the
// agent's running speed test, for the page to turn into a live rate. An empty
// phase means no test is running.
func (s *Server) handleSpeedProgress(w http.ResponseWriter, r *http.Request) {
var out struct {
Phase string `json:"phase"`
Down int64 `json:"down"`
Up int64 `json:"up"`
Ms int64 `json:"ms"` // since the test began, by the server's clock
}
if m := s.meters.get(r.PathValue("id")); m != nil {
m.mu.Lock()
out.Phase, out.Down, out.Up = m.phase, m.down.n, m.up.n
out.Ms = time.Since(m.start).Milliseconds()
m.mu.Unlock()
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, out)
}
// speedBlock is one chunk of incompressible bytes, made once and sent over and
@@ -150,23 +329,32 @@ var speedBlock = sync.OnceValue(func() []byte {
return b
})
func speedStreamHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
// Keep a proxy in front from buffering or compressing the stream.
w.Header().Set("X-Accel-Buffering", "no")
}
// GET /api/agent/speed/down?bytes=N - stream N bytes (default: as much as fits
// in the time window) for the agent to time. bytes=0 is the ping probe.
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
m := s.meters.get(a.ID)
left := speedDownCap
if v := r.URL.Query().Get("bytes"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
left = n
}
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
// Keep a proxy in front from buffering or compressing the stream.
w.Header().Set("X-Accel-Buffering", "no")
if left == 0 {
m.ping()
}
speedStreamHeaders(w)
block := speedBlock()
deadline := time.Now().Add(speedDownFor)
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
@@ -178,16 +366,177 @@ func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
if err != nil {
return
}
m.add(false, n)
left -= n
}
}
// POST /api/agent/speed/up - swallow the agent's upload.
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_, _ = io.Copy(io.Discard, http.MaxBytesReader(w, r.Body, speedUpCap))
sink := meterWriter{w: io.Discard, m: s.meters.get(a.ID), up: true}
_, _ = io.Copy(sink, http.MaxBytesReader(w, r.Body, speedUpCap))
w.WriteHeader(http.StatusNoContent)
}
// speedRelay joins the agent's speed test streams to the browser's, so the
// bytes travel browser - panel - client. Both pipes are unbuffered: a slow
// reader holds its writer back, and the rate is that of the whole path.
type speedRelay struct {
downR *io.PipeReader // browser -> agent
downW *io.PipeWriter
upR *io.PipeReader // agent -> browser
upW *io.PipeWriter
}
func (rl *speedRelay) close() {
rl.downR.CloseWithError(io.ErrClosedPipe)
rl.downW.CloseWithError(io.ErrClosedPipe)
rl.upR.CloseWithError(io.ErrClosedPipe)
rl.upW.CloseWithError(io.ErrClosedPipe)
}
// speedRelays holds the relay of each agent under test, one per agent.
type speedRelays struct {
mu sync.Mutex
m map[string]*speedRelay
}
// open starts a fresh relay for the agent, ending the one it had.
func (c *speedRelays) open(agentID string) {
rl := &speedRelay{}
rl.downR, rl.downW = io.Pipe()
rl.upR, rl.upW = io.Pipe()
c.mu.Lock()
if c.m == nil {
c.m = map[string]*speedRelay{}
}
old := c.m[agentID]
c.m[agentID] = rl
c.mu.Unlock()
if old != nil {
old.close()
}
}
func (c *speedRelays) get(agentID string) *speedRelay {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[agentID]
}
// drop ends the agent's relay; with rl given, only if it is still that one.
func (c *speedRelays) drop(agentID string, rl *speedRelay) {
c.mu.Lock()
cur := c.m[agentID]
if cur != nil && (rl == nil || rl == cur) {
delete(c.m, agentID)
} else {
cur = nil
}
c.mu.Unlock()
if cur != nil {
cur.close()
}
}
// GET /api/panel/ping - an empty answer for the browser to time.
func (s *Server) handlePanelPing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// POST /api/panel/agents/{id}/speed/relay - open the relay of a speed test
// that runs through the browser. The test itself ends it.
func (s *Server) handleSpeedRelayOpen(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
s.relays.open(id)
writeJSON(w, proto.TaskResult{OK: true})
}
// POST /api/panel/agents/{id}/speed/down - a piece of the browser's upload,
// passed on to the agent. 204 asks for the next piece, 200 says the agent has
// stopped reading.
func (s *Server) handleSpeedRelayFeed(w http.ResponseWriter, r *http.Request) {
body := http.MaxBytesReader(w, r.Body, speedUpCap)
rl := s.relays.get(r.PathValue("id"))
if rl != nil {
if _, err := io.Copy(rl.downW, body); err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
}
// Take the rest of the piece before answering: an answer sent over an
// unread body makes Go drop the connection, which a proxy in front turns
// into a 502.
_, _ = io.Copy(io.Discard, body)
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, "done")
}
// GET /api/panel/agents/{id}/speed/up - the agent's upload, for the browser to
// time. Ends when the agent is done sending.
func (s *Server) handleSpeedRelayDrain(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rl := s.relays.get(id)
if rl == nil {
http.Error(w, "gone", http.StatusGone)
return
}
speedStreamHeaders(w)
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(id), up: true}, rl.upR)
// The browser may have left: don't let the agent keep sending to nobody.
rl.upR.CloseWithError(io.ErrClosedPipe)
}
// GET /api/agent/speed/relay/down - what the browser uploads, for the agent to
// time.
func (s *Server) handleSpeedRelayDown(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rl := s.relays.get(a.ID)
if rl == nil {
http.NotFound(w, r)
return
}
speedStreamHeaders(w)
// The agent waits on the headers, the browser may not have sent a byte yet.
_ = http.NewResponseController(w).Flush()
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(a.ID)}, rl.downR)
// The agent hung up at the end of its window: stop the browser's upload.
rl.downR.CloseWithError(io.ErrClosedPipe)
}
// POST /api/agent/speed/relay/up - the agent's upload, passed on to the browser.
func (s *Server) handleSpeedRelayUp(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rl := s.relays.get(a.ID)
if rl == nil {
http.NotFound(w, r)
return
}
body := http.MaxBytesReader(w, r.Body, speedUpCap)
// A proxy in front may hand over the whole upload at once: pass on only
// what the browser takes in the window, or the agent would wait for the
// rest to trickle through a slower link.
t := time.AfterFunc(speedRelayUpFor, func() { rl.upW.Close() })
_, _ = io.Copy(rl.upW, body)
t.Stop()
rl.upW.Close()
_, _ = io.Copy(io.Discard, body)
w.WriteHeader(http.StatusNoContent)
}
+7
View File
@@ -49,6 +49,9 @@ type panelInMsg struct {
D string `json:"d,omitempty"`
Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,omitempty"`
// Seq numbers the messages of a session from 1; 0 from a page that
// predates it.
Seq uint64 `json:"seq,omitempty"`
}
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
@@ -63,6 +66,10 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
w.WriteHeader(http.StatusNoContent) // a repeat of one already taken
return
}
switch m.T {
case "i":
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
+14
View File
@@ -45,6 +45,7 @@ type Session struct {
mu sync.Mutex
inBuf []proto.InFrame
inSignal chan struct{} // size-1; pulsed when inBuf gains data
inSeq uint64 // number of the last browser message taken
out chan proto.OutMessage // agent -> panel
@@ -250,6 +251,19 @@ func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
// Done returns a channel closed when the session ends.
func (s *Session) Done() <-chan struct{} { return s.closed }
// TakeSeq reports whether the browser message numbered seq is new, and takes
// note of it. The browser sends them one at a time in rising order and sends
// one again when it got no answer, so a number not above the last is a repeat.
func (s *Session) TakeSeq(seq uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if seq <= s.inSeq {
return false
}
s.inSeq = seq
return true
}
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
func (s *Session) PushIn(f proto.InFrame) {
s.mu.Lock()
+51 -2
View File
@@ -1,7 +1,9 @@
package server
import (
"crypto/sha256"
"embed"
"encoding/hex"
"html/template"
"io/fs"
"log"
@@ -53,7 +55,10 @@ type Server struct {
tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64
stats statCache // last load sample per agent, for the status page
stats statCache // last load sample per agent, for the status page
relays speedRelays // speed tests that run through the browser
meters speedMeters // byte counts of the speed tests under way
xfers xfers // files on their way between a browser and an agent
}
// New builds the server, parsing embedded templates.
@@ -181,7 +186,7 @@ func (s *Server) Handler() http.Handler {
// Static assets (xterm.js etc.) from the embedded FS.
staticFS, _ := fs.Sub(webFS, "web/static")
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS)))
// Public: install script + agent binary downloads.
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
@@ -216,6 +221,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
// Panel terminal over HTTP long-polling (admin-only).
@@ -226,12 +232,21 @@ func (s *Server) Handler() http.Handler {
// Panel file manager (admin-only).
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
mux.Handle("POST /api/panel/agents/{id}/fs/upload", s.requireAdmin(s.handleFSUploadStart))
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
// Machine status (admin).
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
mux.Handle("GET /api/panel/agents/{id}/speed/progress", s.requireAdmin(s.handleSpeedProgress))
mux.Handle("GET /api/panel/ping", s.requireAdmin(s.handlePanelPing))
mux.Handle("POST /api/panel/agents/{id}/speed/relay", s.requireAdmin(s.handleSpeedRelayOpen))
mux.Handle("POST /api/panel/agents/{id}/speed/down", s.requireAdmin(s.handleSpeedRelayFeed))
mux.Handle("GET /api/panel/agents/{id}/speed/up", s.requireAdmin(s.handleSpeedRelayDrain))
// Panel port forwarding (admin-only).
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
@@ -247,6 +262,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
return s.logRequests(mux)
}
@@ -316,3 +335,33 @@ func clientIP(r *http.Request) string {
}
return host
}
// staticHandler serves the embedded assets with a content hash as ETag. The
// browser asks each time whether a file changed (no-cache) and gets a 304
// when it did not, so a new release reaches it at once without reloading
// what stayed the same.
func staticHandler(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
var mu sync.Mutex
etags := map[string]string{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
mu.Lock()
tag, ok := etags[name]
mu.Unlock()
if !ok {
if data, err := fs.ReadFile(fsys, name); err == nil {
sum := sha256.Sum256(data)
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
}
mu.Lock()
etags[name] = tag
mu.Unlock()
}
if tag != "" {
w.Header().Set("ETag", tag)
}
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
})
}
+25 -5
View File
@@ -87,26 +87,46 @@ func (u *ufwManager) LastError() string {
}
// allow opens port/proto in ufw and records it, bound to the given forward
// rule id (0 = shared/manual). The caller has checked available().
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
// rule id (0 = shared, ufwManual = added in Settings). It reports whether the
// panel now tracks the rule. The caller has checked available().
func (u *ufwManager) allow(port int, proto string, ruleID int64) bool {
if u.db.HasUfwRule(port, proto) {
return
return true
}
spec := fmt.Sprintf("%d/%s", port, proto)
out, err := ufwRun("allow", spec)
if err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
return
return false
}
u.setErr("")
if strings.Contains(out, "Skipping") {
// The rule was there before the panel asked for it, so it is not ours
// to track (and later delete).
return
return false
}
_ = u.db.AddUfwRule(port, proto, ruleID)
log.Printf("ufw: allowed %s", spec)
return true
}
// allowManual opens a port the admin asked for in Settings. A port the panel
// already opened for a forward or the tunnel becomes the admin's, so it stays
// when they no longer need it. It returns the error key for the settings page
// ("" on success).
func (u *ufwManager) allowManual(port int, proto string) string {
if !u.available() {
return "ufw_off"
}
if !u.allow(port, proto, ufwManual) {
if u.LastError() != "" {
return "ufw_failed"
}
return "ufw_exists"
}
_ = u.db.SetUfwRuleManual(port, proto)
return ""
}
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
+35 -3
View File
@@ -10,6 +10,8 @@
--ok: #37c871;
}
* { box-sizing: border-box; }
/* The hidden attribute wins over any display set by a class. */
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
@@ -60,6 +62,12 @@ button, .btn {
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn.danger { background: var(--danger); }
/* Close buttons: red text and border on the ghost button. */
.btn.close, .btn.ghost.close { background: transparent; border: 1px solid rgba(255, 92, 92, .6); color: var(--danger); }
.btn.close:hover, .btn.ghost.close:hover { background: #3a1d1d; border-color: var(--danger); }
/* A close button that is just a cross: square, the height of the buttons by it. */
.btn.close.x { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
.btn.small.close.x { width: 28px; height: 28px; }
.btn.small { padding: 6px 10px; font-size: 13px; }
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
@@ -94,7 +102,31 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); }
@media (max-width: 600px) {
header.topbar { padding: 12px 16px; }
header.topbar nav a { margin-left: 12px; }
/* Phones. A table marked .stack turns each row into a block of
"label: value" lines, the labels coming from data-l on the cells; a cell
marked .lead goes first, as the row's title. */
@media (max-width: 640px) {
header.topbar { flex-wrap: wrap; gap: 6px 12px; padding: 10px 12px; }
header.topbar .brand { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
header.topbar nav { display: flex; align-items: center; gap: 16px; max-width: 100%;
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
header.topbar nav::-webkit-scrollbar { display: none; }
header.topbar nav > * { margin-left: 0 !important; flex: none; }
.wrap { padding: 16px 12px; }
.card { padding: 16px; border-radius: 8px; }
h1 { font-size: 20px; }
code { word-break: break-all; }
/* 16px and up, or iOS zooms the page into a field on focus. */
input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; }
table.stack thead { display: none; }
table.stack, table.stack tbody { display: block; }
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
table.stack tr:first-child { padding-top: 0; }
table.stack tr:last-child { border-bottom: 0; padding-bottom: 0; }
table.stack td { display: flex; justify-content: flex-start; align-items: center; gap: 0;
width: auto; padding: 2px 0; border: 0; text-align: right !important; overflow-wrap: anywhere; }
table.stack td[data-l]::before { content: attr(data-l); flex: none; margin-right: auto; padding-right: 12px; color: var(--muted); font-size: 13px; text-align: left; }
table.stack td.lead { order: -1; justify-content: flex-start; text-align: left !important; font-size: 16px; font-weight: 600; }
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
table.stack td:empty { display: none; }
}
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1 @@
.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:contentLine truncated
@@ -0,0 +1,27 @@
/* CodeMirror 5.65.16 (MIT, see LICENSE) with the addons and language modes the file editor uses, joined into one file. */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2<navigator.maxTouchPoints),$=/Android/.test(e),_=Y||$||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),C=Y||/Mac/.test(l),q=/\bCrOS\b/.test(e),a=/win/i.test(l),u=K&&e.match(/Version\/(\d*\.\d*)/),Z=((u=u&&Number(u[1]))&&15<=u&&(x=!(K=!1)),C&&(s||K&&(null==u||u<12.11))),Q=d||w&&9<=v;function J(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var ee=function(e,t){var n,r=e.className,t=J(t).exec(r);t&&(n=r.slice(t.index+t[0].length),e.className=r.slice(0,t.index)+(n?t[1]+n:""))};function te(e){for(var t=e.childNodes.length;0<t;--t)e.removeChild(e.firstChild);return e}function y(e,t){return te(e).appendChild(t)}function M(e,t,n,r){var i=document.createElement(e);if(n&&(i.className=n),r&&(i.style.cssText=r),"string"==typeof t)i.appendChild(document.createTextNode(t));else if(t)for(var o=0;o<t.length;++o)i.appendChild(t[o]);return i}function ne(e,t,n,r){e=M(e,t,n,r);return e.setAttribute("role","presentation"),e}function re(e,t){if(3==t.nodeType&&(t=t.parentNode),e.contains)return e.contains(t);do{if((t=11==t.nodeType?t.host:t)==e)return!0}while(t=t.parentNode)}function N(e){var t,n=e.ownerDocument||e;try{t=e.activeElement}catch(e){t=n.body||null}for(;t&&t.shadowRoot&&t.shadowRoot.activeElement;)t=t.shadowRoot.activeElement;return t}function ie(e,t){var n=e.className;J(t).test(n)||(e.className+=(n?" ":"")+t)}function oe(e,t){for(var n=e.split(" "),r=0;r<n.length;r++)n[r]&&!J(n[r]).test(t)&&(t+=" "+n[r]);return t}var le=document.createRange?function(e,t,n,r){var i=document.createRange();return i.setEnd(r||e,n),i.setStart(e,t),i}:function(e,t,n){var r=document.body.createTextRange();try{r.moveToElementText(e.parentNode)}catch(e){return r}return r.collapse(!0),r.moveEnd("character",n),r.moveStart("character",t),r},se=function(e){e.select()};function ae(e){return e.display.wrapper.ownerDocument}function ue(e){return ce(e.display.wrapper)}function ce(e){return e.getRootNode?e.getRootNode():e.ownerDocument}function he(e){return ae(e).defaultView}function de(e){var t=Array.prototype.slice.call(arguments,1);return function(){return e.apply(null,t)}}function fe(e,t,n){for(var r in t=t||{},e)!e.hasOwnProperty(r)||!1===n&&t.hasOwnProperty(r)||(t[r]=e[r]);return t}function S(e,t,n,r,i){null==t&&-1==(t=e.search(/[^\s\u00a0]/))&&(t=e.length);for(var o=r||0,l=i||0;;){var s=e.indexOf("\t",o);if(s<0||t<=s)return l+(t-o);l=(l+=s-o)+(n-l%n),o=s+1}}Y?se=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:w&&(se=function(e){try{e.select()}catch(e){}});function pe(){this.id=null,this.f=null,this.time=0,this.handler=de(this.onTimeout,this)}function L(e,t){for(var n=0;n<e.length;++n)if(e[n]==t)return n;return-1}pe.prototype.onTimeout=function(e){e.id=0,e.time<=+new Date?e.f():setTimeout(e.handler,e.time-+new Date)};var ge=50,me={toString:function(){return"CodeMirror.Pass"}},ve={scroll:!(pe.prototype.set=function(e,t){this.f=t;t=+new Date+e;(!this.id||t<this.time)&&(clearTimeout(this.id),this.id=setTimeout(this.handler,e),this.time=t)})},ye={origin:"*mouse"},be={origin:"+move"};function we(e,t,n){for(var r=0,i=0;;){var o=e.indexOf("\t",r),l=(o=-1==o?e.length:o)-r;if(o==e.length||t<=i+l)return r+Math.min(l,t-i);if(i+=o-r,r=o+1,t<=(i+=n-i%n))return r}}var xe=[""];function Ce(e){for(;xe.length<=e;)xe.push(z(xe)+" ");return xe[e]}function z(e){return e[e.length-1]}function Se(e,t){for(var n=[],r=0;r<e.length;r++)n[r]=t(e[r],r);return n}function Le(){}function ke(e,t){e=Object.create?Object.create(e):(Le.prototype=e,new Le);return t&&fe(t,e),e}var Te=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;function Me(e){return/\w/.test(e)||"€"<e&&(e.toUpperCase()!=e.toLowerCase()||Te.test(e))}function Ne(e,t){return t?!!(-1<t.source.indexOf("\\w")&&Me(e))||t.test(e):Me(e)}function Oe(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return;return 1}var Ae=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0acLine truncated
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){"use strict";var p,v,x=r.Pos;function m(t,e){for(var n,i=null!=(i=(n=t).flags)?i:(n.ignoreCase?"i":"")+(n.global?"g":"")+(n.multiline?"m":""),r=i,o=0;o<e.length;o++)-1==r.indexOf(e.charAt(o))&&(r+=e.charAt(o));return i==r?t:new RegExp(t.source,r)}function d(t){return/\\s|\\n|\n|\\W|\\D|\[\^/.test(t.source)}function a(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.lastLine();i<=o;i++,r=0){e.lastIndex=r;var h=t.getLine(i),h=e.exec(h);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function o(t,e,n){if(!d(e))return a(t,e,n);e=m(e,"gm");for(var i=1,r=n.line,o=t.lastLine();r<=o;){for(var h=0;h<i&&!(o<r);h++)var l=t.getLine(r++),c=null==c?l:c+"\n"+l;i*=2,e.lastIndex=n.ch;var s,f,g,u=e.exec(c);if(u)return g=c.slice(0,u.index).split("\n"),s=u[0].split("\n"),f=n.line+g.length-1,g=g[g.length-1].length,{from:x(f,g),to:x(f+s.length-1,1==s.length?g+s[0].length:s[s.length-1].length),match:u}}}function L(t,e,n){for(var i,r=0;r<=t.length;){e.lastIndex=r;var o=e.exec(t);if(!o)break;var h=o.index+o[0].length;if(h>t.length-n)break;(!i||h>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function O(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.firstLine();o<=i;i--,r=-1){var h=t.getLine(i),h=L(h,e,r<0?0:h.length-r);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function h(t,e,n){if(!d(e))return O(t,e,n);e=m(e,"gm");for(var i=1,r=t.getLine(n.line).length-n.ch,o=n.line,h=t.firstLine();h<=o;){for(var l=0;l<i&&h<=o;l++)var c=t.getLine(o--),s=null==s?c:c+"\n"+s;i*=2;var f,g,u,a=L(s,e,r);if(a)return u=s.slice(0,a.index).split("\n"),f=a[0].split("\n"),g=o+u.length,u=u[u.length-1].length,{from:x(g,u),to:x(g+f.length-1,1==f.length?u+f[0].length:f[f.length-1].length),match:a}}}function y(t,e,n,i){if(t.length==e.length)return n;for(var r=0,o=n+Math.max(0,t.length-e.length);;){if(r==o)return r;var h=r+o>>1,l=i(t.slice(0,h)).length;if(l==n)return h;n<l?o=h:r=1+h}}function l(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.lastLine()+1-o.length;h<=c;h++,l=0){var s=t.getLine(h).slice(l),f=r(s);if(1==o.length){var g=f.indexOf(o[0]);if(-1==g);else return n=y(s,f,g,r)+l,{from:x(h,y(s,f,g,r)+l),to:x(h,y(s,f,g+o[0].length,r)+l)}}else{g=f.length-o[0].length;if(f.slice(g)==o[0]){for(var u=1;u<o.length-1;u++)if(r(t.getLine(h+u))!=o[u])continue t;var a=t.getLine(h+o.length-1),m=r(a),d=o[o.length-1];if(m.slice(0,d.length)==d)return{from:x(h,y(s,f,g,r)+l),to:x(h+o.length-1,y(a,m,d.length,r))}}}}}function c(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.firstLine()-1+o.length;c<=h;h--,l=-1){var s=t.getLine(h),f=r(s=-1<l?s.slice(0,l):s);if(1==o.length){var g=f.lastIndexOf(o[0]);if(-1!=g)return{from:x(h,y(s,f,g,r)),to:x(h,y(s,f,g+o[0].length,r))}}else{g=o[o.length-1];if(f.slice(0,g.length)==g){for(var u=1,n=h-o.length+1;u<o.length-1;u++)if(r(t.getLine(n+u))!=o[u])continue t;var a=t.getLine(h+1-o.length),m=r(a);if(m.slice(m.length-o[0].length)==o[0])return{from:x(h+1-o.length,y(a,m,a.length-o[0].length,r)),to:x(h,y(s,f,g.length,r))}}}}}function i(n,i,t,e){var r;this.atOccurrence=!1,this.afterEmptyMatch=!1,this.doc=n,t=t?n.clipPos(t):x(0,0),this.pos={from:t,to:t},"object"==typeof e?r=e.caseFold:(r=e,e=null),"string"==typeof i?(null==r&&(r=!1),this.matches=function(t,e){return(t?c:l)(n,i,e,r)}):(i=m(i,"gm"),e&&!1===e.multiline?this.matches=function(t,e){return(t?O:a)(n,i,e)}:this.matches=function(t,e){return(t?h:o)(n,i,e)})}v=String.prototype.normalize?(p=function(t){return t.normalize("NFD").toLowerCase()},function(t){return t.normalize("NFD")}):(p=function(t){return t.toLowerCase()},function(t){return t}),i.prototype={findNext:function(){return this.find(!1)},findPrevious:function(){return this.find(!0)},find:function(t){var e=this.doc.clipPos(t?this.pos.from:this.pos.to);if(this.afterEmptyMatch&&this.atOccurrence&&(e=x(e.line,e.ch),t?(e.ch--,e.ch<0&&(e.line--,e.ch=(this.doc.getLine(e.line)||"").length)):(e.ch++,e.ch>(this.doc.getLine(e.line)||"").length&&(e.ch=0,e.line++)),0!=r.cmpPos(e,this.doc.clipPos(e))))return this.atOccurrence=!1;var e=this.matches(t,e);return this.afterEmptyMatch=e&&0==r.cmpPos(e.from,e.to),e?(this.pos=e,this.atOccurrence=!0,this.pos.match||!0):(e=x(t?this.doc.firstLine():this.doc.lastLine()+1,0),this.pos={from:e,to:e},this.atOccurrence=!1)},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,e){this.atOccurrence&&(t=r.splitLines(t),this.doc.replaceRange(t,this.pos.from,this.pos.to,e),this.pos.to=x(this.pos.from.line+t.length-1,t[t.length-1].length+(1==t.length?this.pos.from.ch:0)))}},r.defineExtension("getSearchCursor",function(t,e,n){return new i(this.doc,t,e,n)}),r.defineDocExtension("getSearchCursor",function(t,Line truncated
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),t=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==t?null:{from:k(e.line,i),to:t&&t.pos,match:t&&t.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=!n&&"boolean"!=typeof e?e:n?(n.strict=e,n):e?{strict:!0}:null)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});;
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(y){"use strict";var C={autoSelfClosers:{area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},implicitlyClosed:{dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},contextGrabbers:{dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}},doNotIndent:{pre:!0},allowUnquoted:!0,allowMissing:!0,caseFold:!0},z={autoSelfClosers:{},implicitlyClosed:{},contextGrabbers:{},doNotIndent:{},allowUnquoted:!1,allowMissing:!1,allowMissingTagName:!1,caseFold:!1};y.defineMode("xml",function(t,e){var n,i,a,l=t.indentUnit,u={},r=e.htmlMode?C:z;for(n in r)u[n]=r[n];for(n in e)u[n]=e[n];function c(e,n){function t(t){return(n.tokenize=t)(e,n)}var r=e.next();return"<"==r?e.eat("!")?e.eat("[")?e.match("CDATA[")?t(o("atom","]]>")):null:e.match("--")?t(o("comment","--\x3e")):e.match("DOCTYPE",!0,!0)?(e.eatWhile(/[\w\._\-]/),t(function r(o){return function(t,e){for(var n;null!=(n=t.next());){if("<"==n)return e.tokenize=r(o+1),e.tokenize(t,e);if(">"==n){if(1!=o)return e.tokenize=r(o-1),e.tokenize(t,e);e.tokenize=c;break}}return"meta"}}(1))):null:e.eat("?")?(e.eatWhile(/[\w\._\-]/),n.tokenize=o("meta","?>"),"meta"):(i=e.eat("/")?"closeTag":"openTag",n.tokenize=d,"tag bracket"):"&"==r?(e.eat("#")?e.eat("x")?e.eatWhile(/[a-fA-F\d]/)&&e.eat(";"):e.eatWhile(/[\d]/)&&e.eat(";"):e.eatWhile(/[\w\.\-:]/)&&e.eat(";"))?"atom":"error":(e.eatWhile(/[^&<]/),null)}function d(t,e){var n,r,o=t.next();return">"==o||"/"==o&&t.eat(">")?(e.tokenize=c,i=">"==o?"endTag":"selfcloseTag","tag bracket"):"="==o?(i="equals",null):"<"==o?(e.tokenize=c,e.state=p,e.tagName=e.tagStart=null,(n=e.tokenize(t,e))?n+" tag error":"tag error"):/[\'\"]/.test(o)?(e.tokenize=(r=o,a.isInAttribute=!0,a),e.stringStartCol=t.column(),e.tokenize(t,e)):(t.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/),"word");function a(t,e){for(;!t.eol();)if(t.next()==r){e.tokenize=d;break}return"string"}}function o(n,r){return function(t,e){for(;!t.eol();){if(t.match(r)){e.tokenize=c;break}t.next()}return n}}function s(t){return t&&t.toLowerCase()}function f(t,e,n){this.prev=t.context,this.tagName=e||"",this.indent=t.indented,this.startOfLine=n,(u.doNotIndent.hasOwnProperty(e)||t.context&&t.context.noIndent)&&(this.noIndent=!0)}function m(t){t.context&&(t.context=t.context.prev)}function g(t,e){for(var n;;){if(!t.context)return;if(n=t.context.tagName,!u.contextGrabbers.hasOwnProperty(s(n))||!u.contextGrabbers[s(n)].hasOwnProperty(s(e)))return;m(t)}}function p(t,e,n){return"openTag"==t?(n.tagStart=e.column(),h):"closeTag"==t?x:p}function h(t,e,n){return"word"==t?(n.tagName=e.current(),a="tag",w):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",w(t,0,n)):(a="error",h)}function x(t,e,n){var r;return"word"==t?(r=e.current(),n.context&&n.context.tagName!=r&&u.implicitlyClosed.hasOwnProperty(s(n.context.tagName))&&m(n),n.context&&n.context.tagName==r||!1===u.matchClosing?(a="tag",b):(a="tag error",k)):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",b(t,0,n)):(a="error",k)}function b(t,e,n){return"endTag"!=t?(a="error",b):(m(n),p)}function k(t,e,n){return a="error",b(t,0,n)}function w(t,e,n){return"word"==t?(a="attribute",T):"endTag"==t||"selfcloseTag"==t?(r=n.tagName,o=n.tagStart,n.tagName=n.tagStart=null,"selfcloseTag"==t||u.autoSelfClosers.hasOwnProperty(s(r))?g(n,r):(g(n,r),n.context=new f(n,r,o==n.indented)),p):(a="error",w);var r,o}function T(t,e,n){return"equals"==t?v:(u.allowMissing||(a="error"),w(t,0,n))}function v(t,e,n){return"string"==t?N:"word"==t&&u.allowUnquoted?(a="string",w):(a="error",w(t,0,n))}function N(t,e,n){return"string"==t?N:w(t,0,n)}return c.isInText=!0,{startState:function(t){var e={tokenize:c,state:p,indented:t||0,tagName:null,tagStart:null,context:null};return null!=t&&(e.baseIndent=t),e},token:function(t,e){if(!e.tagName&&t.sol()&&(e.indented=t.indentation()),t.eatSpace())return null;i=null;var n=e.tokenize(t,e);return(n||i)&&"comment"!=n&&(a=null,e.state=e.state(i||n,t,e),a&&(n="error"==a?n+" error":a)),n},indent:function(t,e,n){var r=t.context;if(t.tokenize.isInAttribute)return t.tagStart==t.indented?t.stringStartCol+1:t.indented+l;if(r&&r.noIndent)return y.Pass;if(t.tokenize!=d&&t.tokenize!=c)return n?n.match(/^(\s*)/)[0].length:0;if(t.tagName)return!1!==u.multilineTagIndentPastTag?t.tagStart+t.tagName.length+2:t.tagStart+l*(u.multilinLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(rt){"use strict";rt.defineMode("javascript",function(e,l){var t,r,O,P,f=e.indentUnit,N=l.statementIndent,U=l.jsonld,o=l.json||U,W=!1!==l.trackScope,u=l.typescript,B=l.wordCharacters||/[\w$\xa1-\uffff]/,F=(e=n("keyword a"),t=n("keyword b"),r=n("keyword c"),O=n("keyword d"),P=n("operator"),{if:n("if"),while:e,with:e,else:t,do:t,try:t,finally:t,return:O,break:O,continue:O,new:n("new"),delete:r,void:r,throw:r,debugger:n("debugger"),var:n("var"),const:n("var"),let:n("var"),function:n("function"),catch:n("catch"),for:n("for"),switch:n("switch"),case:n("case"),default:n("default"),in:P,typeof:P,instanceof:P,true:e={type:"atom",style:"atom"},false:e,null:e,undefined:e,NaN:e,Infinity:e,this:n("this"),class:n("class"),super:n("atom"),yield:r,export:n("export"),import:n("import"),extends:r,await:r});function n(e){return{type:e,style:"keyword"}}var H,D,G=/[+\-*&%=<>!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.varLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(T){"use strict";function e(e){for(var t={},r=0;r<e.length;++r)t[e[r].toLowerCase()]=!0;return t}T.defineMode("css",function(e,t){var a,i,r=t.inline,n=(t.propertyKeywords||(t=T.resolveMode("text/css")),e.indentUnit),l=t.tokenHooks,o=t.documentTypes||{},s=t.mediaTypes||{},c=t.mediaFeatures||{},d=t.mediaValueKeywords||{},p=t.propertyKeywords||{},u=t.nonStandardPropertyKeywords||{},m=t.fontProperties||{},g=t.counterDescriptors||{},b=t.colorKeywords||{},h=t.valueKeywords||{},f=t.allowNested,k=t.lineComment,y=!0===t.supportsAtComponent,w=!1!==e.highlightNonStandardPropertyKeywords;function v(e,t){return a=t,e}function x(i){return function(e,t){for(var r,o=!1;null!=(r=e.next());){if(r==i&&!o){")"==i&&e.backUp(1);break}o=!o&&"\\"==r}return r!=i&&(o||")"==i)||(t.tokenize=null),a="string"}}function z(e,t){return e.next(),e.match(/^\s*[\"\')]/,!1)?t.tokenize=null:t.tokenize=x(")"),a="(",null}function j(e,t,r){this.type=e,this.indent=t,this.prev=r}function P(e,t,r,o){return e.context=new j(r,t.indentation()+(!1===o?0:n),e.context),r}function K(e){return e.context.prev&&(e.context=e.context.prev),e.context.type}function q(e,t,r){return _[r.context.type](e,t,r)}function C(e,t,r,o){for(var i=o||1;0<i;i--)r.context=r.context.prev;return q(e,t,r)}function B(e){e=e.current().toLowerCase();i=h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"variable"}var _={top:function(e,t,r){if("{"==e)return P(r,t,"block");if("}"==e&&r.context.prev)return K(r);if(y&&/@component/i.test(e))return P(r,t,"atComponentBlock");if(/^@(-moz-)?document$/i.test(e))return P(r,t,"documentTypes");if(/^@(media|supports|(-moz-)?document|import)$/i.test(e))return P(r,t,"atBlock");if(/^@(font-face|counter-style)/i.test(e))return r.stateArg=e,"restricted_atBlock_before";if(/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(e))return"keyframes";if(e&&"@"==e.charAt(0))return P(r,t,"at");if("hash"==e)i="builtin";else if("word"==e)i="tag";else{if("variable-definition"==e)return"maybeprop";if("interpolation"==e)return P(r,t,"interpolation");if(":"==e)return"pseudo";if(f&&"("==e)return P(r,t,"parens")}return r.context.type},block:function(e,t,r){var o;return"word"==e?(o=t.current().toLowerCase(),p.hasOwnProperty(o)?(i="property","maybeprop"):u.hasOwnProperty(o)?(i=w?"string-2":"property","maybeprop"):f?(i=t.match(/^\s*:(?:\s|$)/,!1)?"property":"tag","block"):(i+=" error","maybeprop")):"meta"==e?"block":f||"hash"!=e&&"qualifier"!=e?_.top(e,t,r):(i="error","block")},maybeprop:function(e,t,r){return":"==e?P(r,t,"prop"):q(e,t,r)},prop:function(e,t,r){if(";"==e)return K(r);if("{"==e&&f)return P(r,t,"propBlock");if("}"==e||"{"==e)return C(e,t,r);if("("==e)return P(r,t,"parens");if("hash"!=e||/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t.current())){if("word"==e)B(t);else if("interpolation"==e)return P(r,t,"interpolation")}else i+=" error";return"prop"},propBlock:function(e,t,r){return"}"==e?K(r):"word"==e?(i="property","maybeprop"):r.context.type},parens:function(e,t,r){return"{"==e||"}"==e?C(e,t,r):")"==e?K(r):"("==e?P(r,t,"parens"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&B(t),"parens")},pseudo:function(e,t,r){return"meta"==e?"pseudo":"word"==e?(i="variable-3",r.context.type):q(e,t,r)},documentTypes:function(e,t,r){return"word"==e&&o.hasOwnProperty(t.current())?(i="tag",r.context.type):_.atBlock(e,t,r)},atBlock:function(e,t,r){return"("==e?P(r,t,"atBlock_parens"):"}"==e||";"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&(e=t.current().toLowerCase(),i="only"==e||"not"==e||"and"==e||"or"==e?"keyword":s.hasOwnProperty(e)?"attribute":c.hasOwnProperty(e)?"property":d.hasOwnProperty(e)?"keyword":p.hasOwnProperty(e)?"property":u.hasOwnProperty(e)?w?"string-2":"property":h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"error"),r.context.type)},atComponentBlock:function(e,t,r){return"}"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top",!1):("word"==e&&(i="error"),r.context.type)},atBlock_parens:function(e,t,r){return")"==e?K(r):"{"==e||"}"==e?C(e,t,r,2):_.atBlock(e,t,r)},restricted_atBlock_before:function(e,t,r){return"{"==e?P(r,t,"restricted_atBlock"):"word"==e&&"@counter-style"==r.stateArg?(i="variable","restricted_atBlock_before"):q(e,t,r)},restricted_atBlock:function(e,t,r){return"}"==e?(r.stateArg=null,K(r)):"word"==e?(i="@font-face"==r.stateArg&&!m.hasOwnProperty(t.current().toLowerCase())||"@counter-style"==r.stateArg&&!g.hasOwnProperty(t.current().toLowerCase())?"error":"property","maybeprop"):"restricted_atBlock"},keyframes:function(e,t,r){return"word"==e?(i="variable","keyframes"):"{"==e?P(r,t,"top"):q(e,t,r)},at:function(e,t,r){return";"==e?K(r):"{"==e||"}"==e?C(e,t,r):("word"==e?i="tag":"hash"==e&&(i="builtin"),"at")},interpolation:function(e,t,r){return"}"==e?K(r):"{"==e||";"==e?C(e,tLine truncated
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(M){"use strict";function L(e,t,n,r,o,a){this.indented=e,this.column=t,this.type=n,this.info=r,this.align=o,this.prev=a}function D(e,t,n,r){var o=e.indented;e.context&&"statement"==e.context.type&&"statement"!=n&&(o=e.context.indented),e.context=new L(o,t,n,r,null,e.context)}function P(e){var t=e.context.type;return")"!=t&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev}function E(e,t,n){return"variable"==t.prevToken||"type"==t.prevToken||(!!/\S(?:[^- ]>|[*\]])\s*$|\*$/.test(e.string.slice(0,n))||(!(!t.typeAtEndOfLine||e.column()!=e.indentation())||void 0))}function z(e){for(;;){if(!e||"top"==e.type)return 1;if("}"==e.type&&"namespace"!=e.prev.info)return;e=e.prev}}function e(e){for(var t={},n=e.split(" "),r=0;r<n.length;++r)t[n[r]]=!0;return t}function F(e,t){return"function"==typeof e?e(t):e.propertyIsEnumerable(t)}M.defineMode("clike",function(e,a){var i,l,s=e.indentUnit,c=a.statementIndentUnit||s,u=a.dontAlignCalls,o=a.keywords||{},d=a.types||{},f=a.builtin||{},p=a.blockKeywords||{},m=a.defKeywords||{},h=a.atoms||{},y=a.hooks||{},g=a.multiLineStrings,k=!1!==a.indentStatements,e=!1!==a.indentSwitch,x=a.namespaceSeparator,b=a.isPunctuationChar||/[\[\]{}\(\),;\:\.]/,v=a.numberStart||/[\d\.]/,w=a.number||/^(?:0x[a-f\d]+|0b[01]+|(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(u|ll?|l|f)?/i,_=a.isOperatorChar||/[+\-*&%=<>!?|\/]/,S=a.isIdentifierChar||/[\w\$_\xa1-\uffff]/,T=a.isReservedIdentifier||!1;function C(e,t){var a,n=e.next();if(y[n]){var r=y[n](e,t);if(!1!==r)return r}if('"'==n||"'"==n)return t.tokenize=(a=n,function(e,t){for(var n,r=!1,o=!1;null!=(n=e.next());){if(n==a&&!r){o=!0;break}r=!r&&"\\"==n}return!o&&(r||g)||(t.tokenize=null),"string"}),t.tokenize(e,t);if(v.test(n)){if(e.backUp(1),e.match(w))return"number";e.next()}if(b.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=I)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(_.test(n)){for(;!e.match(/^\/[\/*]/,!1)&&e.eat(_););return"operator"}if(e.eatWhile(S),x)for(;e.match(x);)e.eatWhile(S);r=e.current();return F(o,r)?(F(p,r)&&(i="newstatement"),F(m,r)&&(l=!0),"keyword"):F(d,r)?"type":F(f,r)||T&&T(r)?(F(p,r)&&(i="newstatement"),"builtin"):F(h,r)?"atom":"variable"}function I(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=null;break}r="*"==n}return"comment"}function N(e,t){a.typeFirstDefinitions&&e.eol()&&z(t.context)&&(t.typeAtEndOfLine=E(e,t,e.pos))}return{startState:function(e){return{tokenize:null,context:new L((e||0)-s,0,"top",null,!1),indented:0,startOfLine:!0,prevToken:null}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0),e.eatSpace())return N(e,t),null;i=l=null;var r,o=(t.tokenize||C)(e,t);if("comment"==o||"meta"==o)return o;if(null==n.align&&(n.align=!0),";"==i||":"==i||","==i&&e.match(/^\s*(?:\/\/.*)?$/,!1))for(;"statement"==t.context.type;)P(t);else if("{"==i)D(t,e.column(),"}");else if("["==i)D(t,e.column(),"]");else if("("==i)D(t,e.column(),")");else if("}"==i){for(;"statement"==n.type;)n=P(t);for("}"==n.type&&(n=P(t));"statement"==n.type;)n=P(t)}else i==n.type?P(t):k&&(("}"==n.type||"top"==n.type)&&";"!=i||"statement"==n.type&&"newstatement"==i)&&D(t,e.column(),"statement",e.current());return"variable"==o&&("def"==t.prevToken||a.typeFirstDefinitions&&E(e,t,e.start)&&z(t.context)&&e.match(/^\s*\(/,!1))&&(o="def"),"def"==(o=y.token&&void 0!==(r=y.token(e,t,o))?r:o)&&!1===a.styleDefs&&(o="variable"),t.startOfLine=!1,t.prevToken=l?"def":o||i,N(e,t),o},indent:function(e,t){if(e.tokenize!=C&&null!=e.tokenize||e.typeAtEndOfLine&&z(e.context))return M.Pass;var n=e.context,r=t&&t.charAt(0),o=r==n.type;if("statement"==n.type&&"}"==r&&(n=n.prev),a.dontIndentStatements)for(;"statement"==n.type&&a.dontIndentStatements.test(n.info);)n=n.prev;if(y.indent){e=y.indent(e,n,t,s);if("number"==typeof e)return e}e=n.prev&&"switch"==n.prev.info;if(a.allmanIndentation&&/[{(]/.test(r)){for(;"top"!=n.type&&"}"!=n.type;)n=n.prev;return n.indented}return"statement"==n.type?n.indented+("{"==r?0:c):!n.align||u&&")"==n.type?")"!=n.type||o?n.indented+(o?0:s)+(o||!e||/^(?:case|default)\b/.test(t)?0:s):n.indented+c:n.column+(o?0:1)},electricInput:e?/^\s*(?:case .*?:|default:|\{\}?|\})$/:/^\s*[{}]$/,blockCommentStart:"/*",blockCommentEnd:"*/",blockCommentContinue:" * ",lineComment:"//",fold:"brace"}});var t="auto if break case register continue return default do sizeof static else struct switch extern typedef union for goto while enum const volatile inline restrict asm fortran",n="alignas alignof and and_eq audit axiom bitand bitor catch class compl concept constexpr const_cast decltype delete dynamic_cast explicit export final friend import module mutable namespace new noexcept not not_eq operator or or_eq override private protected public reinterpret_cast requLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../htmlmixed/htmlmixed"),require("../clike/clike")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../htmlmixed/htmlmixed","../clike/clike"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t={},_=e.split(" "),r=0;r<_.length;++r)t[_[r]]=!0;return t}function o(s,i,l){return 0==s.length?n(i):function(e,t){for(var _=s[0],r=0;r<_.length;r++)if(e.match(_[r][0]))return t.tokenize=o(s.slice(1),i),_[r][1];return t.tokenize=n(i,l),"string"}}function n(n,a){return function(e,t){var _=e,r=t,s=n,i=a;if(!1!==i&&_.match("${",!1)||_.match("{$",!1))return r.tokenize=null,"string";if(!1!==i&&_.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/))return _.match("[",!1)&&(r.tokenize=o([[["[",null]],[[/\d[\w\.]*/,"number"],[/\$[a-zA-Z_][a-zA-Z0-9_]*/,"variable-2"],[/[\w\$]+/,"variable"]],[["]",null]]],s,i)),_.match(/^->\w/,!1)&&(r.tokenize=o([[["->",null]],[[/[\w]+/,"variable"]]],s,i)),"variable-2";for(var l=!1;!_.eol()&&(l||!1===i||!_.match("{$",!1)&&!_.match(/^(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{)/,!1));){if(!l&&_.match(s)){r.tokenize=null,r.tokStack.pop(),r.tokStack.pop();break}l="\\"==_.next()&&!l}return"string"}}var t="abstract and array as break case catch class clone const continue declare default do else elseif enddeclare endfor endforeach endif endswitch endwhile enum extends final for foreach function global goto if implements interface instanceof namespace new or private protected public static switch throw trait try use var while xor die echo empty exit eval include include_once isset list require require_once return print unset __halt_compiler self static parent yield insteadof finally readonly match",_="true false null TRUE FALSE NULL __CLASS__ __DIR__ __FILE__ __LINE__ __METHOD__ __FUNCTION__ __NAMESPACE__ __TRAIT__",r="func_num_args func_get_arg func_get_args strlen strcmp strncmp strcasecmp strncasecmp each error_reporting define defined trigger_error user_error set_error_handler restore_error_handler get_declared_classes get_loaded_extensions extension_loaded get_extension_funcs debug_backtrace constant bin2hex hex2bin sleep usleep time mktime gmmktime strftime gmstrftime strtotime date gmdate getdate localtime checkdate flush wordwrap htmlspecialchars htmlentities html_entity_decode md5 md5_file crc32 getimagesize image_type_to_mime_type phpinfo phpversion phpcredits strnatcmp strnatcasecmp substr_count strspn strcspn strtok strtoupper strtolower strpos strrpos strrev hebrev hebrevc nl2br basename dirname pathinfo stripslashes stripcslashes strstr stristr strrchr str_shuffle str_word_count strcoll substr substr_replace quotemeta ucfirst ucwords strtr addslashes addcslashes rtrim str_replace str_repeat count_chars chunk_split trim ltrim strip_tags similar_text explode implode setlocale localeconv parse_str str_pad chop strchr sprintf printf vprintf vsprintf sscanf fscanf parse_url urlencode urldecode rawurlencode rawurldecode readlink linkinfo link unlink exec system escapeshellcmd escapeshellarg passthru shell_exec proc_open proc_close rand srand getrandmax mt_rand mt_srand mt_getrandmax base64_decode base64_encode abs ceil floor round is_finite is_nan is_infinite bindec hexdec octdec decbin decoct dechex base_convert number_format fmod ip2long long2ip getenv putenv getopt microtime gettimeofday getrusage uniqid quoted_printable_decode set_time_limit get_cfg_var magic_quotes_runtime set_magic_quotes_runtime get_magic_quotes_gpc get_magic_quotes_runtime import_request_variables error_log serialize unserialize memory_get_usage memory_get_peak_usage var_dump var_export debug_zval_dump print_r highlight_file show_source highlight_string ini_get ini_get_all ini_set ini_alter ini_restore get_include_path set_include_path restore_include_path setcookie header headers_sent connection_aborted connection_status ignore_user_abort parse_ini_file is_uploaded_file move_uploaded_file intval floatval doubleval strval gettype settype is_null is_resource is_bool is_long is_float is_int is_integer is_double is_real is_numeric is_string is_array is_object is_scalar ereg ereg_replace eregi eregi_replace split spliti join sql_regcase dl pclose popen readfile rewind rmdir umask fclose feof fgetc fgets fgetss fread fopen fpassthru ftruncate fstat fseek ftell fflush fwrite fputs mkdir rename copy tempnam tmpfile file file_get_contents file_put_contents stream_select stream_context_create stream_context_set_params stream_context_set_option stream_context_get_options stream_filter_prepend stream_filter_append fgetcsv flock get_meta_tags stream_set_write_buffer set_file_buffer set_socket_blocking stream_set_blocking socket_set_blocking stream_get_meta_data stream_register_wrapper stream_wrapper_register stream_set_timeout socket_set_timeout socket_get_status realpath fnmatch fsockopen pfsockopen pack unpack get_browser crypt opendir closedir chdir getcwd rewinddir readdir dir glob fileatime filectime filegroup fileiLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(x){"use strict";function k(e){return new RegExp("^(("+e.join(")|(")+"))\\b")}var _=k(["and","or","not","is"]),v=["as","assert","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","lambda","pass","raise","return","try","while","with","yield","in","False","True"],z=["abs","all","any","bin","bool","bytearray","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip","__import__","NotImplemented","Ellipsis","__debug__"];function w(e){return e.scopes[e.scopes.length-1]}x.registerHelper("hintWords","python",v.concat(z).concat(["exec","print"])),x.defineMode("python",function(t,l){for(var s="error",o=l.delimiters||l.singleDelimiters||/^[\(\)\[\]\{\}@,:`=;\.\\]/,a=[l.singleOperators,l.doubleOperators,l.doubleDelimiters,l.tripleDelimiters,l.operators||/^([-+*/%\/&|^]=?|[<>=]+|\/\/=?|\*\*=?|!=|[~!@]|\.\.\.)/],e=0;e<a.length;e++)a[e]||a.splice(e--,1);var c,u,f=l.hangingIndent||t.indentUnit,n=v,r=z,p=(null!=l.extra_keywords&&(n=n.concat(l.extra_keywords)),null!=l.extra_builtins&&(r=r.concat(l.extra_builtins)),!(l.version&&Number(l.version)<3)),d=(u=p?(c=l.identifiers||/^[_A-Za-z\u00A1-\uFFFF][_A-Za-z0-9\u00A1-\uFFFF]*/,n=n.concat(["nonlocal","None","aiter","anext","async","await","breakpoint","match","case"]),r=r.concat(["ascii","bytes","exec","print"]),new RegExp("^(([rbuf]|(br)|(rb)|(fr)|(rf))?('{3}|\"{3}|['\"]))","i")):(c=l.identifiers||/^[_A-Za-z][_A-Za-z0-9]*/,n=n.concat(["exec","print"]),r=r.concat(["apply","basestring","buffer","cmp","coerce","execfile","file","intern","long","raw_input","reduce","reload","unichr","unicode","xrange","None"]),new RegExp("^(([rubf]|(ur)|(br))?('{3}|\"{3}|['\"]))","i")),k(n)),m=k(r);function i(e,t){var n,r=e.sol()&&"\\"!=t.lastToken;return r&&(t.indent=e.indentation()),r&&"py"==w(t).type?(r=w(t).offset,e.eatSpace()?(r<(n=e.indentation())?b(t):n<r&&y(e,t)&&"#"!=e.peek()&&(t.errorToken=!0),null):(n=h(e,t),0<r&&y(e,t)&&(n+=" "+s),n)):h(e,t)}function h(e,t,n){if(e.eatSpace())return null;if(!n&&e.match(/^#.*/))return"comment";if(e.match(/^[0-9\.]/,!1)){var r=!1;if(e.match(/^[\d_]*\.\d+(e[\+\-]?\d+)?/i)&&(r=!0),e.match(/^[\d_]+\.\d*/)&&(r=!0),r=e.match(/^\.\d+/)?!0:r)return e.eat(/J/i),"number";r=!1;if(e.match(/^0x[0-9a-f_]+/i)&&(r=!0),e.match(/^0b[01_]+/i)&&(r=!0),e.match(/^0o[0-7_]+/i)&&(r=!0),e.match(/^[1-9][\d_]*(e[\+\-]?[\d_]+)?/)&&(e.eat(/J/i),r=!0),r=e.match(/^0(?![\dx])/i)?!0:r)return e.eat(/L/i),"number"}if(e.match(u))return r=-1!==e.current().toLowerCase().indexOf("f"),t.tokenize=(r?function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function a(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\{\}\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;if(e.match("{{"))return o;if(e.match("{",!1))return t.tokenize=function r(i){return function(e,t){var n=h(e,t,!0);return"punctuation"==n&&("{"==e.current()?t.tokenize=r(i+1):"}"==e.current()&&(t.tokenize=1<i?r(i-1):a)),n}}(0),e.current()?o:t.tokenize(e,t);if(e.match("}}"))return o;if(e.match("}"))return s;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return a.isString=!0,a}:function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function e(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return e.isString=!0,e})(e.current(),t.tokenize),t.tokenize(e,t);for(var i=0;i<a.length;i++)if(e.match(a[i]))return"operator";return e.match(o)?"punctuation":"."==t.lastToken&&e.match(c)?"property":e.match(d)||e.match(_)?"keyword":e.match(m)?"builtin":e.match(/^(self|cls)\b/)?"variable-2":e.match(c)?"def"==t.lastToken||"class"==t.lastToken?"def":"variable":(e.next(),n?null:s)}function b(e){for(;"py"!=w(e).type;)e.scopes.pop();e.scopes.push({offset:w(e).offset+t.indentUnit,type:"py",align:null})}function y(e,t){for(var n=e.indentation();1<t.scopes.length&&w(t).offset>n;){if("py"!=w(t).type)return 1;t.scopes.pop()}return w(t).offset!=n}function g(e,t){e.sol()&&(t.beginningOfLine=!0,t.dedent=!1);var n,r,i,o=t.tokenize(e,t),a=e.current();if(t.beginningOfLine&&"@"==a)return e.match(c,!1)?"meta":p?"operator":s;if(/\S/.test(a)&&(t.beginningOfLine=!1),"variable"!=o&&"builtin"Line truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(p){"use strict";p.defineMode("go",function(e){var i,o=e.indentUnit,r={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},a={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},c=/[+\-*&^%:=<>!|\/]/;function u(e,t){var o,n=e.next();if('"'==n||"'"==n||"`"==n)return t.tokenize=(o=n,function(e,t){for(var n,r=!1,i=!1;null!=(n=e.next());){if(n==o&&!r){i=!0;break}r=!r&&"`"!=o&&"\\"==n}return(i||!r&&"`"!=o)&&(t.tokenize=u),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(n))return"."==n?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==n?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=l)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(c.test(n))return e.eatWhile(c),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);t=e.current();return r.propertyIsEnumerable(t)?("case"!=t&&"default"!=t||(i="case"),"keyword"):a.propertyIsEnumerable(t)?"atom":"variable"}function l(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=u;break}r="*"==n}return"comment"}function f(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function s(e,t,n){e.context=new f(e.indented,t,n,null,e.context)}function d(e){var t;e.context.prev&&(")"!=(t=e.context.type)&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev)}return{startState:function(e){return{tokenize:null,context:new f((e||0)-o,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==n.type&&(n.type="}")),e.eatSpace())return null;i=null;var r=(t.tokenize||u)(e,t);return"comment"==r||(null==n.align&&(n.align=!0),"{"==i?s(t,e.column(),"}"):"["==i?s(t,e.column(),"]"):"("==i?s(t,e.column(),")"):"case"==i?n.type="case":("}"==i&&"}"==n.type||i==n.type)&&d(t),t.startOfLine=!1),r},indent:function(e,t){if(e.tokenize!=u&&null!=e.tokenize)return p.Pass;var n=e.context,r=t&&t.charAt(0);if("case"==n.type&&/^(?:case|default)\b/.test(t))return e.context.type="}",n.indented;t=r==n.type;return n.align?n.column+(t?0:1):n.indented+(t?0:o)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}}),p.defineMIME("text/x-go","go")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});;
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../meta")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../meta"],t):t(CodeMirror)}(function(w){"use strict";w.defineMode("markdown",function(m,d){var g=w.getMode(m,"text/html"),u="null"==g.name;void 0===d.highlightFormatting&&(d.highlightFormatting=!1),void 0===d.maxBlockquoteDepth&&(d.maxBlockquoteDepth=0),void 0===d.taskLists&&(d.taskLists=!1),void 0===d.strikethrough&&(d.strikethrough=!1),void 0===d.emoji&&(d.emoji=!1),void 0===d.fencedCodeBlockHighlighting&&(d.fencedCodeBlockHighlighting=!0),void 0===d.fencedCodeBlockDefaultMode&&(d.fencedCodeBlockDefaultMode="text/plain"),void 0===d.xml&&(d.xml=!0),void 0===d.tokenTypeOverrides&&(d.tokenTypeOverrides={});var t,c={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"image",imageAltText:"image-alt-text",imageMarker:"image-marker",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough",emoji:"builtin"};for(t in c)c.hasOwnProperty(t)&&d.tokenTypeOverrides[t]&&(c[t]=d.tokenTypeOverrides[t]);var f=/^([*\-_])(?:\s*\1){2,}\s*$/,k=/^(?:[*\-+]|^[0-9]+([.)]))\s+/,F=/^\[(x| )\](?=\s)/i,D=d.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,p=/^ {0,3}(?:\={1,}|-{2,})\s*$/,i=/^[^#!\[\]*_\\<>` "'(~:]+/,E=/^(~~~+|```+)[ \t]*([\w\/+#-]*)[^\n`]*$/,x=/^\s*\[[^\]]+?\]:.*$/,A=/[!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/;function C(t,e,i){return(e.f=e.inline=i)(t,e)}function S(t,e,i){return(e.f=e.block=i)(t,e)}function n(t){var e;return t.linkTitle=!1,t.linkHref=!1,t.linkText=!1,t.em=!1,t.strong=!1,t.strikethrough=!1,t.quote=0,t.indentedCode=!1,t.f==v&&(u?u:"xml"==(e=w.innerMode(g,t.htmlState)).mode.name&&null===e.state.tagStart&&!e.state.context&&e.state.tokenize.isInText)&&(t.f=T,t.block=a,t.htmlState=null),t.trailingSpace=0,t.trailingSpaceNewLine=!1,t.prevLine=t.thisLine,t.thisLine={stream:null},null}function a(t,e){var i=t.column()===e.indentation,n=!(n=e.prevLine.stream)||!/\S/.test(n.string),u=e.indentedCode,a=e.prevLine.hr,r=!1!==e.list,o=(e.listStack[e.listStack.length-1]||0)+3,l=(e.indentedCode=!1,e.indentation);if(null===e.indentationDiff&&(e.indentationDiff=e.indentation,r)){for(e.list=null;l<e.listStack[e.listStack.length-1];)e.listStack.pop(),e.listStack.length?e.indentation=e.listStack[e.listStack.length-1]:e.list=!1;!1!==e.list&&(e.indentationDiff=l-e.listStack[e.listStack.length-1])}var h,s=!(n||a||e.prevLine.header||r&&u||e.prevLine.fencedCodeEnd),a=(!1===e.list||a||n)&&e.indentation<=o&&t.match(f),g=null;return 4<=e.indentationDiff&&(u||e.prevLine.fencedCodeEnd||e.prevLine.header||n)?(t.skipToEnd(),e.indentedCode=!0,c.code):t.eatSpace()?null:i&&e.indentation<=o&&(g=t.match(D))&&g[1].length<=6?(e.quote=0,e.header=g[1].length,e.thisLine.header=!0,d.highlightFormatting&&(e.formatting="header"),e.f=e.inline,L(e)):e.indentation<=o&&t.eat(">")?(e.quote=i?1:e.quote+1,d.highlightFormatting&&(e.formatting="quote"),t.eatSpace(),L(e)):!a&&!e.setext&&i&&e.indentation<=o&&(g=t.match(k))?(u=g[1]?"ol":"ul",e.indentation=l+t.current().length,e.list=!0,e.quote=0,e.listStack.push(e.indentation),e.em=!1,e.strong=!1,e.code=!1,e.strikethrough=!1,d.taskLists&Line truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t;null!=(t=e.next());)if("`"==t&&!e.eat("`"))return"variable-2";return e.backUp(e.current().length-1),e.eatWhile(/\w/)?"variable-2":null}function t(e){return e.eat("@")&&(e.match("session."),e.match("local."),e.match("global.")),e.eat("'")?(e.match(/^.*'/),"variable-2"):e.eat('"')?(e.match(/^.*"/),"variable-2"):e.eat("`")?(e.match(/^.*`/),"variable-2"):e.match(/^[0-9a-zA-Z$\.\_]+/)?"variable-2":null}function r(e){return e.eat("N")?"atom":e.match(/^[a-zA-Z.#!?]/)?"variable-2":null}a.defineMode("sql",function(r,e){var i=e.client||{},n=e.atoms||{false:!0,true:!0,null:!0},s=e.builtin||y(v),o=e.keywords||y(f),l=e.operatorChars||/^[*+\-%<>!=&|~^\/]/,c=e.support||{},m=e.hooks||{},d=e.dateSQL||{date:!0,time:!0,timestamp:!0},u=!1!==e.backslashStringEscapes,p=e.brackets||/^[\{}\(\)\[\]]/,_=e.punctuation||/^[;.,:]/;function g(e,t){var r=e.next();if(m[r]){var a=m[r](e,t);if(!1!==a)return a}return c.hexNumber&&("0"==r&&e.match(/^[xX][0-9a-fA-F]+/)||("x"==r||"X"==r)&&e.match(/^'[0-9a-fA-F]*'/))||c.binaryNumber&&(("b"==r||"B"==r)&&e.match(/^'[01]*'/)||"0"==r&&e.match(/^b[01]+/))?"number":47<r.charCodeAt(0)&&r.charCodeAt(0)<58?(e.match(/^[0-9]*(\.[0-9]+)?([eE][-+]?[0-9]+)?/),c.decimallessFloat&&e.match(/^\.(?!\.)/),"number"):"?"==r&&(e.eatSpace()||e.eol()||e.eat(";"))?"variable-3":"'"==r||'"'==r&&c.doubleQuote?(t.tokenize=h(r),t.tokenize(e,t)):!(c.nCharCast&&("n"==r||"N"==r)||c.charsetCast&&"_"==r&&e.match(/[a-z][a-z0-9]*/i))||"'"!=e.peek()&&'"'!=e.peek()?c.escapeConstant&&("e"==r||"E"==r)&&("'"==e.peek()||'"'==e.peek()&&c.doubleQuote)?(t.tokenize=function(e,t){return(t.tokenize=h(e.next(),!0))(e,t)},"keyword"):c.commentSlashSlash&&"/"==r&&e.eat("/")||c.commentHash&&"#"==r||"-"==r&&e.eat("-")&&(!c.commentSpaceRequired||e.eat(" "))?(e.skipToEnd(),"comment"):"/"==r&&e.eat("*")?(t.tokenize=function a(i){return function(e,t){var r=e.match(/^.*?(\/\*|\*\/)/);return r?"/*"==r[1]?t.tokenize=a(i+1):t.tokenize=1<i?a(i-1):g:e.skipToEnd(),"comment"}}(1),t.tokenize(e,t)):"."==r?c.zerolessFloat&&e.match(/^(?:\d+(?:e[+-]?\d+)?)/i)?"number":e.match(/^\.+/)?null:e.match(/^[\w\d_$#]+/)?"variable-2":void 0:l.test(r)?(e.eatWhile(l),"operator"):p.test(r)?"bracket":_.test(r)?(e.eatWhile(_),"punctuation"):"{"==r&&(e.match(/^( )*(d|D|t|T|ts|TS)( )*'[^']*'( )*}/)||e.match(/^( )*(d|D|t|T|ts|TS)( )*"[^"]*"( )*}/))?"number":(e.eatWhile(/^[_\w\d]/),a=e.current().toLowerCase(),d.hasOwnProperty(a)&&(e.match(/^( )+'[^']*'/)||e.match(/^( )+"[^"]*"/))?"number":n.hasOwnProperty(a)?"atom":s.hasOwnProperty(a)?"type":o.hasOwnProperty(a)?"keyword":i.hasOwnProperty(a)?"builtin":null):"keyword"}function h(i,n){return function(e,t){for(var r,a=!1;null!=(r=e.next());){if(r==i&&!a){t.tokenize=g;break}a=(u||n)&&!a&&"\\"==r}return"string"}}function b(e,t,r){t.context={prev:t.context,indent:e.indentation(),col:e.column(),type:r}}return{startState:function(){return{tokenize:g,context:null}},token:function(e,t){if(e.sol()&&t.context&&null==t.context.align&&(t.context.align=!1),t.tokenize==g&&e.eatSpace())return null;var r=t.tokenize(e,t);if("comment"==r)return r;t.context&&null==t.context.align&&(t.context.align=!0);var a=e.current();return"("==a?b(e,t,")"):"["==a?b(e,t,"]"):t.context&&t.context.type==a&&((e=t).indent=e.context.indent,e.context=e.context.prev),r},indent:function(e,t){e=e.context;if(!e)return a.Pass;t=t.charAt(0)==e.type;return e.align?e.col+(t?0:1):e.indent+(t?0:r.indentUnit)},blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:c.commentSlashSlash?"//":c.commentHash?"#":"--",closeBrackets:"()[]{}''\"\"``",config:e}});var f="alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit ";function y(e){for(var t={},r=e.split(" "),a=0;a<r.length;++a)t[r[a]]=!0;return t}var v="bool boolean bit blob enum long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision real date datetime year unsigned signed decimal numeric";a.defineMIME("text/x-sql",{name:"sql",keywords:y(f+"begin"),builtin:y(v),atoms:y("false true null unknown"),dateSQL:y("date time timestamp"),support:y("doubleQuote binaryNumber hexNumber")}),a.defineMIME("text/x-mssql",{name:"sql",client:y("$partition binary_checksum checksum connectionproperty context_info current_request_id error_line error_message error_number error_procedure error_severity error_state formatmessage get_filestream_transaction_context getansinull host_id host_name isnull isnumeric min_active_rowversion newid newsequentialid rowcount_big xact_state object_id"),keywords:y(f+"begin trigger proc view index for add constLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("toml",function(){return{startState:function(){return{inString:!1,stringType:"",lhs:!0,inArray:0}},token:function(e,t){if(t.inString||'"'!=e.peek()&&"'"!=e.peek()||(t.stringType=e.peek(),e.next(),t.inString=!0),e.sol()&&0===t.inArray&&(t.lhs=!0),t.inString){for(;t.inString&&!e.eol();)e.peek()===t.stringType?(e.next(),t.inString=!1):"\\"===e.peek()?(e.next(),e.next()):e.match(/^.[^\\\"\']*/);return t.lhs?"property string":"string"}return t.inArray&&"]"===e.peek()?(e.next(),t.inArray--,"bracket"):t.lhs&&"["===e.peek()&&e.skipTo("]")?(e.next(),"]"===e.peek()&&e.next(),"atom"):"#"===e.peek()?(e.skipToEnd(),"comment"):e.eatSpace()?null:t.lhs&&e.eatWhile(function(e){return"="!=e&&" "!=e})?"property":t.lhs&&"="===e.peek()?(e.next(),t.lhs=!1,null):(t.lhs||!e.match(/^\d\d\d\d[\d\-\:\.T]*Z/))&&(t.lhs||!e.match("true")&&!e.match("false"))?t.lhs||"["!==e.peek()?!t.lhs&&e.match(/^\-?\d+(?:\.\d+)?/)?"number":(e.eatSpace()||e.next(),null):(t.inArray++,e.next(),"bracket"):"atom"}}}),e.defineMIME("text/x-toml","toml")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("nginx",function(e){function _(e){for(var _={},t=e.split(" "),i=0;i<t.length;++i)_[t[i]]=!0;return _}var s,i=_("break return rewrite set accept_mutex accept_mutex_delay access_log add_after_body add_before_body add_header addition_types aio alias allow ancient_browser ancient_browser_value auth_basic auth_basic_user_file auth_http auth_http_header auth_http_timeout autoindex autoindex_exact_size autoindex_localtime charset charset_types client_body_buffer_size client_body_in_file_only client_body_in_single_buffer client_body_temp_path client_body_timeout client_header_buffer_size client_header_timeout client_max_body_size connection_pool_size create_full_put_path daemon dav_access dav_methods debug_connection debug_points default_type degradation degrade deny devpoll_changes devpoll_events directio directio_alignment empty_gif env epoll_events error_log eventport_events expires fastcgi_bind fastcgi_buffer_size fastcgi_buffers fastcgi_busy_buffers_size fastcgi_cache fastcgi_cache_key fastcgi_cache_methods fastcgi_cache_min_uses fastcgi_cache_path fastcgi_cache_use_stale fastcgi_cache_valid fastcgi_catch_stderr fastcgi_connect_timeout fastcgi_hide_header fastcgi_ignore_client_abort fastcgi_ignore_headers fastcgi_index fastcgi_intercept_errors fastcgi_max_temp_file_size fastcgi_next_upstream fastcgi_param fastcgi_pass_header fastcgi_pass_request_body fastcgi_pass_request_headers fastcgi_read_timeout fastcgi_send_lowat fastcgi_send_timeout fastcgi_split_path_info fastcgi_store fastcgi_store_access fastcgi_temp_file_write_size fastcgi_temp_path fastcgi_upstream_fail_timeout fastcgi_upstream_max_fails flv geoip_city geoip_country google_perftools_profiles gzip gzip_buffers gzip_comp_level gzip_disable gzip_hash gzip_http_version gzip_min_length gzip_no_buffer gzip_proxied gzip_static gzip_types gzip_vary gzip_window if_modified_since ignore_invalid_headers image_filter image_filter_buffer image_filter_jpeg_quality image_filter_transparency imap_auth imap_capabilities imap_client_buffer index ip_hash keepalive_requests keepalive_timeout kqueue_changes kqueue_events large_client_header_buffers limit_conn limit_conn_log_level limit_rate limit_rate_after limit_req limit_req_log_level limit_req_zone limit_zone lingering_time lingering_timeout lock_file log_format log_not_found log_subrequest map_hash_bucket_size map_hash_max_size master_process memcached_bind memcached_buffer_size memcached_connect_timeout memcached_next_upstream memcached_read_timeout memcached_send_timeout memcached_upstream_fail_timeout memcached_upstream_max_fails merge_slashes min_delete_depth modern_browser modern_browser_value msie_padding msie_refresh multi_accept open_file_cache open_file_cache_errors open_file_cache_events open_file_cache_min_uses open_file_cache_valid open_log_file_cache output_buffers override_charset perl perl_modules perl_require perl_set pid pop3_auth pop3_capabilities port_in_redirect postpone_gzipping postpone_output protocol proxy proxy_bind proxy_buffer proxy_buffer_size proxy_buffering proxy_buffers proxy_busy_buffers_size proxy_cache proxy_cache_key proxy_cache_methods proxy_cache_min_uses proxy_cache_path proxy_cache_use_stale proxy_cache_valid proxy_connect_timeout proxy_headers_hash_bucket_size proxy_headers_hash_max_size proxy_hide_header proxy_ignore_client_abort proxy_ignore_headers proxy_intercept_errors proxy_max_temp_file_size proxy_method proxy_next_upstream proxy_pass_error_message proxy_pass_header proxy_pass_request_body proxy_pass_request_headers proxy_read_timeout proxy_redirect proxy_send_lowat proxy_send_timeout proxy_set_body proxy_set_header proxy_ssl_session_reuse proxy_store proxy_store_access proxy_temp_file_write_size proxy_temp_path proxy_timeout proxy_upstream_fail_timeout proxy_upstream_max_fails random_index read_ahead real_ip_header recursive_error_pages request_pool_size reset_timedout_connection resolver resolver_timeout rewrite_log rtsig_overflow_events rtsig_overflow_test rtsig_overflow_threshold rtsig_signo satisfy secure_link_secret send_lowat send_timeout sendfile sendfile_max_chunk server_name_in_redirect server_names_hash_bucket_size server_names_hash_max_size server_tokens set_real_ip_from smtp_auth smtp_capabilities smtp_client_buffer smtp_greeting_delay so_keepalive source_charset ssi ssi_ignore_recycled_buffers ssi_min_file_chunk ssi_silent_errors ssi_types ssi_value_length ssl ssl_certificate ssl_certificate_key ssl_ciphers ssl_client_certificate ssl_crl ssl_dhparam ssl_engine ssl_prefer_server_ciphers ssl_protocols ssl_session_cache ssl_session_timeout ssl_verify_client ssl_verify_depth starttls stub_status sub_filter sub_filter_once sub_filter_types tcp_nodelay tcp_nopush thread_stack_size timeout timer_resolution types_hash_bucket_size types_hash_maLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";var n="from",t=new RegExp("^(\\s*)\\b(from)\\b","i"),r=["run","cmd","entrypoint","shell"],o=new RegExp("^(\\s*)("+r.join("|")+")(\\s+\\[)","i"),l="expose",s=new RegExp("^(\\s*)("+l+")(\\s+)","i"),n="("+[n,l].concat(r).concat(["arg","from","maintainer","label","env","add","copy","volume","user","workdir","onbuild","stopsignal","healthcheck","shell"]).join("|")+")",l=new RegExp("^(\\s*)"+n+"(\\s*)(#.*)?$","i"),r=new RegExp("^(\\s*)"+n+"(\\s+)","i");e.defineSimpleMode("dockerfile",{start:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:t,token:[null,"keyword"],sol:!0,next:"from"},{regex:l,token:[null,"keyword",null,"error"],sol:!0},{regex:o,token:[null,"keyword",null],sol:!0,next:"array"},{regex:s,token:[null,"keyword",null],sol:!0,next:"expose"},{regex:r,token:[null,"keyword",null],sol:!0,next:"arguments"},{regex:/./,token:null}],from:[{regex:/\s*$/,token:null,next:"start"},{regex:/(\s*)(#.*)$/,token:[null,"error"],next:"start"},{regex:/(\s*\S+\s+)(as)/i,token:[null,"keyword"],next:"start"},{token:null,next:"start"}],single:[{regex:/(?:[^\\']|\\.)/,token:"string"},{regex:/'/,token:"string",pop:!0}],double:[{regex:/(?:[^\\"]|\\.)/,token:"string"},{regex:/"/,token:"string",pop:!0}],array:[{regex:/\]/,token:null,next:"start"},{regex:/"(?:[^\\"]|\\.)*"?/,token:"string"}],expose:[{regex:/\d+$/,token:"number",next:"start"},{regex:/[^\d]+$/,token:null,next:"start"},{regex:/\d+/,token:"number"},{regex:/[^\d]+/,token:null},{token:null,next:"start"}],arguments:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:/"(?:[^\\"]|\\.)*"?$/,token:"string",next:"start"},{regex:/"/,token:"string",push:"double"},{regex:/'(?:[^\\']|\\.)*'?$/,token:"string",next:"start"},{regex:/'/,token:"string",push:"single"},{regex:/[^#"']+[\\`]$/,token:null},{regex:/[^#"']+$/,token:null,next:"start"},{regex:/[^#"']+/,token:null},{token:null,next:"start"}],meta:{lineComment:"#"}}),e.defineMIME("text/x-dockerfile","dockerfile")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("lua",function(e,t){var n=e.indentUnit;function a(e){return new RegExp("^(?:"+e.join("|")+")$","i")}var r=a(t.specials||[]),o=a(["_G","_VERSION","assert","collectgarbage","dofile","error","getfenv","getmetatable","ipairs","load","loadfile","loadstring","module","next","pairs","pcall","print","rawequal","rawget","rawset","require","select","setfenv","setmetatable","tonumber","tostring","type","unpack","xpcall","coroutine.create","coroutine.resume","coroutine.running","coroutine.status","coroutine.wrap","coroutine.yield","debug.debug","debug.getfenv","debug.gethook","debug.getinfo","debug.getlocal","debug.getmetatable","debug.getregistry","debug.getupvalue","debug.setfenv","debug.sethook","debug.setlocal","debug.setmetatable","debug.setupvalue","debug.traceback","close","flush","lines","read","seek","setvbuf","write","io.close","io.flush","io.input","io.lines","io.open","io.output","io.popen","io.read","io.stderr","io.stdin","io.stdout","io.tmpfile","io.type","io.write","math.abs","math.acos","math.asin","math.atan","math.atan2","math.ceil","math.cos","math.cosh","math.deg","math.exp","math.floor","math.fmod","math.frexp","math.huge","math.ldexp","math.log","math.log10","math.max","math.min","math.modf","math.pi","math.pow","math.rad","math.random","math.randomseed","math.sin","math.sinh","math.sqrt","math.tan","math.tanh","os.clock","os.date","os.difftime","os.execute","os.exit","os.getenv","os.remove","os.rename","os.setlocale","os.time","os.tmpname","package.cpath","package.loaded","package.loaders","package.loadlib","package.path","package.preload","package.seeall","string.byte","string.char","string.dump","string.find","string.format","string.gmatch","string.gsub","string.len","string.lower","string.match","string.rep","string.reverse","string.sub","string.upper","table.concat","table.insert","table.maxn","table.remove","table.sort"]),i=a(["and","break","elseif","false","nil","not","or","return","true","function","end","if","then","else","do","while","repeat","until","for","in","local"]),l=a(["function","if","repeat","do","\\(","{"]),s=a(["end","until","\\)","}"]),u=new RegExp("^(?:"+["end","until","\\)","}","else","elseif"].join("|")+")","i");function c(e){for(var t=0;e.eat("=");)++t;return e.eat("["),t}function m(e,t){var r,n=e.next();return"-"==n&&e.eat("-")?e.eat("[")&&e.eat("[")?(t.cur=d(c(e),"comment"))(e,t):(e.skipToEnd(),"comment"):'"'==n||"'"==n?(t.cur=(r=n,function(e,t){for(var n,a=!1;null!=(n=e.next())&&(n!=r||a);)a=!a&&"\\"==n;return a||(t.cur=m),"string"}))(e,t):"["==n&&/[\[=]/.test(e.peek())?(t.cur=d(c(e),"string"))(e,t):/\d/.test(n)?(e.eatWhile(/[\w.%]/),"number"):/[\w_]/.test(n)?(e.eatWhile(/[\w\\\-_.]/),"variable"):null}function d(r,o){return function(e,t){for(var n,a=null;null!=(n=e.next());)if(null==a)"]"==n&&(a=0);else if("="==n)++a;else{if("]"==n&&a==r){t.cur=m;break}a=null}return o}}return{startState:function(e){return{basecol:e||0,indentDepth:0,cur:m}},token:function(e,t){if(e.eatSpace())return null;var n=t.cur(e,t),e=e.current();return"variable"==n&&(i.test(e)?n="keyword":o.test(e)?n="builtin":r.test(e)&&(n="variable-2")),"comment"!=n&&"string"!=n&&(l.test(e)?++t.indentDepth:s.test(e)&&--t.indentDepth),n},indent:function(e,t){t=u.test(t);return e.basecol+n*(e.indentDepth-(t?1:0))},electricInput:/^\s*(?:end|until|else|\)|\})$/,lineComment:"--",blockCommentStart:"--[[",blockCommentEnd:"]]"}}),e.defineMIME("text/x-lua","lua")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";e.defineSimpleMode("rust",{start:[{regex:/b?"/,token:"string",next:"string"},{regex:/b?r"/,token:"string",next:"string_raw"},{regex:/b?r#+"/,token:"string",next:"string_raw_hash"},{regex:/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/,token:"string-2"},{regex:/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/,token:"string-2"},{regex:/(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/,token:"number"},{regex:/(let(?:\s+mut)?|fn|enum|mod|struct|type|union)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/(?:abstract|alignof|as|async|await|box|break|continue|const|crate|do|dyn|else|enum|extern|fn|for|final|if|impl|in|loop|macro|match|mod|move|offsetof|override|priv|proc|pub|pure|ref|return|self|sizeof|static|struct|super|trait|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,token:"keyword"},{regex:/\b(?:Self|isize|usize|char|bool|u8|u16|u32|u64|f16|f32|f64|i8|i16|i32|i64|str|Option)\b/,token:"atom"},{regex:/\b(?:true|false|Some|None|Ok|Err)\b/,token:"builtin"},{regex:/\b(fn)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/#!?\[.*\]/,token:"meta"},{regex:/\/\/.*/,token:"comment"},{regex:/\/\*/,token:"comment",next:"comment"},{regex:/[-+\/*=<>!]+/,token:"operator"},{regex:/[a-zA-Z_]\w*!/,token:"variable-3"},{regex:/[a-zA-Z_]\w*/,token:"variable"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0}],string:[{regex:/"/,token:"string",next:"start"},{regex:/(?:[^\\"]|\\(?:.|$))*/,token:"string"}],string_raw:[{regex:/"/,token:"string",next:"start"},{regex:/[^"]*/,token:"string"}],string_raw_hash:[{regex:/"#+/,token:"string",next:"start"},{regex:/(?:[^"]|"(?!#))*/,token:"string"}],comment:[{regex:/.*?\*\//,token:"comment",next:"start"},{regex:/.*/,token:"comment"}],meta:{dontIndentStates:["comment"],electricInput:/^\s*\}$/,blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//",fold:"brace"}}),e.defineMIME("text/x-rustsrc","rust"),e.defineMIME("text/rust","rust")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("diff",function(){var o={"+":"positive","-":"negative","@":"meta"};return{token:function(e){var r=e.string.search(/[\t ]+?$/);if(!e.sol()||0===r)return e.skipToEnd(),("error "+(o[e.string.charAt(0)]||"")).replace(/ $/,"");var i=o[e.peek()]||e.skipToEnd();return-1===r?e.skipToEnd():e.pos=r,i}}}),e.defineMIME("text/x-diff","diff")});;
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(o){"use strict";function e(e){for(var t={},n=0,r=e.length;n<r;++n)t[e[n]]=!0;return t}var t=["alias","and","BEGIN","begin","break","case","class","def","defined?","do","else","elsif","END","end","ensure","false","for","if","in","module","next","not","or","redo","rescue","retry","return","self","super","then","true","undef","unless","until","when","while","yield","nil","raise","throw","catch","fail","loop","callcc","caller","lambda","proc","public","protected","private","require","load","require_relative","extend","autoload","__END__","__FILE__","__LINE__","__dir__"],a=e(t),u=e(["def","class","case","for","while","until","module","catch","loop","proc","begin"]),p=e(["end","until"]),k={"[":"]","{":"}","(":")"},m={"]":"[","}":"{",")":"("};o.defineMode("ruby",function(i){var l;function f(e,t,n){return n.tokenize.push(e),e(t,n)}function d(e,t){if(e.sol()&&e.match("=begin")&&e.eol())return t.tokenize.push(c),"comment";if(e.eatSpace())return null;var n,r,i,o,a,u=e.next();if("`"==u||"'"==u||'"'==u)return f(s(u,"string",'"'==u||"`"==u),e,t);if("/"==u)return function(e){var t,n=e.pos,r=0,i=!1,o=!1;for(;null!=(t=e.next());)if(o)o=!1;else{if(-1<"[{(".indexOf(t))r++;else if(-1<"]})".indexOf(t)){if(--r<0)break}else if("/"==t&&0==r){i=!0;break}o="\\"==t}return e.backUp(e.pos-n),i}(e)?f(s(u,"string-2",!0),e,t):"operator";if("%"==u)return i="string",n=!0,e.eat("s")?i="atom":e.eat(/[WQ]/)?i="string":e.eat(/[r]/)?i="string-2":e.eat(/[wxq]/)&&(n=!(i="string")),(r=e.eat(/[^\w\s=]/))?f(s(r=k.propertyIsEnumerable(r)?k[r]:r,i,n,!0),e,t):"operator";if("#"==u)return e.skipToEnd(),"comment";if("<"==u&&(r=e.match(/^<([-~])[\`\"\']?([a-zA-Z_?]\w*)[\`\"\']?(?:;|$)/)))return f((o=r[2],a=r[1],function(e,t){return a&&e.eatSpace(),e.match(o)?t.tokenize.pop():e.skipToEnd(),"string"}),e,t);if("0"==u)return e.eat("x")?e.eatWhile(/[\da-fA-F]/):e.eat("b")?e.eatWhile(/[01]/):e.eatWhile(/[0-7]/),"number";if(/\d/.test(u))return e.match(/^[\d_]*(?:\.[\d_]+)?(?:[eE][+\-]?[\d_]+)?/),"number";if("?"!=u)return":"==u?e.eat("'")?f(s("'","atom",!1),e,t):e.eat('"')?f(s('"',"atom",!0),e,t):e.eat(/[\<\>]/)?(e.eat(/[\<\>]/),"atom"):e.eat(/[\+\-\*\/\&\|\:\!]/)?"atom":e.eat(/[a-zA-Z$@_\xa1-\uffff]/)?(e.eatWhile(/[\w$\xa1-\uffff]/),e.eat(/[\?\!\=]/),"atom"):"operator":"@"==u&&e.match(/^@?[a-zA-Z_\xa1-\uffff]/)?(e.eat("@"),e.eatWhile(/[\w\xa1-\uffff]/),"variable-2"):"$"==u?(e.eat(/[a-zA-Z_]/)?e.eatWhile(/[\w]/):e.eat(/\d/)?e.eat(/\d/):e.next(),"variable-3"):/[a-zA-Z_\xa1-\uffff]/.test(u)?(e.eatWhile(/[\w\xa1-\uffff]/),e.eat(/[\?\!]/),e.eat(":")?"atom":"ident"):"|"!=u||!t.varList&&"{"!=t.lastTok&&"do"!=t.lastTok?/[\(\)\[\]{}\\;]/.test(u)?(l=u,null):"-"==u&&e.eat(">")?"arrow":/[=+\-\/*:\.^%<>~|]/.test(u)?(i=e.eatWhile(/[=+\-\/*:\.^%<>~|]/),"."!=u||i||(l="."),"operator"):null:(l="|",null);for(;e.match(/^\\[CM]-/););return e.eat("\\")?e.eatWhile(/\w/):e.next(),"string"}function s(i,o,a,u){return function(e,t){var n,r=!1;for("read-quoted-paused"===t.context.type&&(t.context=t.context.prev,e.eat("}"));null!=(n=e.next());){if(n==i&&(u||!r)){t.tokenize.pop();break}if(a&&"#"==n&&!r){if(e.eat("{")){"}"==i&&(t.context={prev:t.context,type:"read-quoted-paused"}),t.tokenize.push(function n(r){return r=r||1,function(e,t){if("}"==e.peek()){if(1==r)return t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t);t.tokenize[t.tokenize.length-1]=n(r-1)}else"{"==e.peek()&&(t.tokenize[t.tokenize.length-1]=n(r+1));return d(e,t)}}());break}if(/[@\$]/.test(e.peek())){t.tokenize.push(function(){var n=!1;return function(e,t){return n?(t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t)):(n=!0,d(e,t))}}());break}}r=!r&&"\\"==n}return o}}function c(e,t){return e.sol()&&e.match("=end")&&e.eol()&&t.tokenize.pop(),e.skipToEnd(),"comment"}return{startState:function(){return{tokenize:[d],indented:0,context:{type:"top",indented:-i.indentUnit},continuedLine:!1,lastTok:null,varList:!1}},token:function(e,t){l=null,e.sol()&&(t.indented=e.indentation());var n,r,i=t.tokenize[t.tokenize.length-1](e,t),o=l;return"ident"==i&&(r=e.current(),"keyword"==(i="."==t.lastTok?"property":a.propertyIsEnumerable(e.current())?"keyword":/^[A-Z]/.test(r)?"tag":"def"==t.lastTok||"class"==t.lastTok||t.varList?"def":"variable")&&(u.propertyIsEnumerable(o=r)?n="indent":p.propertyIsEnumerable(r)?n="dedent":(("if"==r||"unless"==r)&&e.column()==e.indentation()||"do"==r&&t.context.indented<t.indented)&&(n="indent"))),(l||i&&"comment"!=i)&&(t.lastTok=o),"|"==l&&(t.varList=!t.varList),"indent"==n||/[\(\[\{]/.test(l)?t.context={prev:t.context,type:l||i,indented:t.indented}:("dedent"==n||/[\)\]\}]/.test(l))&&t.context.prev&&(t.context=t.context.prev),e.eol()&&(t.continuedLine="\\"==l||"operator"==i),i},indent:function(e,t){if(e.tokenize[e.tokenize.length-1]!=d)return o.Pass;var n=t&&t.charAt(0),r=e.context,n=r.type==m[n]||"keyword"==r.type&Line truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";function R(e,t){return e.string.charAt(e.pos+(t||0))}function c(e,t){var r;return t?(r=e.pos-t,e.string.substr(0<=r?r:0,t)):e.string.substr(0,e.pos-1)}function p(e,t){var r=e.string.length,n=r-e.pos+1;return e.string.substr(e.pos,t&&t<r?t:n)}function _(e,t){var r,t=e.pos+t;t<=0?e.pos=0:t>=(r=e.string.length-1)?e.pos=r:e.pos=t}e.defineMode("perl",function(){var a={"->":4,"++":4,"--":4,"**":4,"=~":4,"!~":4,"*":4,"/":4,"%":4,x:4,"+":4,"-":4,".":4,"<<":4,">>":4,"<":4,">":4,"<=":4,">=":4,lt:4,gt:4,le:4,ge:4,"==":4,"!=":4,"<=>":4,eq:4,ne:4,cmp:4,"~~":4,"&":4,"|":4,"^":4,"&&":4,"||":4,"//":4,"..":4,"...":4,"?":4,":":4,"=":4,"+=":4,"-=":4,"*=":4,",":4,"=>":4,"::":4,not:4,and:4,or:4,xor:4,BEGIN:[5,1],END:[5,1],PRINT:[5,1],PRINTF:[5,1],GETC:[5,1],READ:[5,1],READLINE:[5,1],DESTROY:[5,1],TIE:[5,1],TIEHANDLE:[5,1],UNTIE:[5,1],STDIN:5,STDIN_TOP:5,STDOUT:5,STDOUT_TOP:5,STDERR:5,STDERR_TOP:5,$ARG:5,$_:5,"@ARG":5,"@_":5,$LIST_SEPARATOR:5,'$"':5,$PROCESS_ID:5,$PID:5,$$:5,$REAL_GROUP_ID:5,$GID:5,"$(":5,$EFFECTIVE_GROUP_ID:5,$EGID:5,"$)":5,$PROGRAM_NAME:5,$0:5,$SUBSCRIPT_SEPARATOR:5,$SUBSEP:5,"$;":5,$REAL_USER_ID:5,$UID:5,"$<":5,$EFFECTIVE_USER_ID:5,$EUID:5,"$>":5,$a:5,$b:5,$COMPILING:5,"$^C":5,$DEBUGGING:5,"$^D":5,"${^ENCODING}":5,$ENV:5,"%ENV":5,$SYSTEM_FD_MAX:5,"$^F":5,"@F":5,"${^GLOBAL_PHASE}":5,"$^H":5,"%^H":5,"@INC":5,"%INC":5,$INPLACE_EDIT:5,"$^I":5,"$^M":5,$OSNAME:5,"$^O":5,"${^OPEN}":5,$PERLDB:5,"$^P":5,$SIG:5,"%SIG":5,$BASETIME:5,"$^T":5,"${^TAINT}":5,"${^UNICODE}":5,"${^UTF8CACHE}":5,"${^UTF8LOCALE}":5,$PERL_VERSION:5,"$^V":5,"${^WIN32_SLOPPY_STAT}":5,$EXECUTABLE_NAME:5,"$^X":5,$1:5,$MATCH:5,"$&":5,"${^MATCH}":5,$PREMATCH:5,"$`":5,"${^PREMATCH}":5,$POSTMATCH:5,"$'":5,"${^POSTMATCH}":5,$LAST_PAREN_MATCH:5,"$+":5,$LAST_SUBMATCH_RESULT:5,"$^N":5,"@LAST_MATCH_END":5,"@+":5,"%LAST_PAREN_MATCH":5,"%+":5,"@LAST_MATCH_START":5,"@-":5,"%LAST_MATCH_START":5,"%-":5,$LAST_REGEXP_CODE_RESULT:5,"$^R":5,"${^RE_DEBUG_FLAGS}":5,"${^RE_TRIE_MAXBUF}":5,$ARGV:5,"@ARGV":5,ARGV:5,ARGVOUT:5,$OUTPUT_FIELD_SEPARATOR:5,$OFS:5,"$,":5,$INPUT_LINE_NUMBER:5,$NR:5,"$.":5,$INPUT_RECORD_SEPARATOR:5,$RS:5,"$/":5,$OUTPUT_RECORD_SEPARATOR:5,$ORS:5,"$\\":5,$OUTPUT_AUTOFLUSH:5,"$|":5,$ACCUMULATOR:5,"$^A":5,$FORMAT_FORMFEED:5,"$^L":5,$FORMAT_PAGE_NUMBER:5,"$%":5,$FORMAT_LINES_LEFT:5,"$-":5,$FORMAT_LINE_BREAK_CHARACTERS:5,"$:":5,$FORMAT_LINES_PER_PAGE:5,"$=":5,$FORMAT_TOP_NAME:5,"$^":5,$FORMAT_NAME:5,"$~":5,"${^CHILD_ERROR_NATIVE}":5,$EXTENDED_OS_ERROR:5,"$^E":5,$EXCEPTIONS_BEING_CAUGHT:5,"$^S":5,$WARNING:5,"$^W":5,"${^WARNING_BITS}":5,$OS_ERROR:5,$ERRNO:5,"$!":5,"%OS_ERROR":5,"%ERRNO":5,"%!":5,$CHILD_ERROR:5,"$?":5,$EVAL_ERROR:5,"$@":5,$OFMT:5,"$#":5,"$*":5,$ARRAY_BASE:5,"$[":5,$OLD_PERL_VERSION:5,"$]":5,if:[1,1],elsif:[1,1],else:[1,1],while:[1,1],unless:[1,1],for:[1,1],foreach:[1,1],abs:1,accept:1,alarm:1,atan2:1,bind:1,binmode:1,bless:1,bootstrap:1,break:1,caller:1,chdir:1,chmod:1,chomp:1,chop:1,chown:1,chr:1,chroot:1,close:1,closedir:1,connect:1,continue:[1,1],cos:1,crypt:1,dbmclose:1,dbmopen:1,default:1,defined:1,delete:1,die:1,do:1,dump:1,each:1,endgrent:1,endhostent:1,endnetent:1,endprotoent:1,endpwent:1,endservent:1,eof:1,eval:1,exec:1,exists:1,exit:1,exp:1,fcntl:1,fileno:1,flock:1,fork:1,format:1,formline:1,getc:1,getgrent:1,getgrgid:1,getgrnam:1,gethostbyaddr:1,gethostbyname:1,gethostent:1,getlogin:1,getnetbyaddr:1,getnetbyname:1,getnetent:1,getpeername:1,getpgrp:1,getppid:1,getpriority:1,getprotobyname:1,getprotobynumber:1,getprotoent:1,getpwent:1,getpwnam:1,getpwuid:1,getservbyname:1,getservbyport:1,getservent:1,getsockname:1,getsockopt:1,given:1,glob:1,gmtime:1,goto:1,grep:1,hex:1,import:1,index:1,int:1,ioctl:1,join:1,keys:1,kill:1,last:1,lc:1,lcfirst:1,length:1,link:1,listen:1,local:2,localtime:1,lock:1,log:1,lstat:1,m:null,map:1,mkdir:1,msgctl:1,msgget:1,msgrcv:1,msgsnd:1,my:2,new:1,next:1,no:1,oct:1,open:1,opendir:1,ord:1,our:2,pack:1,package:1,pipe:1,pop:1,pos:1,print:1,printf:1,prototype:1,push:1,q:null,qq:null,qr:null,quotemeta:null,qw:null,qx:null,rand:1,read:1,readdir:1,readline:1,readlink:1,readpipe:1,recv:1,redo:1,ref:1,rename:1,require:1,reset:1,return:1,reverse:1,rewinddir:1,rindex:1,rmdir:1,s:null,say:1,scalar:1,seek:1,seekdir:1,select:1,semctl:1,semget:1,semop:1,send:1,setgrent:1,sethostent:1,setnetent:1,setpgrp:1,setpriority:1,setprotoent:1,setpwent:1,setservent:1,setsockopt:1,shift:1,shmctl:1,shmget:1,shmread:1,shmwrite:1,shutdown:1,sin:1,sleep:1,socket:1,socketpair:1,sort:1,splice:1,split:1,sprintf:1,sqrt:1,srand:1,stat:1,state:1,study:1,sub:1,substr:1,symlink:1,syscall:1,sysopen:1,sysread:1,sysseek:1,system:1,syswrite:1,tell:1,telldir:1,tie:1,tied:1,time:1,times:1,tr:null,truncate:1,uc:1,ucfirst:1,umask:1,undef:1,unlink:1,unpack:1,unshift:1,untie:1,use:1,utime:1,values:1,vec:1,wait:1,waitpid:1,wantarray:1,warn:1,whenLine truncated
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("cmake",function(){var i=/({)?[a-zA-Z0-9_]+(})?/;function r(e,n){for(var t,i,r=!1;!e.eol()&&(t=e.next())!=n.pending;){if("$"===t&&"\\"!=i&&'"'==n.pending){r=!0;break}i=t}return r&&e.backUp(1),t==n.pending?n.continueString=!1:n.continueString=!0,"string"}return{startState:function(){var e={inDefinition:!1,inInclude:!1,continueString:!1,pending:!1};return e},token:function(e,n){return e.eatSpace()?null:(n=n,"$"===(t=(e=e).next())?e.match(i)?"variable-2":"variable":n.continueString?(e.backUp(1),r(e,n)):e.match(/(\s+)?\w+\(/)||e.match(/(\s+)?\w+\ \(/)?(e.backUp(1),"def"):"#"==t?(e.skipToEnd(),"comment"):"'"==t||'"'==t?(n.pending=t,r(e,n)):"("==t||")"==t?"bracket":t.match(/[0-9]/)?"number":(e.eatWhile(/[\w-]/),null));var t}}}),e.defineMIME("text/x-cmake","cmake")});;
+528
View File
@@ -0,0 +1,528 @@
// Light previews of office files for the file manager, with no libraries:
// docx, xlsx and ods are zip archives of XML, opened with the browser's own
// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
// gives up its text. Each turns into plain HTML the page styles itself.
(function () {
'use strict';
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
// ---------- zip ----------
// Reads the central directory; a file is inflated only when asked for.
function openZip(buf) {
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
let eocd = -1;
for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('не zip-архив');
const count = dv.getUint16(eocd + 10, true);
let p = dv.getUint32(eocd + 16, true);
const files = new Map();
const dec = new TextDecoder();
for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
const local = dv.getUint32(p + 42, true);
files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
p += 46 + nameLen + extra + comment;
}
async function bytes(name) {
const f = files.get(name);
if (!f) return null;
const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
const raw = b.subarray(start, start + f.size);
if (f.method === 0) return raw;
if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(out).arrayBuffer());
}
async function xml(name) {
const raw = await bytes(name);
return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
}
return {files, bytes, xml};
}
// Children of an element by local name, whatever the namespace prefix.
const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
const kid = (el, name) => kids(el, name)[0] || null;
const attr = (el, name) => {
if (!el) return null;
for (const a of el.attributes) if (a.localName === name) return a.value;
return null;
};
// ---------- docx ----------
async function docx(buf) {
const z = openZip(buf);
const doc = await z.xml('word/document.xml');
if (!doc) throw new Error('в архиве нет word/document.xml');
// Style ids to names, to know headings whatever the language of Word.
const styles = {};
const st = await z.xml('word/styles.xml');
if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
}
const body = doc.getElementsByTagNameNS('*', 'body')[0];
return '<div class="dv-doc">' + blocks(body, styles) + '</div>';
}
function blocks(el, styles) {
let html = '', list = false;
for (const c of el ? el.children : []) {
if (c.localName === 'p') {
const pPr = kid(c, 'pPr');
const isList = !!kid(pPr, 'numPr');
if (isList && !list) { html += '<ul>'; list = true; }
if (!isList && list) { html += '</ul>'; list = false; }
html += para(c, pPr, styles, isList);
} else {
if (list) { html += '</ul>'; list = false; }
if (c.localName === 'tbl') html += table(c, styles);
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
}
}
return html + (list ? '</ul>' : '');
}
function para(p, pPr, styles, isList) {
const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
const h = /^heading (\d)/.exec(name);
const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
const jc = attr(kid(pPr, 'jc'), 'val');
const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
const inner = runs(p);
return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || '&nbsp;') + '</' + tag + '>';
}
function runs(el) {
let html = '';
for (const c of el.children) {
if (c.localName === 'r') html += run(c);
else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
const inner = runs(c);
html += c.localName === 'hyperlink' ? '<u class="dv-link">' + inner + '</u>' : inner;
}
}
return html;
}
function run(r) {
const pr = kid(r, 'rPr');
const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
let t = '';
for (const c of r.children) {
if (c.localName === 't') t += esc(c.textContent);
else if (c.localName === 'tab') t += '&emsp;';
else if (c.localName === 'br' || c.localName === 'cr') t += '<br>';
}
if (!t) return '';
if (on('b')) t = '<b>' + t + '</b>';
if (on('i')) t = '<i>' + t + '</i>';
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '<u>' + t + '</u>';
if (on('strike')) t = '<s>' + t + '</s>';
// A size set on the run itself, as many documents do instead of styles.
const sz = +attr(kid(pr, 'sz'), 'val');
if (sz && sz !== 24) t = '<span style="font-size:' + Math.max(9, Math.min(36, sz / 2)) + 'pt">' + t + '</span>';
const va = attr(kid(pr, 'vertAlign'), 'val');
if (va === 'superscript') t = '<sup>' + t + '</sup>';
else if (va === 'subscript') t = '<sub>' + t + '</sub>';
return t;
}
function table(tbl, styles) {
let html = '<div class="dv-scroll"><table class="dv-table">';
for (const tr of kids(tbl, 'tr')) {
html += '<tr>';
for (const tc of kids(tr, 'tc')) {
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
html += '<td' + (span > 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + '</td>';
}
html += '</tr>';
}
return html + '</table></div>';
}
// ---------- tables: one grid per sheet ----------
const MAX_ROWS = 2000, MAX_COLS = 60;
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
function grid(rows, names) {
let cols = names ? names.length : 0;
for (const r of rows) cols = Math.max(cols, r.length);
cols = Math.min(cols, MAX_COLS);
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
let html = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
for (let c = 0; c < cols; c++) html += '<th>' + esc(names && names[c] != null ? names[c] : colName(c)) + '</th>';
html += '</tr></thead><tbody>';
rows.slice(0, MAX_ROWS).forEach((r, i) => {
html += '<tr><th>' + (i + 1) + '</th>';
for (let c = 0; c < cols; c++) {
const v = r[c] == null ? '' : r[c];
html += '<td' + (typeof v === 'number' ? ' class="n"' : '') + '>' + esc(v) + '</td>';
}
html += '</tr>';
});
html += '</tbody></table></div>';
return {html, cut};
}
// Several sheets: tabs over the grid.
function sheets(list) {
if (!list.length) return '<p class="dv-note">в книге нет листов</p>';
let html = '';
if (list.length > 1) {
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
}
list.forEach((s, i) => {
const g = grid(s.rows, s.cols);
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + (s.note ? '<p class="dv-note">' + esc(s.note) + '</p>' : '') + g.html +
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
});
return html;
}
function bindTabs(root) {
root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => {
root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b));
root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet);
});
}
// ---------- xlsx ----------
async function xlsx(buf) {
const z = openZip(buf);
const wb = await z.xml('xl/workbook.xml');
if (!wb) throw new Error('в архиве нет xl/workbook.xml');
const rels = {};
const rx = await z.xml('xl/_rels/workbook.xml.rels');
if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target');
const shared = [];
const ss = await z.xml('xl/sharedStrings.xml');
if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) {
shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join(''));
}
const list = [];
for (const s of wb.getElementsByTagNameNS('*', 'sheet')) {
let target = rels[attr(s, 'id')] || '';
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, '');
const doc = await z.xml(target);
list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []});
}
return sheets(list);
}
function xlsxRows(doc, shared) {
const rows = [];
for (const row of doc.getElementsByTagNameNS('*', 'row')) {
const ri = (+row.getAttribute('r') || rows.length + 1) - 1;
if (ri >= MAX_ROWS) break;
const out = rows[ri] = rows[ri] || [];
let next = 0;
for (const c of kids(row, 'c')) {
const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || '');
let ci = next;
if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; }
next = ci + 1;
if (ci >= MAX_COLS + 1) continue;
const t = c.getAttribute('t'), v = kid(c, 'v');
let val = v ? v.textContent : '';
if (t === 's') val = shared[+val] || '';
else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join('');
else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE';
else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val;
out[ci] = val;
}
}
for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || [];
return rows;
}
// ---------- ods / odt ----------
async function ods(buf) {
const z = openZip(buf);
const doc = await z.xml('content.xml');
if (!doc) throw new Error('в архиве нет content.xml');
const list = [];
for (const t of doc.getElementsByTagNameNS('*', 'table')) {
if (t.parentElement && t.parentElement.localName === 'table-cell') continue;
const rows = [];
for (const r of t.getElementsByTagNameNS('*', 'table-row')) {
const cells = [];
for (const c of r.children) {
if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue;
const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length);
const num = attr(c, 'value');
const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n');
const val = attr(c, 'value-type') === 'float' && num != null ? +num : text;
for (let i = 0; i < rep; i++) cells.push(val);
if (cells.length > MAX_COLS) break;
}
while (cells.length && cells[cells.length - 1] === '') cells.pop();
const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length);
for (let i = 0; i < rep; i++) rows.push(cells);
if (rows.length > MAX_ROWS) break;
}
while (rows.length && !rows[rows.length - 1].length) rows.pop();
list.push({name: attr(t, 'name') || '?', rows});
}
return sheets(list);
}
async function odt(buf) {
const z = openZip(buf);
const doc = await z.xml('content.xml');
const body = doc && doc.getElementsByTagNameNS('*', 'text')[0];
if (!body) throw new Error('в архиве нет текста');
// Runs of spaces, tabs and breaks are elements of their own in ODF.
const inline = el => {
let html = '';
for (const n of el.childNodes) {
if (n.nodeType === 3) html += esc(n.nodeValue);
else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1);
else if (n.localName === 'tab') html += '&emsp;';
else if (n.localName === 'line-break') html += '<br>';
else if (n.nodeType === 1) html += inline(n);
}
return html;
};
const walk = el => {
let html = '';
for (const c of el.children) {
if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '<h' + l + '>' + inline(c) + '</h' + l + '>'; }
else if (c.localName === 'p') html += '<p>' + (inline(c) || '&nbsp;') + '</p>';
else if (c.localName === 'list') html += '<ul>' + kids(c, 'list-item').map(li => '<li>' + walk(li) + '</li>').join('') + '</ul>';
else if (c.localName === 'table') html += '<div class="dv-scroll"><table class="dv-table">' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r =>
'<tr>' + kids(r, 'table-cell').map(td => '<td>' + walk(td) + '</td>').join('') + '</tr>').join('') + '</table></div>';
else if (c.localName === 'section') html += walk(c);
}
return html;
};
return '<div class="dv-doc">' + walk(body) + '</div>';
}
// ---------- csv / tsv ----------
function csv(text, tab) {
const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length);
const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ',');
const rows = [];
let row = [], f = '', q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
else f += c;
} else if (c === '"' && f === '') q = true;
else if (c === sep) { row.push(f); f = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(f); f = ''; rows.push(row); row = [];
if (rows.length > MAX_ROWS) break;
} else f += c;
}
if (f !== '' || row.length) { row.push(f); rows.push(row); }
return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]);
}
// ---------- old .doc ----------
// Word 97-2003 is a binary format; without a full parser all that can be
// shown is the text, picked out of the document's WordDocument stream as
// the runs that read as words.
function cfbStream(b, want) {
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
if (dv.getUint32(0, true) !== 0xE011CFD0) return null;
const ss = 1 << dv.getUint16(0x1E, true);
const sector = n => 512 + n * ss;
const fat = [];
const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); };
for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); }
for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) {
const o = sector(n);
for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); }
n = dv.getUint32(o + ss - 4, true);
}
const chain = (start, size) => {
const out = new Uint8Array(size);
let n = start, at = 0;
for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) {
const o = sector(n), len = Math.min(ss, size - at);
if (o + len > b.length) break;
out.set(b.subarray(o, o + len), at);
at += len;
n = fat[n];
}
return out.subarray(0, at);
};
const dirStart = dv.getUint32(0x30, true);
const dir = chain(dirStart, ss * 64);
const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
for (let o = 0; o + 128 <= dir.length; o += 128) {
const nameLen = dd.getUint16(o + 0x40, true);
let name = '';
for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true));
if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true));
}
return null;
}
function doc(buf) {
const all = new Uint8Array(buf);
const b = cfbStream(all, 'WordDocument') || all;
const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116;
const pieces = [];
let cur = '';
const flush = min => {
const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur);
cur = '';
};
for (let i = 0; i + 1 < b.length; i += 2) {
const c = b[i] | (b[i + 1] << 8);
if (ok(c)) cur += String.fromCharCode(c); else flush(3);
}
flush(4);
let text = pieces.join('\n');
if (text.length < 40) { // a document that keeps its text 8-bit
const p8 = [];
for (let i = 0; i < b.length; i++) {
const c = b[i];
if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c);
else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; }
}
text = p8.join('\n');
}
if (!text.trim()) throw new Error('текст не нашёлся');
return '<p class="dv-note">старый формат .doc: показан только текст, без оформления</p><div class="dv-doc">' +
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
}
// ---------- sqlite ----------
// Reads the database file itself: the b-tree of sqlite_master for the
// tables, then each table's b-tree for its first rows. No SQL engine.
function sqlite(buf) {
const b = new Uint8Array(buf);
if (new TextDecoder().decode(b.subarray(0, 15)) !== 'SQLite format 3') throw new Error('не база SQLite');
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
let ps = dv.getUint16(16);
if (ps === 1) ps = 65536;
const usable = ps - b[20];
const enc = dv.getUint32(56);
const dec = new TextDecoder(enc === 2 ? 'utf-16le' : enc === 3 ? 'utf-16be' : 'utf-8');
const page = n => (n - 1) * ps;
function varint(o) {
let v = 0;
for (let i = 0; i < 9; i++) {
const c = b[o + i];
if (i === 8) return [v * 256 + c, o + 9];
v = v * 128 + (c & 0x7f);
if (!(c & 0x80)) return [v, o + i + 1];
}
}
// A cell's payload, gathered from its overflow pages when it spills.
function payload(o, size) {
const max = usable - 35;
if (size <= max) return b.subarray(o, o + size);
const min = Math.floor((usable - 12) * 32 / 255) - 23;
let local = min + (size - min) % (usable - 4);
if (local > max) local = min;
const out = new Uint8Array(size);
out.set(b.subarray(o, o + local));
let at = local, next = dv.getUint32(o + local);
for (let guard = 0; next && at < size && guard < 1e6; guard++) {
const po = page(next), len = Math.min(usable - 4, size - at);
out.set(b.subarray(po + 4, po + 4 + len), at);
at += len;
next = dv.getUint32(po);
}
return out;
}
function record(p) {
const pv = new DataView(p.buffer, p.byteOffset, p.byteLength);
let [hlen, o] = vi(p, 0);
const types = [];
while (o < hlen) { const r = vi(p, o); types.push(r[0]); o = r[1]; }
let d = hlen;
return types.map(t => {
let v = null;
if (t >= 1 && t <= 6) {
const n = [0, 1, 2, 3, 4, 6, 8][t];
let x = 0;
for (let i = 0; i < n; i++) x = x * 256 + p[d + i];
if (p[d] & 0x80) x -= Math.pow(2, 8 * n);
v = n === 8 && Math.abs(x) > Number.MAX_SAFE_INTEGER ? pv.getBigInt64(d).toString() : x;
d += n;
} else if (t === 7) { v = pv.getFloat64(d); d += 8; }
else if (t === 8) v = 0;
else if (t === 9) v = 1;
else if (t >= 12 && t % 2 === 0) { const n = (t - 12) / 2; v = '[blob ' + n + ' B]'; d += n; }
else if (t >= 13) { const n = (t - 13) / 2; v = dec.decode(p.subarray(d, d + n)); d += n; }
return v;
});
}
// varint over a payload buffer rather than the file
function vi(p, o) {
let v = 0;
for (let i = 0; i < 9; i++) {
const c = p[o + i];
if (i === 8) return [v * 256 + c, o + 9];
v = v * 128 + (c & 0x7f);
if (!(c & 0x80)) return [v, o + i + 1];
}
}
// Walks a table b-tree in rowid order, up to limit rows; also counts all.
function table(root, limit) {
const rows = [];
let count = 0;
const walk = (n, depth) => {
if (depth > 40) return;
const h = page(n) + (n === 1 ? 100 : 0);
const type = b[h], cells = dv.getUint16(h + 3);
const ptrs = h + (type === 0x05 ? 12 : 8);
if (type === 0x0d) {
count += cells;
for (let i = 0; i < cells && rows.length < limit; i++) {
let o = page(n) + dv.getUint16(ptrs + i * 2), size, rowid;
[size, o] = varint(o);
[rowid, o] = varint(o);
rows.push({rowid, values: record(payload(o, size))});
}
} else if (type === 0x05) {
for (let i = 0; i < cells; i++) walk(dv.getUint32(page(n) + dv.getUint16(ptrs + i * 2)), depth + 1);
walk(dv.getUint32(h + 8), depth + 1);
}
};
walk(root, 0);
return {rows, count};
}
// Column names out of CREATE TABLE; an INTEGER PRIMARY KEY is the rowid.
function columns(sql) {
const m = /\(([\s\S]*)\)/.exec(sql || '');
if (!m) return {names: [], rowidCol: -1};
const parts = [];
let depth = 0, cur = '';
for (const c of m[1]) {
if (c === '(') depth++;
if (c === ')') depth--;
if (c === ',' && !depth) { parts.push(cur); cur = ''; } else cur += c;
}
parts.push(cur);
const names = [];
let rowidCol = -1;
for (const p of parts) {
const t = p.trim();
if (!t || /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i.test(t)) continue;
const nm = /^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/.exec(t)[1].replace(/^["`\[]|["`\]]$/g, '');
if (/^\S+\s+integer\s+primary\s+key\b/i.test(t.replace(/^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\])/, 'x'))) rowidCol = names.length;
names.push(nm);
}
return {names, rowidCol};
}
const master = table(1, 100000).rows.map(r => ({type: r.values[0], name: r.values[1], root: r.values[3], sql: r.values[4] || ''}));
const list = [];
for (const t of master.filter(x => x.type === 'table' && x.root).sort((a, c) => String(a.name).localeCompare(String(c.name)))) {
if (/without\s+rowid/i.test(t.sql)) { list.push({name: t.name, rows: [], note: 'таблица WITHOUT ROWID - её строки здесь не показываются'}); continue; }
const {names, rowidCol} = columns(t.sql);
let data;
try { data = table(t.root, MAX_ROWS); } catch (e) { list.push({name: t.name, rows: [], note: 'не удалось прочитать: ' + e.message}); continue; }
const rows = data.rows.map(r => { const v = r.values.slice(); if (rowidCol >= 0 && v[rowidCol] == null) v[rowidCol] = r.rowid; return v; });
list.push({name: t.name, cols: names, rows, note: data.count + ' ' + (data.count % 10 === 1 && data.count % 100 !== 11 ? 'строка' : data.count % 10 >= 2 && data.count % 10 <= 4 && (data.count % 100 < 12 || data.count % 100 > 14) ? 'строки' : 'строк') + (data.count > rows.length ? ', показаны первые ' + rows.length : '')});
}
if (!list.length) return '<p class="dv-note">в базе нет таблиц</p>';
return sheets(list);
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
})();
+6 -6
View File
@@ -21,16 +21,16 @@
<h1>Машины-клиенты</h1>
<div class="card">
{{if .Agents}}
<table>
<table class="stack">
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
<tbody>
{{range .Agents}}
<tr data-agent="{{.ID}}">
<td class="nowrap"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
<td>{{.Name}}</td>
<td class="muted">{{.Hostname}}</td>
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
<td class="muted nowrap seen">{{.Seen}}</td>
<td class="nowrap" data-l="Статус"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
<td class="lead">{{.Name}}</td>
<td class="muted" data-l="Hostname">{{.Hostname}}</td>
<td class="muted nowrap" data-l="ОС / Арх">{{.OS}}/{{.Arch}}</td>
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
<td class="actions">
<div class="row" style="gap:6px">
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
+840 -51
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/codemirror/codemirror.css">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */
@@ -19,6 +20,23 @@
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
border-radius:50%; animation:spin .7s linear infinite; display:none; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Busy indicator; while files upload it also shows how far they got, and a
click on it lists them. */
.busy { display:inline-flex; align-items:center; gap:6px; background:none; border:0; padding:0 4px;
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
.busy:hover { background:none; }
.busy.up { cursor:pointer; color:var(--text); }
.busy.up:hover { color:var(--accent); }
#updlg { max-width:480px; }
.upl-item { padding:8px 0; border-bottom:1px solid var(--border); }
.upl-item:last-child { border-bottom:0; }
.upl-row { display:flex; gap:12px; justify-content:space-between; font-size:14px; }
.upl-row .n { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.upl-row .st { flex:none; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
.upl-row .st.ok { color:var(--ok); }
.upl-row .st.bad { color:var(--danger); }
.upl-bar { height:4px; margin-top:6px; border-radius:2px; background:#0c0f13; overflow:hidden; }
.upl-bar i { display:block; height:100%; background:var(--accent); transform-origin:left; transition:transform .2s; }
#drop { border:1px dashed var(--border); border-radius:10px; }
#drop.drag { border-color:var(--accent); background:#12202f; }
table.fm td, table.fm th { padding:7px 8px; }
@@ -27,15 +45,134 @@
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */
table.fm th.srt { cursor:pointer; user-select:none; white-space:nowrap; }
table.fm th.srt:hover { color:var(--text); }
table.fm .c-sz { width:120px; }
table.fm .c-mt { width:160px; }
table.fm .c-act { width:160px; }
.fm .nm { overflow-wrap:anywhere; }
/* Phones: no date column, and the row buttons only as wide as they are. */
@media (max-width: 640px) {
table.fm .c-mt, table.fm td.mt { display:none; }
table.fm .c-sz { width:auto; white-space:nowrap; }
table.fm .c-act { width:1%; }
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
}
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); }
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
.dlg-b { padding:16px 18px; }
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
border:1px solid var(--border); border-radius:8px; padding:12px;
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
#save { display:inline-flex; align-items:center; gap:8px; }
#save.saving { opacity:.85; cursor:progress; }
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
border-radius:50%; animation:spin .7s linear infinite; }
#save[hidden] { display:none; }
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
#viewer { max-width:1100px; }
#viewer .dlg-h { flex-wrap:wrap; }
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
.vmsg { width:100%; order:9; font-size:12px; }
.vbody { padding:0; }
#vcode .CodeMirror { height:70vh; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
.vmedia { display:flex; align-items:center; justify-content:center; min-height:200px; max-height:75vh; padding:16px;
background:#0c0f13 repeating-conic-gradient(#151a21 0 25%, #0c0f13 0 50%) 0 0/20px 20px; border-radius:0 0 12px 12px; }
.vmedia img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; }
.vmedia audio { width:min(100%, 520px); }
/* Find and replace, over the editor: both fields the same width, the
buttons of each row after its field. */
.find { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:6px 8px; align-items:center;
padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
.find .fin { position:relative; grid-column:1; }
.find input { width:100%; height:32px; padding:4px 76px 4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; }
.find #frep { padding-right:10px; }
.find input.bad { border-color:var(--danger); }
.find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none;
color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; }
.find .fbtns { grid-column:2; justify-self:end; display:flex; gap:6px; }
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px;
display:inline-flex; align-items:center; justify-content:center; }
.find button.tg { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:ui-monospace,Menlo,monospace; }
.find button.tg.on { border-color:var(--accent); color:var(--text); background:#12202f; }
.find #frow2, .find #frow2b { grid-row:2; }
/* Documents and tables, drawn in the panel's colours. */
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
.dv-doc p { margin:0 0 .7em; }
.dv-doc h1, .dv-doc h2, .dv-doc h3, .dv-doc h4, .dv-doc h5, .dv-doc h6 { margin:1.2em 0 .5em; line-height:1.3; }
.dv-doc h1 { font-size:26px; } .dv-doc h2 { font-size:21px; } .dv-doc h3 { font-size:18px; } .dv-doc h4, .dv-doc h5, .dv-doc h6 { font-size:16px; }
.dv-doc ul { margin:0 0 .7em; padding-left:1.4em; }
.dv-doc li p { margin:0; }
.dv-doc .dv-link { color:var(--accent); }
.dv-table { border-collapse:collapse; margin:.4em 0 1em; width:auto; }
.dv-table td { border:1px solid var(--border); padding:6px 10px; vertical-align:top; font-size:14px; }
.dv-table td p { margin:0; }
.dv-scroll { overflow:auto; }
.dv-grid { max-height:100%; }
.dv-sheet { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
.dv-sheet th, .dv-sheet td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:4px 10px;
white-space:nowrap; max-width:360px; overflow:hidden; text-overflow:ellipsis; text-align:left; }
.dv-sheet thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--muted); font-weight:500; text-align:center; }
.dv-sheet tbody th { position:sticky; left:0; background:var(--panel); color:var(--muted); font-weight:500; text-align:right; }
.dv-sheet thead th:first-child { left:0; z-index:2; }
.dv-sheet td.n { text-align:right; }
.dv-tabs { position:sticky; top:0; z-index:3; display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--border); }
.dv-tabs button { height:28px; padding:0 12px; font-size:13px; background:transparent; border:1px solid var(--border); color:var(--muted); }
.dv-tabs button.on { border-color:var(--accent); color:var(--text); background:#12202f; }
.dv-note { color:var(--muted); font-size:13px; padding:12px 16px; margin:0; }
/* Hex editor: offset, 16 bytes, the same as text. Only the rows in sight are drawn. */
.hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13;
font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; }
.hexed .hx-rows { position:absolute; left:0; right:0; padding:0 12px; }
.hx-r { display:flex; gap:18px; height:20px; white-space:pre; }
.hx-o { color:var(--muted); }
.hx-b i, .hx-a i { font-style:normal; cursor:text; }
.hx-b i { margin-right:6px; }
.hx-b i:nth-child(8) { margin-right:14px; }
.hx-a { color:#9aa4b2; }
.hx-r i.ch { color:#ffcb6b; }
.hx-r i.cur { background:var(--accent); color:#fff; border-radius:2px; }
.hx-r i.cur2 { outline:1px solid var(--accent); border-radius:2px; }
/* Editor colours, in the panel's palette. */
.cm-s-lpra.CodeMirror { background:#0c0f13; color:#d7dce4; }
.cm-s-lpra .CodeMirror-gutters { background:#0c0f13; border-right:1px solid var(--border); }
.cm-s-lpra .CodeMirror-linenumber { color:#4b5563; }
.cm-s-lpra .CodeMirror-cursor { border-left:2px solid #e6e9ef; }
.cm-s-lpra .CodeMirror-selected, .cm-s-lpra.CodeMirror-focused .CodeMirror-selected { background:rgba(79,157,255,.28); }
.cm-s-lpra .CodeMirror-line::selection, .cm-s-lpra .CodeMirror-line > span::selection { background:rgba(79,157,255,.28); }
.cm-s-lpra span.cm-keyword { color:#c792ea; }
.cm-s-lpra span.cm-def { color:#82aaff; }
.cm-s-lpra span.cm-variable-2, .cm-s-lpra span.cm-variable-3, .cm-s-lpra span.cm-type { color:#ffcb6b; }
.cm-s-lpra span.cm-builtin { color:#ffcb6b; }
.cm-s-lpra span.cm-string, .cm-s-lpra span.cm-string-2 { color:#c3e88d; }
.cm-s-lpra span.cm-number, .cm-s-lpra span.cm-atom { color:#f78c6c; }
.cm-s-lpra span.cm-comment { color:#6b7686; font-style:italic; }
.cm-s-lpra span.cm-meta, .cm-s-lpra span.cm-qualifier { color:#89ddff; }
.cm-s-lpra span.cm-operator { color:#89ddff; }
.cm-s-lpra span.cm-property { color:#82aaff; }
.cm-s-lpra span.cm-tag { color:#f07178; }
.cm-s-lpra span.cm-attribute { color:#ffcb6b; }
.cm-s-lpra span.cm-header { color:#82aaff; font-weight:600; }
.cm-s-lpra span.cm-link { color:#89ddff; text-decoration:underline; }
.cm-s-lpra span.cm-error { color:#ff5c5c; }
.cm-s-lpra span.cm-positive { color:#37c871; }
.cm-s-lpra span.cm-negative { color:#ff5c5c; }
.cm-s-lpra .CodeMirror-matchingbracket { color:#fff !important; outline:1px solid #4f9dff; }
.cm-s-lpra .cm-searching { background:rgba(255,203,107,.32); border-radius:2px; }
@media (max-width: 640px) {
#vcode .CodeMirror, .hexed { height:calc(100vh - 220px); }
.hexed { font-size:11px; }
.hx-r { gap:10px; }
.hx-b i { margin-right:3px; }
.hx-b i:nth-child(8) { margin-right:8px; }
}
.toast { z-index:50; position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
@@ -57,7 +194,7 @@
<div class="fm-bar">
<button class="btn ghost small" id="up">↑ Вверх</button>
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<span class="spinner" id="spin"></span>
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
<button class="btn ghost small" id="refresh">Обновить</button>
</div>
<div class="fm-bar">
@@ -74,8 +211,8 @@
<table class="fm">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="all"></th>
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
<th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>
<th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>
</tr></thead>
<tbody id="list"></tbody>
</table>
@@ -85,18 +222,54 @@
<dialog id="viewer">
<div class="dlg-h">
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
<button class="btn small" id="save" style="display:none">Сохранить</button>
<button class="btn ghost small" id="closev">Закрыть</button>
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
<button class="btn small" id="save" hidden>Сохранить</button>
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
<div class="vmsg muted" id="vmsg"></div>
</div>
<div class="dlg-b">
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
<textarea id="edit" spellcheck="false"></textarea>
<div class="vbody">
<div class="find" id="find" hidden>
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
<div class="fbtns">
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
<div class="fbtns" id="frow2b">
<button type="button" class="btn ghost" id="frepone">Заменить</button>
<button type="button" class="btn ghost" id="frepall">Все</button>
</div>
</div>
<div id="vcode" hidden></div>
<div class="vmedia" id="vmedia" hidden></div>
<div class="vdoc" id="vdoc" hidden></div>
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
</div>
</dialog>
<dialog id="updlg">
<div class="dlg-h">
<strong style="flex:1">Загрузка файлов</strong>
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="dlg-b" id="updlg-list"></div>
</dialog>
<div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script>
<script src="/static/docview.js"></script>
<script>
(function () {
const agentID = {{.Agent.ID}};
@@ -115,15 +288,25 @@
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
let cwd = '/';
let entries = [];
const selected = new Set();
const $ = id => document.getElementById(id);
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2200); };
// An open dialog sits in the page's top layer, above any z-index: the toast
// goes inside it then, so "сохранено" or an error shows over the editor.
let toastTimer = null;
const toast = (msg, err) => {
const t = $('toast'), host = document.querySelector('dialog[open]') || document.body;
if (t.parentNode !== host) host.appendChild(t);
t.textContent = msg;
t.className = 'toast show' + (err ? ' err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.className = 'toast', err ? 3500 : 2200);
};
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
const bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
@@ -146,20 +329,52 @@
$('path').value = cwd; render();
}
// Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each machine.
const SORT_DEF = {by: 'name', dir: 1};
const sortKey = () => 'lpra.sort:' + agentID + ':' + cwd;
function getSort() {
try { const v = JSON.parse(localStorage.getItem(sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) {}
return SORT_DEF;
}
function setSort(v) {
try {
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(sortKey());
else localStorage.setItem(sortKey(), JSON.stringify(v));
} catch (e) {}
}
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
function sorted() {
const s = getSort();
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
return entries.slice().sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
const d = key ? key(a) - key(b) : 0;
return (d || byName(a, b)) * s.dir;
});
}
document.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
const s = getSort(), by = th.dataset.sort;
// A new column starts the way it is most often wanted: names A to Z,
// sizes and times biggest and newest first.
setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
render();
});
function render() {
const tb = $('list'); tb.innerHTML = '';
updateSel();
for (const e of entries) {
for (const e of sorted()) {
const tr = document.createElement('tr');
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' +
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
@@ -181,30 +396,530 @@
$('zipsel').disabled = !n; $('delsel').disabled = !n;
}
async function view(e) {
const r = await api('read', {path: join(cwd, e.name)});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const d = r.data;
$('vname').textContent = e.name;
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
if (d.binary) {
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
msg.textContent = 'Размер: '+fmtSize(d.size);
} else {
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size <= EDIT_LIMIT && !d.truncated;
ta.readOnly = !editable;
save.style.display = editable ? '' : 'none';
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
save.onclick = async () => {
const bytes = new TextEncoder().encode(ta.value);
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
};
// ---------- File viewer ----------
const fsURL = (what, path) => '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + what + '?path=' + encodeURIComponent(path);
// Sends a file to the client in pieces: each a request of its own (a proxy
// in front takes only so much at once), a few in flight together so a good
// link stays busy, and one the link drops is sent again alone. onFrac gets
// how much has gone; the answer is the client's, once it has written it.
const SEND_WORKERS = 4;
async function sendFile(path, blob, onFrac) {
let st;
try {
st = await fetch(fsURL('upload', path) + '&size=' + blob.size, {method: 'POST'}).then(r => r.json());
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
if (!st.ok) return st;
const {id, chunk} = st.data;
const xurl = '/api/panel/fs/xfer/' + encodeURIComponent(id);
const n = Math.max(1, Math.ceil(blob.size / chunk));
const sent = new Array(n).fill(0);
const report = () => onFrac && onFrac(blob.size ? Math.min(1, sent.reduce((a, b) => a + b, 0) / blob.size) : 1);
let next = 0, failed = null;
const putPiece = (i, part) => new Promise(resolve => {
const x = new XMLHttpRequest();
x.open('PUT', xurl + '/' + i);
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = ev => { sent[i] = ev.loaded; report(); };
x.onload = () => resolve({status: x.status, text: x.responseText});
x.onerror = x.ontimeout = () => resolve({status: 0});
x.timeout = 5 * 60 * 1000;
x.send(part);
});
async function worker() {
while (!failed && next < n) {
const i = next++, part = blob.slice(i * chunk, (i + 1) * chunk);
for (let tries = 0; !failed; ) {
const r = await putPiece(i, part);
if (r.status === 204) { sent[i] = part.size; report(); break; }
if (r.status === 410 || r.status === 400) { failed = (r.text || '').trim() || 'передача прервана'; break; }
sent[i] = 0; report();
if (r.status === 503) continue; // the client is busy: just again
if (++tries >= 30) { failed = 'сеть не держит передачу'; break; }
await new Promise(res => setTimeout(res, Math.min(tries, 10) * 1000));
}
}
}
await Promise.all(Array.from({length: Math.min(SEND_WORKERS, n)}, worker));
if (failed) {
fetch(xurl + '/cancel', {method: 'POST'}).catch(() => {});
return {ok: false, error: failed};
}
for (let tries = 0; ; ) {
try {
const r = await fetch(xurl + '/result', {cache: 'no-store'}).then(r => r.json());
if (r.done) return r.result;
tries = 0;
} catch (e) {
if (++tries >= 30) return {ok: false, error: 'нет ответа о записи файла'};
await new Promise(res => setTimeout(res, 2000));
}
}
$('viewer').showModal();
}
$('closev').onclick = () => $('viewer').close();
// Writes a file as it is, with no base64: the request body is the file.
async function putFile(path, body) {
// The save button turns into a small spinner meanwhile, and takes no
// second click.
const btn = $('save');
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
btn.classList.add('saving');
btn.disabled = true;
setBusy(true);
try {
return await sendFile(path, new Blob([body]));
} finally { setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
}
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?|db|sqlite3?|db3)$/i;
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
// Highlighting by the file's name, then its extension, then its #! line.
const BY_EXT = {
py:'python', pyw:'python', go:'go', c:'text/x-csrc', h:'text/x-csrc', cc:'text/x-c++src', cpp:'text/x-c++src', cxx:'text/x-c++src',
hpp:'text/x-c++src', hh:'text/x-c++src', java:'text/x-java', kt:'text/x-kotlin', kts:'text/x-kotlin', cs:'text/x-csharp', scala:'text/x-scala',
js:'javascript', mjs:'javascript', cjs:'javascript', jsx:'javascript', ts:'text/typescript', tsx:'text/typescript', json:'application/json',
css:'css', scss:'text/x-scss', less:'text/x-less', html:'htmlmixed', htm:'htmlmixed', vue:'htmlmixed', xml:'xml', svg:'xml', plist:'xml', xsd:'xml',
php:'php', sh:'shell', bash:'shell', zsh:'shell', ksh:'shell', yaml:'yaml', yml:'yaml', md:'markdown', markdown:'markdown', sql:'sql',
toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties',
service:'properties', timer:'properties', socket:'properties', mount:'properties', lua:'lua', rs:'rust', diff:'diff', patch:'diff',
rb:'ruby', pl:'perl', pm:'perl', cmake:'cmake'
};
const BY_NAME = {
'dockerfile':'dockerfile', 'containerfile':'dockerfile', 'makefile':'shell', '.bashrc':'shell', '.bash_profile':'shell', '.profile':'shell',
'.zshrc':'shell', '.zprofile':'shell', '.env':'properties', 'cmakelists.txt':'cmake', 'gemfile':'ruby', 'rakefile':'ruby',
'nginx.conf':'nginx', 'go.mod':'go', 'requirements.txt':'properties'
};
function modeFor(path, text) {
const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase();
if (BY_NAME[name]) return BY_NAME[name];
if (/^dockerfile\./.test(name)) return 'dockerfile';
if (/\/nginx\//.test(path) && /\.conf$/.test(name)) return 'nginx';
const dot = name.lastIndexOf('.');
const ext = dot > 0 ? name.slice(dot + 1) : '';
if (BY_EXT[ext]) return BY_EXT[ext];
const bang = /^#!.*?\b(\w+)\s*$/.exec(text.slice(0, 200).split('\n')[0]);
if (bang) return {sh:'shell', bash:'shell', zsh:'shell', dash:'shell', python:'python', python3:'python', node:'javascript', ruby:'ruby', perl:'perl'}[bang[1]] || null;
return null;
}
const modeName = m => !m ? 'текст' : m.replace(/^(text|application)\/(x-)?/, '').replace('c++src', 'c++').replace('csrc', 'c');
let cm = null;
function editor() {
if (cm) return cm;
cm = CodeMirror($('vcode'), {
theme: 'lpra', lineNumbers: true, matchBrackets: true, indentUnit: 4, tabSize: 4,
extraKeys: {
'Ctrl-F': () => openFind(), 'Cmd-F': () => openFind(),
'Ctrl-H': () => openFind(true), 'Cmd-Alt-F': () => openFind(true), 'Shift-Ctrl-F': () => openFind(true),
'Ctrl-S': () => $('save').click(), 'Cmd-S': () => $('save').click(),
'Ctrl-G': () => findStep(1), 'Cmd-G': () => findStep(1), 'Shift-Ctrl-G': () => findStep(-1), 'Shift-Cmd-G': () => findStep(-1),
'Tab': c => c.somethingSelected() ? c.indentSelection('add') : c.replaceSelection(c.getOption('indentWithTabs') ? '\t' : ' '.repeat(c.getOption('indentUnit')))
}
});
cm.on('changes', () => { V.dirty = !cm.isClean(V.clean); if (F.open) refreshFind(); });
return cm;
}
// What the viewer has open now.
const V = { path: '', kind: '', dirty: false, clean: 0 };
function showPane(which) {
for (const [id, k] of [['vcode', 'code'], ['vmedia', 'media'], ['vhexed', 'hex'], ['vdoc', 'doc']]) $(id).hidden = which !== k;
if (which !== 'doc') $('vdoc').innerHTML = '';
if (which !== 'code') closeFind();
}
function setButtons(o) {
$('vtext').hidden = !o.text; $('vhex').hidden = !o.hex; $('save').hidden = !o.save;
}
function openViewer(path) {
V.path = path; V.dirty = false;
$('vname').textContent = path;
$('vname').parentElement.title = path;
if (!$('viewer').open) $('viewer').showModal();
}
async function view(e) {
const path = join(cwd, e.name);
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size);
if (DOC.test(e.name)) return viewDoc(path, e.size);
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
if (r.data.binary) return viewHex(path, r.data.size);
viewText(path, r.data);
}
function viewText(path, d) {
openViewer(path);
V.kind = 'code';
const text = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size <= EDIT_LIMIT && !d.truncated;
const c = editor();
showPane('code');
const mode = modeFor(path, text);
c.setOption('mode', mode);
c.setOption('indentWithTabs', mode === 'go' || /(^|\/)makefile$/i.test(path));
c.setOption('readOnly', !editable);
c.setValue(text);
c.clearHistory();
V.clean = c.changeGeneration();
V.dirty = false;
setButtons({save: editable});
$('frow2').hidden = $('frow2b').hidden = !editable;
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
' · Ctrl/Cmd+F - поиск и замена';
$('save').onclick = async () => {
const w = await putFile(path, new TextEncoder().encode(c.getValue()));
if (w.ok) { V.clean = c.changeGeneration(); V.dirty = false; toast('сохранено'); load(cwd); } else toast(w.error || 'ошибка', true);
};
setTimeout(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }, 0);
}
function viewMedia(path, size) {
openViewer(path);
V.kind = 'media';
showPane('media');
const box = $('vmedia');
box.innerHTML = '';
const el = document.createElement(VID.test(path) ? 'video' : AUD.test(path) ? 'audio' : 'img');
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
else el.alt = path;
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
el.src = fsURL('download', path) + '&inline=1';
box.appendChild(el);
// SVG is text: edited as such, not byte by byte.
const svg = /\.svg$/i.test(path);
setButtons({text: svg, hex: !svg});
$('vmsg').textContent = fmtSize(size);
$('vtext').onclick = async () => {
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
stopMedia();
viewText(path, r.data);
};
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
}
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
async function viewDoc(path, size) {
openViewer(path);
V.kind = 'doc';
showPane('doc');
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
// Text with a preview gets the text editor; the rest, the hex one.
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
setButtons({text: isText, hex: !isText});
$('vtext').onclick = async () => {
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
viewText(path, r.data);
};
$('vhex').onclick = () => viewHex(path, size);
const box = $('vdoc');
$('vmsg').textContent = fmtSize(size);
if (ext === 'pdf') {
const src = fsURL('download', path) + '&inline=1';
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
box.firstChild.src = src;
const a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'открыть в новой вкладке';
$('vmsg').append(' · ', a);
return;
}
if (ext === 'html' || ext === 'htm') {
// The page as it looks, in a frame that runs none of its scripts.
const r = await api('read', {path});
if (V.path !== path || V.kind !== 'doc') return;
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
const f = document.createElement('iframe');
f.className = 'dv-html';
f.setAttribute('sandbox', '');
f.title = 'HTML';
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
box.innerHTML = '';
box.appendChild(f);
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return;
}
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html;
try {
setBusy(true);
const r = await fetch(fsURL('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
const buf = await r.arrayBuffer();
if (V.path !== path || V.kind !== 'doc') return;
html = ext === 'docx' ? await DocView.docx(buf)
: ext === 'odt' ? await DocView.odt(buf)
: ext === 'xlsx' ? await DocView.xlsx(buf)
: ext === 'ods' ? await DocView.ods(buf)
: ext === 'doc' ? DocView.doc(buf)
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
} catch (err) {
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
} finally { setBusy(false); }
if (V.path !== path || V.kind !== 'doc') return;
box.innerHTML = html;
DocView.bindTabs(box);
}
function stopMedia() {
const el = $('vmedia').firstChild;
if (el && el.pause) el.pause();
if (el) el.removeAttribute('src');
$('vmedia').innerHTML = '';
}
// ---------- Hex editor ----------
// Overwrite in place: a hex digit sets half a byte, a character in the text
// column sets a byte. Changed bytes stay marked until saved.
const H = { data: null, orig: null, cur: 0, nib: 0, side: 'hex', changed: 0 };
const ROW = 20;
async function viewHex(path, size) {
if (size > HEX_CAP) { toast('для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')', true); return; }
openViewer(path);
V.kind = 'hex';
showPane('hex');
setButtons({save: true});
$('vmsg').textContent = 'загрузка…';
$('hxrows').innerHTML = '';
let buf;
try {
setBusy(true);
const r = await fetch(fsURL('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
buf = new Uint8Array(await r.arrayBuffer());
} catch (err) {
$('vmsg').textContent = 'не удалось прочитать: ' + err.message;
return;
} finally { setBusy(false); }
if (V.path !== path || V.kind !== 'hex') return;
H.data = buf; H.orig = buf.slice(); H.cur = 0; H.nib = 0; H.side = 'hex'; H.changed = 0;
$('hxsp').style.height = (Math.ceil(buf.length / 16) * ROW + 8) + 'px';
$('vhexed').scrollTop = 0;
drawHex(); hexInfo();
$('vhexed').focus();
$('save').onclick = async () => {
const w = await putFile(path, H.data);
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); load(cwd); }
else toast(w.error || 'ошибка', true);
};
}
const hx2 = b => (b < 16 ? '0' : '') + b.toString(16).toUpperCase();
function drawHex() {
const box = $('vhexed'), n = H.data.length;
const first = Math.max(0, Math.floor(box.scrollTop / ROW) - 2);
const last = Math.min(Math.ceil(n / 16), first + Math.ceil(box.clientHeight / ROW) + 5);
let html = '';
for (let r = first; r < last; r++) {
let hb = '', ha = '';
for (let i = r * 16; i < r * 16 + 16; i++) {
if (i >= n) { hb += '<i> </i>'; continue; }
const b = H.data[i], ch = b !== H.orig[i] ? 'ch' : '';
const at = i === H.cur;
hb += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'hex' ? ' cur' : ' cur2') : '') + '">' + hx2(b) + '</i>';
const c = b >= 32 && b < 127 ? String.fromCharCode(b) : '.';
ha += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'txt' ? ' cur' : ' cur2') : '') + '">' + (c === '<' ? '&lt;' : c === '&' ? '&amp;' : c) + '</i>';
}
html += '<div class="hx-r"><span class="hx-o">' + (r * 16).toString(16).toUpperCase().padStart(8, '0') +
'</span><span class="hx-b">' + hb + '</span><span class="hx-a">' + ha + '</span></div>';
}
const rows = $('hxrows');
rows.style.top = (first * ROW + 4) + 'px';
rows.innerHTML = html;
}
function hexInfo() {
$('vmsg').textContent = fmtSize(H.data.length) + ' · смещение 0x' + H.cur.toString(16).toUpperCase() + ' (' + H.cur + ')' +
(H.changed ? ' · изменено байт: ' + H.changed : '') + ' · Tab - между hex и текстом';
}
function hexMove(to) {
if (!H.data.length) return;
H.cur = Math.max(0, Math.min(H.data.length - 1, to)); H.nib = 0;
const box = $('vhexed'), y = Math.floor(H.cur / 16) * ROW;
if (y < box.scrollTop) box.scrollTop = y;
else if (y + ROW > box.scrollTop + box.clientHeight - 8) box.scrollTop = y + ROW - box.clientHeight + 8;
drawHex(); hexInfo();
}
function hexSet(i, b) {
const was = H.data[i] !== H.orig[i];
H.data[i] = b;
H.changed += (H.data[i] !== H.orig[i] ? 1 : 0) - (was ? 1 : 0);
V.dirty = H.changed > 0;
}
$('vhexed').addEventListener('scroll', () => { if (V.kind === 'hex' && H.data) drawHex(); });
$('vhexed').addEventListener('mousedown', ev => {
const t = ev.target.closest('i[data-i]');
if (!t) return;
H.side = t.parentElement.classList.contains('hx-a') ? 'txt' : 'hex';
hexMove(+t.dataset.i);
});
$('vhexed').addEventListener('keydown', ev => {
if (!H.data || !H.data.length) return;
const mod = ev.ctrlKey || ev.metaKey, page = 16 * Math.max(1, Math.floor($('vhexed').clientHeight / ROW) - 1);
const moves = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -16, ArrowDown: 16, PageUp: -page, PageDown: page };
if (mod && (ev.key === 's' || ev.key === 'S' || ev.code === 'KeyS')) { ev.preventDefault(); $('save').click(); return; }
if (mod) return;
if (moves[ev.key] !== undefined) { ev.preventDefault(); hexMove(H.cur + moves[ev.key]); return; }
if (ev.key === 'Home') { ev.preventDefault(); hexMove(H.cur - H.cur % 16); return; }
if (ev.key === 'End') { ev.preventDefault(); hexMove(H.cur - H.cur % 16 + 15); return; }
if (ev.key === 'Tab') { ev.preventDefault(); H.side = H.side === 'hex' ? 'txt' : 'hex'; H.nib = 0; drawHex(); return; }
if (ev.key.length !== 1) return;
if (H.side === 'hex') {
const h = parseInt(ev.key, 16);
if (isNaN(h)) return;
ev.preventDefault();
const b = H.data[H.cur];
if (H.nib === 0) { hexSet(H.cur, (h << 4) | (b & 15)); H.nib = 1; drawHex(); hexInfo(); }
else { hexSet(H.cur, (b & 0xf0) | h); hexMove(H.cur + 1); }
} else {
const code = ev.key.charCodeAt(0);
if (code < 32 || code > 126) return;
ev.preventDefault();
hexSet(H.cur, code); hexMove(H.cur + 1);
}
});
// ---------- Find and replace ----------
const F = { open: false, overlay: null, caseS: false, re: false };
function findRe(global) {
const q = $('fq').value;
if (!q) return null;
try {
return new RegExp(F.re ? q : q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), (global ? 'g' : '') + (F.caseS ? '' : 'i') + 'm');
} catch (e) { return null; }
}
function openFind(replace) {
if (V.kind !== 'code' || !cm) return;
F.open = true;
$('find').hidden = false;
const sel = cm.getSelection();
if (sel && sel.indexOf('\n') < 0) $('fq').value = sel;
const input = replace && !$('frow2').hidden ? $('frep') : $('fq');
input.focus();
input.select();
refreshFind();
cm.refresh();
}
function closeFind() {
if (!F.open) return;
F.open = false;
$('find').hidden = true;
if (cm && F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
if (cm) { cm.refresh(); if (V.kind === 'code') cm.focus(); }
}
// Marks every match and counts them; the one selected shows as "n из всех".
function refreshFind() {
if (F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
const q = $('fq').value, re = findRe(true);
$('fq').classList.toggle('bad', !!q && !re);
if (!re) { $('fcnt').textContent = q ? 'ошибка' : ''; return; }
F.overlay = { token: stream => {
re.lastIndex = stream.pos;
const m = re.exec(stream.string);
if (m && m.index === stream.pos && m[0].length) { stream.pos += m[0].length; return 'searching'; }
if (m && m.index > stream.pos) { stream.pos = m.index; return null; }
if (m) { stream.next(); return null; }
stream.skipToEnd(); return null;
} };
cm.addOverlay(F.overlay);
countFind();
}
function countFind() {
const re = findRe(false);
if (!re) return;
let n = 0, at = 0;
const from = cm.getCursor('from'), to = cm.getCursor('to');
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
while (cur.findNext() && n < 9999) {
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
n++;
if (!at && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) at = n;
}
$('fcnt').textContent = n ? (at ? at + ' из ' + n : n + (n >= 9999 ? '+' : '')) : 'нет';
}
function findStep(dir, fromStart) {
const re = findRe(false);
if (!re || !cm) return false;
let cur = cm.getSearchCursor(re, fromStart ? {line: 0, ch: 0} : dir > 0 ? cm.getCursor('to') : cm.getCursor('from'));
let ok = dir > 0 ? cur.findNext() : cur.findPrevious();
if (!ok) { // go round the end
cur = cm.getSearchCursor(re, dir > 0 ? {line: 0, ch: 0} : {line: cm.lastLine(), ch: cm.getLine(cm.lastLine()).length});
ok = dir > 0 ? cur.findNext() : cur.findPrevious();
}
if (!ok || CodeMirror.cmpPos(cur.from(), cur.to()) === 0) { countFind(); return false; }
cm.setSelection(cur.from(), cur.to());
cm.scrollIntoView({from: cur.from(), to: cur.to()}, 80);
countFind();
return true;
}
// $1.. and $& stand for the groups of a regular expression's match.
function replacement(m) {
const r = $('frep').value;
return F.re && m ? r.replace(/\$(\d+|&)/g, (_, g) => g === '&' ? m[0] : (m[+g] || '')) : r;
}
function replaceOne() {
const re = findRe(false);
if (!re || cm.getOption('readOnly')) return;
const from = cm.getCursor('from'), to = cm.getCursor('to');
const cur = cm.getSearchCursor(re, from);
const m = cur.findNext();
if (m && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) {
cur.replace(replacement(m));
cm.setCursor(cur.to());
}
findStep(1);
}
function replaceAll() {
const re = findRe(false);
if (!re || cm.getOption('readOnly')) return;
let n = 0;
cm.operation(() => {
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
let m;
while ((m = cur.findNext()) && n < 100000) {
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
cur.replace(replacement(m));
n++;
}
});
toast(n ? 'заменено: ' + n : 'совпадений нет', !n);
countFind();
}
$('fq').addEventListener('input', () => { refreshFind(); findStep(1, true); });
$('fq').addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); findStep(ev.shiftKey ? -1 : 1); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
});
$('frep').addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); ev.ctrlKey || ev.metaKey ? replaceAll() : replaceOne(); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
});
$('fcase').onclick = () => { F.caseS = !F.caseS; $('fcase').classList.toggle('on', F.caseS); refreshFind(); };
$('fre').onclick = () => { F.re = !F.re; $('fre').classList.toggle('on', F.re); refreshFind(); };
$('fnext').onclick = () => findStep(1);
$('fprev').onclick = () => findStep(-1);
$('fclose').onclick = closeFind;
$('frepone').onclick = replaceOne;
$('frepall').onclick = replaceAll;
// Ctrl/Cmd+F anywhere in the viewer opens this search, not the browser's.
$('viewer').addEventListener('keydown', ev => {
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'f' || ev.key === 'F' || ev.code === 'KeyF') && V.kind === 'code') {
ev.preventDefault(); openFind();
}
});
// Closing: Esc first closes the search; unsaved changes are asked about.
function closeViewer() {
if (V.dirty && !confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return;
stopMedia(); closeFind();
H.data = H.orig = null;
V.kind = ''; V.dirty = false;
$('viewer').close();
}
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
$('closev').onclick = closeViewer;
async function del(e) {
if (e.is_dir) {
@@ -266,23 +981,97 @@
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
});
// uploads
async function uploadFiles(files) {
for (const f of files) {
if (f.size > 32*1024*1024) { toast('файл '+f.name+' больше 32 МБ', true); continue; }
const buf = new Uint8Array(await f.arrayBuffer());
const r = await api('write', {path: join(cwd, f.name), data: bytesToB64(buf)});
if (!r.ok) { toast(r.error||('ошибка '+f.name), true); return; }
// uploads: one file at a time, each with its progress. What has gone up
// shows as a percentage by the spinner; a click on it lists the files.
const uploads = [];
let upActive = false, upDoneTimer = null;
const UP_ST = { wait: 'в очереди', send: '', write: 'запись на клиенте…', done: 'готово', fail: 'ошибка' };
function renderUploads() {
const pend = uploads.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
const counted = uploads.filter(u => !u.skip);
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
const busy = $('busy');
busy.classList.toggle('up', uploads.length > 0);
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : '';
const list = $('updlg-list');
if (!$('updlg').open) return;
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
for (const u of uploads) {
const pct = u.state === 'send' ? Math.floor(100 * u.frac) : u.state === 'wait' ? 0 : 100;
const st = u.state === 'send' ? pct + '%' : u.state === 'fail' ? (u.error || UP_ST.fail) : UP_ST[u.state];
const el = document.createElement('div');
el.className = 'upl-item';
el.innerHTML = '<div class="upl-row"><span class="n"></span><span class="st"></span></div><div class="upl-bar"><i></i></div>';
el.querySelector('.n').textContent = u.name + ' · ' + fmtSize(u.size);
const stEl = el.querySelector('.st');
stEl.textContent = st;
stEl.className = 'st' + (u.state === 'done' ? ' ok' : u.state === 'fail' ? ' bad' : '');
el.querySelector('.upl-bar i').style.transform = 'scaleX(' + pct / 100 + ')';
if (u.state === 'fail') el.querySelector('.upl-bar i').style.background = 'var(--danger)';
list.appendChild(el);
}
toast('загружено'); load(cwd);
}
$('upl').onchange = ev => uploadFiles(ev.target.files);
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
$('updlg-close').onclick = () => $('updlg').close();
// The pieces go on to the client as they come, so the bar follows the
// whole way.
const UP_CAP = 512 * 1024 * 1024;
const writeWithProgress = (path, file, onFrac) => sendFile(path, file, onFrac);
async function uploadFiles(files) {
clearTimeout(upDoneTimer);
// A new batch starts a fresh list once the last one is over.
if (!upActive) uploads.length = 0;
const dir = cwd;
const batch = [...files].map(f => f.size > UP_CAP
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
uploads.push(...batch);
renderUploads();
if (upActive) return;
upActive = true;
setBusy(true);
let failed = 0;
// Files refused up front count as failed too.
const refused = () => uploads.filter(u => u.skip).length;
for (let u; (u = uploads.find(x => x.state === 'wait')); ) {
u.state = 'send'; renderUploads();
const r = await writeWithProgress(join(u.dir, u.name), u.file, frac => {
u.frac = frac;
if (frac >= 1 && u.state === 'send') u.state = 'write';
renderUploads();
});
if (r.ok) u.state = 'done';
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
renderUploads();
if (u.dir === cwd) load(cwd);
}
setBusy(false);
upActive = false;
failed += refused();
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
renderUploads();
// The list stays a while for a look, then the indicator goes quiet.
upDoneTimer = setTimeout(() => { if (!upActive) { uploads.length = 0; renderUploads(); } }, failed ? 30000 : 8000);
}
$('upl').onchange = ev => { uploadFiles(ev.target.files); ev.target.value = ''; };
const drop = $('drop');
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
load('/');
// Start in the home directory of the machine's user; an agent too old to
// know "~" fails that listing, so fall back to the root.
(async () => {
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
if (!r.ok) { load('/'); return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd;
render();
})();
})();
</script>
</body>
+56 -33
View File
@@ -6,16 +6,32 @@
<title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<style>
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
.frow label { margin:0 0 6px; }
.frow .fld { display:flex; flex-direction:column; }
.frow select, .frow input, .frow button {
/* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
.frow label { margin:0 0 6px; white-space:nowrap; }
.frow .fld { display:flex; flex-direction:column; flex:none; width:108px; }
.frow .fld.addr { width:128px; }
.frow .fld.grow { flex:1; width:auto; min-width:150px; }
.frow select, .frow input[type=text], .frow button {
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
}
label.check { display:inline-flex; align-items:center; gap:8px; margin:14px 0 0; color:var(--text); font-size:14px; cursor:pointer; }
.frow select {
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
background:#0c0f13; color:var(--text);
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
/* The ufw switch hangs under the button, centred on it, level with the note. */
.frow .act { position:relative; }
.frow .act label.check {
position:absolute; top:100%; left:50%; transform:translateX(-50%);
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
color:var(--text); font-size:14px; cursor:pointer;
}
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
/* Phones: two fields a line, the label and the button each a line of their own. */
@media (max-width: 640px) {
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
.frow .fld.grow { flex:1 1 100%; }
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
.frow .act button { flex:1; }
.frow .act label.check { position:static; transform:none; margin:0; }
.under { margin-right:0; }
}
</style>
</head>
@@ -39,16 +55,17 @@
<div class="card">
<h2>Правила</h2>
{{if .Rules}}
<table>
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead>
<table class="stack">
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
<tbody>
{{range .Rules}}
<tr>
<td><code>{{.Proto}}</code></td>
<td class="muted">:{{.HostPort}}</td>
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
<td class="muted">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
<td style="text-align:right">
<td data-l="Тип"><code>{{.Proto}}</code></td>
<td class="muted" data-l="Хост">:{{.HostPort}}</td>
<td class="muted" data-l="→ Клиент">127.0.0.1:{{.ClientPort}}</td>
<td class="muted" data-l="Слушать на">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
<td class="lead">{{.Label}}</td>
<td class="actions" style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -72,38 +89,44 @@
<div class="fld">
<label>Тип</label>
<select name="proto" id="proto">
<option value="http">http (long-poll, работает везде)</option>
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
<option value="http">http-lp</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<div class="fld">
<label>Порт на клиенте</label>
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required>
<label>Порт клиента</label>
<input type="text" name="client_port" placeholder="22" required>
</div>
<div class="fld">
<label>Порт на хосте</label>
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required>
<label>Порт хоста</label>
<input type="text" name="host_port" placeholder="2222" required>
</div>
<div class="fld">
<label>Слушать на хосте</label>
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px">
<div class="fld addr">
<label>Слушать на</label>
<input type="text" name="listen_addr" value="0.0.0.0">
</div>
<div class="fld grow">
<label>Метка</label>
<input type="text" name="label" maxlength="80" placeholder="необязательно">
</div>
<div class="act">
<button type="submit">Создать</button>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw
</label>
{{end}}
</div>
<button type="submit">Создать</button>
</div>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw правила
</label>
{{end}}
<div class="under"><span class="muted" id="note"></span></div>
</form>
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
</div>
<div class="card">
<h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul>
@@ -141,6 +141,7 @@
{{range .UfwRules}}
<tr>
<td><code>{{.Port}}/{{.Proto}}</code></td>
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline"
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
@@ -156,6 +157,20 @@
{{else}}
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
{{end}}
{{if and .UfwActive .UfwEnabled}}
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
<form method="post" action="/settings/ufw/add" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
<select name="proto" style="max-width:100px">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button type="submit">Открыть</button>
</form>
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
{{end}}
{{end}}
</div>
+200 -11
View File
@@ -19,6 +19,25 @@
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
.speed span { color:var(--muted); font-size:13px; }
.live { display:flex; align-items:center; gap:28px; flex-wrap:wrap; margin-top:20px; }
.gauge { position:relative; width:240px; flex:none; }
.gauge svg { display:block; width:100%; }
.gauge path { fill:none; stroke-width:14; stroke-linecap:round; }
.gauge .track { stroke:#0c0f13; }
.gauge .fill { stroke:var(--accent); stroke-dasharray:251.4; stroke-dashoffset:251.4; transition:stroke-dashoffset .25s linear, stroke .3s; }
.gauge .num { position:absolute; left:0; right:0; bottom:26px; text-align:center; font-size:38px; font-weight:600; line-height:1; font-variant-numeric:tabular-nums; }
.gauge .unit { position:absolute; left:0; right:0; bottom:4px; text-align:center; color:var(--muted); font-size:13px; }
.live .side { flex:1; min-width:220px; }
.live .phase { font-size:15px; margin-bottom:8px; }
.live .phase i { font-style:normal; color:var(--accent); margin-right:6px; }
.live.up .phase i { color:var(--ok); }
.live.up .gauge .fill { stroke:var(--ok); }
.spark { display:block; width:100%; height:64px; }
.spark polyline { fill:none; stroke:var(--accent); stroke-width:2; vector-effect:non-scaling-stroke; stroke-linejoin:round; }
.spark polygon { fill:var(--accent); opacity:.15; }
.live.up .spark polyline { stroke:var(--ok); }
.live.up .spark polygon { fill:var(--ok); }
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
</style>
</head>
<body>
@@ -57,15 +76,36 @@
<div class="card">
<h2>Скорость соединения</h2>
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
Замер занимает около 7 секунд и на это время нагружает канал.</p>
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
Замер занимает около 15 секунд, не дольше 40, и на это время нагружает канал.</p>
<div class="row">
<button id="speed-btn" type="button">Замерить скорость</button>
<label class="check" title="Данные пойдут не только между клиентом и хостом панели, а через панель до этого браузера и обратно">
<input type="checkbox" id="speed-relay"> До браузера
</label>
<span class="muted" id="speed-note"></span>
</div>
<div class="live" id="live" hidden>
<div class="gauge">
<svg viewBox="0 0 200 110">
<path class="track" d="M20 100 A80 80 0 0 1 180 100"/>
<path class="fill" id="lv-arc" d="M20 100 A80 80 0 0 1 180 100"/>
</svg>
<div class="num" id="lv-num">-</div>
<div class="unit" id="lv-unit"></div>
</div>
<div class="side">
<div class="phase"><i id="lv-ico"></i><span id="lv-phase"></span></div>
<svg class="spark" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon id="lv-area" points=""/>
<polyline id="lv-line" points=""/>
</svg>
</div>
</div>
<div class="speed" id="speed" hidden>
<div><b id="sp-ping"></b><span>задержка</span></div>
<div><b id="sp-down"></b><span>хост → клиент</span></div>
<div><b id="sp-up"></b><span>клиент → хост</span></div>
<div><b id="sp-down"></b><span id="sp-down-lbl"></span></div>
<div><b id="sp-up"></b><span id="sp-up-lbl"></span></div>
</div>
</div>
</div>
@@ -80,11 +120,12 @@
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rate(bps) {
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return bps.toFixed(bps < 100 && i > 0 ? 1 : 0) + ' ' + u[i];
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
@@ -134,22 +175,170 @@
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
fetch(base + '/speed', {method: 'POST', credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс';
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = rate(d.up_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = '';
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { btn.disabled = false; });
.then(function () { liveStop(); btn.disabled = false; });
};
})();
</script>
+285 -8
View File
@@ -9,6 +9,31 @@
<style>
#status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. */
.keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
body.touch #terminal { flex:1; min-width:0; height:auto; }
body.touch .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
}
.keys button {
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
/* Copy and paste on touch screens: a long press selects, this menu acts. */
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.selmenu[hidden] { display:none; }
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
</style>
</head>
<body>
@@ -26,6 +51,36 @@
<div class="wrap" style="max-width:none">
<div id="terminal"></div>
</div>
<div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
</button>
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
</button>
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
</button>
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
<button type="button" data-k="-">-</button>
<button type="button" data-k="home">HOME</button>
<button type="button" data-k="up">↑</button>
<button type="button" data-k="end">END</button>
<button type="button" data-k="pgup">PGUP</button>
<button type="button" data-k="tab">TAB</button>
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
<button type="button" data-k="alt" data-mod="alt">ALT</button>
<button type="button" data-k="left">←</button>
<button type="button" data-k="down">↓</button>
<button type="button" data-k="right">→</button>
<button type="button" data-k="pgdn">PGDN</button>
</div>
<script src="/static/xterm.js"></script>
<script src="/static/addon-fit.js"></script>
@@ -33,11 +88,13 @@
(function () {
const agentID = {{.Agent.ID}};
const statusEl = document.getElementById('status');
const touch = window.matchMedia('(pointer: coarse)').matches;
if (touch) document.body.classList.add('touch');
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontSize: touch ? 13 : 14,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0c0f13' }
});
@@ -126,17 +183,237 @@
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
}
term.onData(function (data) {
// Input goes out one request at a time, so on a slow link a keystroke can
// never overtake the one before it; what is typed meanwhile is sent together
// next. A request that fails goes again under the same number, and the
// server drops one it already has.
let inSeq = 0, inBusy = false;
const inQueue = [];
function queueIn(m) {
if (closed || !sid) return;
const bytes = new TextEncoder().encode(data);
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
const last = inQueue[inQueue.length - 1];
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
else inQueue.push(m);
pumpIn();
}
async function pumpIn() {
if (inBusy || !inQueue.length) return;
inBusy = true;
while (inQueue.length && !closed) {
const m = inQueue.shift();
const body = m.t === 'i'
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
let r = null;
try {
r = await fetch(base + sid + '/in', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
} catch (e) { /* no answer: send it again */ }
if (r && r.status < 500) break; // taken, or refused for good
await new Promise(res => setTimeout(res, wait));
}
}
inBusy = false;
}
function send(data) { queueIn({ t: 'i', text: data }); }
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
term.onData(function (data) { send(withMods(data)); });
// --- Extra keys (touch screens) ---
// CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
const modBtn = {};
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
function showMods() {
for (const m in mods) {
modBtn[m].classList.toggle('on', mods[m] === 1);
modBtn[m].classList.toggle('lock', mods[m] === 2);
}
}
function useMods() {
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
showMods();
return used;
}
function ctrlChar(c) {
if (c === ' ' || c === '@') return '\x00';
if (c === '?') return '\x7f';
const code = c.toUpperCase().charCodeAt(0);
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
}
function withMods(data) {
if (!mods.ctrl && !mods.alt) return data;
const m = useMods();
if (m.ctrl && data.length === 1) data = ctrlChar(data);
return m.alt ? '\x1b' + data : data;
}
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k];
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
}
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
if (m.ctrl && s.length === 1) s = ctrlChar(s);
return m.alt ? '\x1b' + s : s;
}
let press = null;
document.querySelectorAll('#keys button').forEach(function (b) {
// Keep the focus (and the on-screen keyboard) on the terminal.
b.addEventListener('pointerdown', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
});
b.addEventListener('pointerup', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) {
if (press && press.mod === mod) {
clearTimeout(press.timer);
press = null;
mods[mod] = mods[mod] ? 0 : 1;
showMods();
}
return;
}
send(keySeq(b.dataset.k));
});
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
});
function sendResize() {
if (closed || !sid) return;
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
// --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word
// under the finger and dragging on from there stretches the selection; a
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
if (touch) {
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
let timer = null, start = null, anchor = null, selecting = false;
function cellAt(x, y) {
const r = box.querySelector('.xterm-screen').getBoundingClientRect();
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
return { col: col, row: row + term.buffer.active.viewportY };
}
function selectWord(c) {
const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch);
let a = c.col, b = c.col;
if (word(text[c.col])) {
while (a > 0 && word(text[a - 1])) a--;
while (b < text.length - 1 && word(text[b + 1])) b++;
}
term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
}
function stretch(c) {
const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
}
function showMenu() {
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 8) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
menu.hidden = false;
}
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { clearTimeout(timer); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu();
clearTimeout(timer);
timer = setTimeout(function () {
selecting = true;
selectWord(cellAt(start.x, start.y));
if (navigator.vibrate) navigator.vibrate(15);
}, 450);
}, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) {
const t = e.touches[0];
if (selecting) {
e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation();
stretch(cellAt(t.clientX, t.clientY));
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll
}
}, { passive: false, capture: true });
box.addEventListener('touchend', function (e) {
clearTimeout(timer);
if (!selecting) return;
selecting = false;
e.preventDefault(); // no tap: the keyboard stays as it was
e.stopPropagation();
showMenu();
}, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) {
const b = e.target.closest('button');
if (!b) return;
const a = b.dataset.a;
if (a === 'copy') {
const text = term.getSelection();
hideMenu();
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') {
hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') {
term.selectAll();
showMenu();
} else {
hideMenu();
}
});
}
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the
// on-screen keyboard.
const vv = window.visualViewport;
document.body.style.top = vv.offsetTop + 'px';
document.body.style.height = vv.height + 'px';
}
fit.fit();
}
window.addEventListener('resize', refit);
if (touch && window.visualViewport) {
window.visualViewport.addEventListener('resize', refit);
window.visualViewport.addEventListener('scroll', refit);
}
refit();
term.onResize(sendResize);
window.addEventListener('pagehide', function () {