pywebexec 1.2.0__py3-none-any.whl → 1.4.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pywebexec/pywebexec.py CHANGED
@@ -1,7 +1,6 @@
1
1
  import sys
2
2
  from flask import Flask, request, jsonify, render_template, session, redirect, url_for
3
3
  from flask_httpauth import HTTPBasicAuth
4
- import subprocess
5
4
  import threading
6
5
  import os
7
6
  import json
@@ -10,12 +9,23 @@ import argparse
10
9
  import random
11
10
  import string
12
11
  from datetime import datetime, timezone, timedelta
12
+ import time
13
13
  import shlex
14
14
  from gunicorn.app.base import Application
15
15
  import ipaddress
16
- from socket import gethostname, gethostbyname_ex
16
+ from socket import gethostname, gethostbyname_ex, gethostbyaddr, inet_aton, inet_ntoa
17
17
  import ssl
18
18
  import re
19
+ import pwd
20
+ from secrets import token_urlsafe
21
+ import pexpect
22
+ import signal
23
+ import fcntl
24
+ import termios
25
+ import struct
26
+ import subprocess
27
+
28
+
19
29
  if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
20
30
  from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
21
31
 
@@ -32,6 +42,7 @@ app.config['LDAP_BIND_DN'] = os.environ.get('PYWEBEXEC_LDAP_BIND_DN')
32
42
  app.config['LDAP_BIND_PASSWORD'] = os.environ.get('PYWEBEXEC_LDAP_BIND_PASSWORD')
33
43
 
34
44
  # Directory to store the command status and output
45
+ CWD = os.getcwd()
35
46
  COMMAND_STATUS_DIR = '.web_status'
36
47
  CONFDIR = os.path.expanduser("~/")
37
48
  if os.path.isdir(f"{CONFDIR}/.config"):
@@ -48,11 +59,38 @@ def generate_random_password(length=12):
48
59
 
49
60
 
50
61
  def resolve_hostname(host):
51
- """try get fqdn from DNS"""
62
+ """try get fqdn from DNS/hosts"""
52
63
  try:
53
- return gethostbyname_ex(host)[0]
64
+ hostinfo = gethostbyname_ex(host)
65
+ return (hostinfo[0].rstrip('.'), hostinfo[2][0])
54
66
  except OSError:
55
- return host
67
+ return (host, host)
68
+
69
+
70
+ def resolve_ip(ip):
71
+ """try resolve hostname by reverse dns query on ip addr"""
72
+ ip = inet_ntoa(inet_aton(ip))
73
+ try:
74
+ ipinfo = gethostbyaddr(ip)
75
+ return (ipinfo[0].rstrip('.'), ipinfo[2][0])
76
+ except OSError:
77
+ return (ip, ip)
78
+
79
+
80
+ def is_ip(host):
81
+ """determine if host is valid ip"""
82
+ try:
83
+ inet_aton(host)
84
+ return True
85
+ except OSError:
86
+ return False
87
+
88
+
89
+ def resolve(host_or_ip):
90
+ """resolve hostname from ip / hostname"""
91
+ if is_ip(host_or_ip):
92
+ return resolve_ip(host_or_ip)
93
+ return resolve_hostname(host_or_ip)
56
94
 
57
95
 
58
96
  def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
@@ -118,7 +156,7 @@ def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
118
156
 
119
157
 
120
158
 
121
- class StandaloneApplication(Application):
159
+ class PyWebExec(Application):
122
160
 
123
161
  def __init__(self, app, options=None):
124
162
  self.options = options or {}
@@ -137,22 +175,11 @@ class StandaloneApplication(Application):
137
175
  return self.application
138
176
 
139
177
 
178
+ ANSI_ESCAPE = re.compile(br'(?:\x1B[@-Z\\-_]|\x1B([(]B|>)|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]|\x1B\[[0-9]{1,2};[0-9]{1,2}[m|K]|\x1B\[[0-9;]*[mGKHF]|[\x00-\x1F\x7F])')
179
+
140
180
  def strip_ansi_control_chars(text):
141
181
  """Remove ANSI and control characters from the text."""
142
- # To clean
143
- # ansi_escape = re.compile(r'''
144
- # (?:\x1B[@-_]| # ANSI ESCape sequences
145
- # \x1B\[.*?[ -/]*[@-~]| # ANSI CSI sequences
146
- # \x1B\].*?\x07| # ANSI OSC sequences
147
- # \x1B=P| # ANSI DCS sequences
148
- # \x1B\\| # ANSI ST sequences
149
- # \x1B\^| # ANSI PM sequences
150
- # \x1B_.*?\x1B\\| # ANSI APC sequences
151
- # [\x00-\x1F\x7F]) # Control characters
152
- # ''', re.VERBOSE)
153
- # ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|[(]B)|>')
154
- ansi_escape = re.compile(br'(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]|\x1B([(]B|>))')
155
- return ansi_escape.sub(b'', text)
182
+ return ANSI_ESCAPE.sub(b'', text)
156
183
 
157
184
 
158
185
  def decode_line(line: bytes) -> str:
@@ -191,31 +218,35 @@ def get_last_non_empty_line_of_file(file_path):
191
218
  return last_line(f)
192
219
 
193
220
 
194
- def start_gunicorn(daemon=False, baselog=None):
195
- if daemon:
221
+ def start_gunicorn(daemonized=False, baselog=None):
222
+ pidfile = f"{baselog}.pid"
223
+ if daemonized:
224
+ if daemon_d('status', pidfilepath=baselog, silent=True):
225
+ print(f"Error: pywebexec already running on {args.listen}:{args.port}", file=sys.stderr)
226
+ sys.exit(1)
227
+
228
+ if sys.stdout.isatty():
229
+ errorlog = "-"
230
+ accesslog = None #"-"
231
+ else:
196
232
  errorlog = f"{baselog}.log"
197
233
  accesslog = None # f"{baselog}.access.log"
198
- pidfile = f"{baselog}.pid"
199
- else:
200
- errorlog = "-"
201
- accesslog = "-"
202
- pidfile = None
234
+
203
235
  options = {
204
236
  'bind': '%s:%s' % (args.listen, args.port),
205
237
  'workers': 4,
206
238
  'timeout': 600,
207
239
  'certfile': args.cert,
208
240
  'keyfile': args.key,
209
- 'daemon': daemon,
241
+ 'daemon': daemonized,
210
242
  'errorlog': errorlog,
211
243
  'accesslog': accesslog,
212
244
  'pidfile': pidfile,
213
245
  }
214
- StandaloneApplication(app, options=options).run()
246
+ PyWebExec(app, options=options).run()
215
247
 
216
- def daemon_d(action, pidfilepath, hostname=None, args=None):
248
+ def daemon_d(action, pidfilepath, silent=False, hostname=None, args=None):
217
249
  """start/stop daemon"""
218
- import signal
219
250
  import daemon, daemon.pidfile
220
251
 
221
252
  pidfile = daemon.pidfile.TimeoutPIDLockFile(pidfilepath+".pid", acquire_timeout=30)
@@ -233,10 +264,14 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
233
264
  if status:
234
265
  print(f"pywebexec running pid {pidfile.read_pid()}")
235
266
  return True
236
- print("pywebexec not running")
267
+ if not silent:
268
+ print("pywebexec not running")
237
269
  return False
238
270
  elif action == "start":
239
- print(f"Starting server")
271
+ status = pidfile.is_locked()
272
+ if status:
273
+ print(f"pywebexc already running pid {pidfile.read_pid()}", file=sys.stderr)
274
+ sys.exit(1)
240
275
  log = open(pidfilepath + ".log", "ab+")
241
276
  daemon_context = daemon.DaemonContext(
242
277
  stderr=log,
@@ -250,8 +285,22 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
250
285
  except Exception as e:
251
286
  print(e)
252
287
 
288
+ def start_term():
289
+ os.environ["PYWEBEXEC"] = " (shared)"
290
+ os.chdir(CWD)
291
+ command_id = str(uuid.uuid4())
292
+ start_time = datetime.now().isoformat()
293
+ user = pwd.getpwuid(os.getuid())[0]
294
+ update_command_status(command_id, 'running', command="term", params=[user,os.ttyname(sys.stdout.fileno())], start_time=start_time, user=user)
295
+ output_file_path = get_output_file_path(command_id)
296
+ res = script(output_file_path)
297
+ end_time = datetime.now().isoformat()
298
+ update_command_status(command_id, status="success", end_time=end_time, exit_code=res)
299
+ return res
300
+
253
301
  def parseargs():
254
- global app, args
302
+ global app, args, COMMAND_STATUS_DIR
303
+
255
304
  parser = argparse.ArgumentParser(description='Run the command execution server.')
256
305
  parser.add_argument('-u', '--user', help='Username for basic auth')
257
306
  parser.add_argument('-P', '--password', help='Password for basic auth')
@@ -268,13 +317,15 @@ def parseargs():
268
317
  "-t",
269
318
  "--title",
270
319
  type=str,
271
- default="pywebexec",
320
+ default="PyWebExec",
272
321
  help="Web html title",
273
322
  )
274
323
  parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
275
324
  parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
276
325
  parser.add_argument("-g", "--gencert", action="store_true", help="https server self signed cert")
277
- parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
326
+ parser.add_argument("-T", "--tokenurl", action="store_true", help="generate safe url to access")
327
+ parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status/shareterm/term",
328
+ choices=["start","stop","restart","status","shareterm", "term"])
278
329
 
279
330
  args = parser.parse_args()
280
331
  if os.path.isdir(args.dir):
@@ -288,8 +339,21 @@ def parseargs():
288
339
  sys.exit(1)
289
340
  if not os.path.exists(COMMAND_STATUS_DIR):
290
341
  os.makedirs(COMMAND_STATUS_DIR)
342
+ if not os.path.exists(CONFDIR):
343
+ os.mkdir(CONFDIR, mode=0o700)
344
+ if args.action == "term":
345
+ COMMAND_STATUS_DIR = f"{os.getcwd()}/{COMMAND_STATUS_DIR}"
346
+ sys.exit(start_term())
347
+ (hostname, ip) = resolve(gethostname()) if args.listen == '0.0.0.0' else resolve(args.listen)
348
+ url_params = ""
349
+
350
+ if args.tokenurl:
351
+ token = os.environ.get("PYWEBEXEC_TOKEN", token_urlsafe())
352
+ os.environ["PYWEBEXEC_TOKEN"] = token
353
+ app.config["TOKEN_URL"] = token
354
+ url_params = f"?token={token}"
355
+
291
356
  if args.gencert:
292
- hostname = resolve_hostname(gethostname())
293
357
  args.cert = args.cert or f"{CONFDIR}/pywebexec.crt"
294
358
  args.key = args.key or f"{CONFDIR}/pywebexec.key"
295
359
  if not os.path.exists(args.cert):
@@ -310,9 +374,13 @@ def parseargs():
310
374
  app.config['USER'] = None
311
375
  app.config['PASSWORD'] = None
312
376
 
313
- return args
377
+ if args.action != 'stop':
378
+ print("Starting server:")
379
+ protocol = 'https' if args.cert else 'http'
380
+ print(f"{protocol}://{hostname}:{args.port}{url_params}")
381
+ print(f"{protocol}://{ip}:{args.port}{url_params}")
314
382
 
315
- parseargs()
383
+ return args
316
384
 
317
385
  def get_status_file_path(command_id):
318
386
  return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
@@ -320,7 +388,7 @@ def get_status_file_path(command_id):
320
388
  def get_output_file_path(command_id):
321
389
  return os.path.join(COMMAND_STATUS_DIR, f'{command_id}_output.txt')
322
390
 
323
- def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None):
391
+ def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None, user=None):
324
392
  status_file_path = get_status_file_path(command_id)
325
393
  status_data = read_command_status(command_id) or {}
326
394
  status_data['status'] = status
@@ -336,10 +404,11 @@ def update_command_status(command_id, status, command=None, params=None, start_t
336
404
  status_data['exit_code'] = exit_code
337
405
  if pid is not None:
338
406
  status_data['pid'] = pid
407
+ if user is not None:
408
+ status_data['user'] = user
339
409
  if status != 'running':
340
410
  output_file_path = get_output_file_path(command_id)
341
411
  if os.path.exists(output_file_path):
342
- print(output_file_path)
343
412
  status_data['last_output_line'] = get_last_non_empty_line_of_file(output_file_path)
344
413
  with open(status_file_path, 'w') as f:
345
414
  json.dump(status_data, f)
@@ -370,40 +439,93 @@ def read_command_status(command_id):
370
439
 
371
440
  return status_data
372
441
 
373
- # Dictionary to store the process objects
374
- processes = {}
442
+ def sigwinch_passthrough(sig, data):
443
+ s = struct.pack("HHHH", 0, 0, 0, 0)
444
+ a = struct.unpack('hhhh', fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, s))
445
+ global p
446
+ p.setwinsize(a[0], a[1])
447
+
448
+
449
+ def script(output_file):
450
+ global p
451
+ shell = os.environ.get('SHELL', 'sh')
452
+ with open(output_file, 'wb') as fd:
453
+ p = pexpect.spawn(shell, echo=True)
454
+ p.logfile_read = fd
455
+ # Set the window size
456
+ sigwinch_passthrough(None, None)
457
+ signal.signal(signal.SIGWINCH, sigwinch_passthrough)
458
+ p.interact()
459
+
375
460
 
376
- def run_command(command, params, command_id):
461
+ def run_command(command, params, command_id, user):
377
462
  start_time = datetime.now().isoformat()
378
- update_command_status(command_id, 'running', command=command, params=params, start_time=start_time)
463
+ update_command_status(command_id, 'running', command=command, params=params, start_time=start_time, user=user)
464
+ output_file_path = get_output_file_path(command_id)
379
465
  try:
380
- output_file_path = get_output_file_path(command_id)
381
- with open(output_file_path, 'w') as output_file:
382
- # Run the command with parameters and redirect stdout and stderr to the file
383
- process = subprocess.Popen([command] + params, stdout=output_file, stderr=output_file, bufsize=0) #text=True)
384
- update_command_status(command_id, 'running', pid=process.pid)
385
- processes[command_id] = process
386
- process.wait()
387
- processes.pop(command_id, None)
388
-
389
- end_time = datetime.now().isoformat()
390
- # Update the status based on the result
391
- if process.returncode == 0:
392
- update_command_status(command_id, 'success', end_time=end_time, exit_code=process.returncode)
393
- elif process.returncode == -15:
394
- update_command_status(command_id, 'aborted', end_time=end_time, exit_code=process.returncode)
395
- else:
396
- update_command_status(command_id, 'failed', end_time=end_time, exit_code=process.returncode)
466
+ with open(output_file_path, 'wb') as fd:
467
+ p = pexpect.spawn(command, params, ignore_sighup=True, timeout=None)
468
+ update_command_status(command_id, 'running', pid=p.pid, user=user)
469
+ p.setwinsize(24, 125)
470
+ p.logfile = fd
471
+ p.expect(pexpect.EOF)
472
+ fd.flush()
473
+ status = p.wait()
474
+ end_time = datetime.now().isoformat()
475
+ # Update the status based on the result
476
+ if status is None:
477
+ exit_code = -15
478
+ update_command_status(command_id, 'aborted', end_time=end_time, exit_code=exit_code, user=user)
479
+ else:
480
+ exit_code = status
481
+ if exit_code == 0:
482
+ update_command_status(command_id, 'success', end_time=end_time, exit_code=exit_code, user=user)
483
+ else:
484
+ update_command_status(command_id, 'failed', end_time=end_time, exit_code=exit_code, user=user)
397
485
  except Exception as e:
398
486
  end_time = datetime.now().isoformat()
399
- update_command_status(command_id, 'failed', end_time=end_time, exit_code=1)
487
+ update_command_status(command_id, 'failed', end_time=end_time, exit_code=1, user=user)
400
488
  with open(get_output_file_path(command_id), 'a') as output_file:
401
489
  output_file.write(str(e))
402
490
 
491
+ @app.route('/stop_command/<command_id>', methods=['POST'])
492
+ def stop_command(command_id):
493
+ status = read_command_status(command_id)
494
+ if not status or 'pid' not in status:
495
+ return jsonify({'error': 'Invalid command_id or command not running'}), 400
496
+
497
+ pid = status['pid']
498
+ end_time = datetime.now().isoformat()
499
+ try:
500
+ #update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
501
+ os.killpg(os.getpgid(pid), 15) # Send SIGTERM to the process group
502
+ return jsonify({'message': 'Command aborted'})
503
+ except Exception as e:
504
+ status_data = read_command_status(command_id) or {}
505
+ status_data['status'] = 'failed'
506
+ status_data['end_time'] = end_time
507
+ status_data['exit_code'] = 1
508
+ with open(get_status_file_path(command_id), 'w') as f:
509
+ json.dump(status_data, f)
510
+ with open(get_output_file_path(command_id), 'a') as output_file:
511
+ output_file.write(str(e))
512
+ return jsonify({'error': 'Failed to terminate command'}), 500
513
+
514
+ parseargs()
515
+
516
+
403
517
  @app.before_request
404
518
  def check_authentication():
519
+ # Check for token in URL if TOKEN_URL is set
520
+ token = app.config.get('TOKEN_URL')
521
+ if token and request.endpoint not in ['login', 'static']:
522
+ if request.args.get('token') == token:
523
+ return
524
+ return jsonify({'error': 'Forbidden'}), 403
525
+
405
526
  if not app.config['USER'] and not app.config['LDAP_SERVER']:
406
527
  return
528
+
407
529
  if 'username' not in session and request.endpoint not in ['login', 'static']:
408
530
  return auth.login_required(lambda: None)()
409
531
 
@@ -478,38 +600,18 @@ def run_command_endpoint():
478
600
  # Generate a unique command_id
479
601
  command_id = str(uuid.uuid4())
480
602
 
603
+ # Get the user from the session
604
+ user = session.get('username', '-')
605
+
481
606
  # Set the initial status to running and save command details
482
- update_command_status(command_id, 'running', command, params)
607
+ update_command_status(command_id, 'running', command, params, user=user)
483
608
 
484
609
  # Run the command in a separate thread
485
- thread = threading.Thread(target=run_command, args=(command_path, params, command_id))
610
+ thread = threading.Thread(target=run_command, args=(command_path, params, command_id, user))
486
611
  thread.start()
487
612
 
488
613
  return jsonify({'message': 'Command is running', 'command_id': command_id})
489
614
 
490
- @app.route('/stop_command/<command_id>', methods=['POST'])
491
- def stop_command(command_id):
492
- status = read_command_status(command_id)
493
- if not status or 'pid' not in status:
494
- return jsonify({'error': 'Invalid command_id or command not running'}), 400
495
-
496
- pid = status['pid']
497
- end_time = datetime.now().isoformat()
498
- try:
499
- os.kill(pid, 15) # Send SIGTERM
500
- #update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
501
- return jsonify({'message': 'Command aborted'})
502
- except Exception as e:
503
- status_data = read_command_status(command_id) or {}
504
- status_data['status'] = 'failed'
505
- status_data['end_time'] = end_time
506
- status_data['exit_code'] = 1
507
- with open(get_status_file_path(command_id), 'w') as f:
508
- json.dump(status_data, f)
509
- with open(get_output_file_path(command_id), 'a') as output_file:
510
- output_file.write(str(e))
511
- return jsonify({'error': 'Failed to terminate command'}), 500
512
-
513
615
  @app.route('/command_status/<command_id>', methods=['GET'])
514
616
  def get_command_status(command_id):
515
617
  status = read_command_status(command_id)
@@ -537,7 +639,7 @@ def list_commands():
537
639
  status = read_command_status(command_id)
538
640
  if status:
539
641
  try:
540
- params = shlex.join(status['params'])
642
+ params = shlex.join(status.get('params', []))
541
643
  except AttributeError:
542
644
  params = " ".join([shlex.quote(p) if " " in p else p for p in status['params']])
543
645
  command = status.get('command', '-') + ' ' + params
@@ -561,30 +663,60 @@ def list_commands():
561
663
 
562
664
  @app.route('/command_output/<command_id>', methods=['GET'])
563
665
  def get_command_output(command_id):
666
+ offset = int(request.args.get('offset', 0))
667
+ maxsize = int(request.args.get('maxsize', 10485760))
564
668
  output_file_path = get_output_file_path(command_id)
565
669
  if os.path.exists(output_file_path):
566
- with open(output_file_path, 'r') as output_file:
567
- output = output_file.read()
670
+ with open(output_file_path, 'rb') as output_file:
671
+ output_file.seek(offset)
672
+ output = output_file.read().decode('utf-8', errors='replace')
673
+ new_offset = output_file.tell()
568
674
  status_data = read_command_status(command_id) or {}
675
+ token = app.config.get("TOKEN_URL")
676
+ token_param = f"&token={token}" if token else ""
677
+ response = {
678
+ 'output': output[-maxsize:],
679
+ 'status': status_data.get("status"),
680
+ 'links': {
681
+ 'next': f'{request.url_root}command_output/{command_id}?offset={new_offset}&maxsize={maxsize}{token_param}'
682
+ }
683
+ }
569
684
  if request.headers.get('Accept') == 'text/plain':
570
685
  return f"{output}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
571
- return jsonify({'output': output, 'status': status_data.get("status")})
686
+ return jsonify(response)
572
687
  return jsonify({'error': 'Invalid command_id'}), 404
573
688
 
574
689
  @app.route('/executables', methods=['GET'])
575
690
  def list_executables():
576
691
  executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
692
+ executables.sort() # Sort the list of executables alphabetically
577
693
  return jsonify(executables)
578
694
 
695
+ @app.route('/popup/<command_id>')
696
+ def popup(command_id):
697
+ return render_template('popup.html', command_id=command_id)
698
+
579
699
  def main():
700
+ global COMMAND_STATUS_DIR
580
701
  basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
581
- if not os.path.exists(CONFDIR):
582
- os.mkdir(CONFDIR, mode=0o700)
702
+ if args.action == "shareterm":
703
+ COMMAND_STATUS_DIR = f"{os.getcwd()}/{COMMAND_STATUS_DIR}"
704
+ with open(basef + ".log", "ab+") as log:
705
+ pywebexec = subprocess.Popen([sys.executable] + sys.argv[:-1], stdout=log, stderr=log)
706
+ start_term()
707
+ time.sleep(1)
708
+ pywebexec.terminate()
709
+ sys.exit()
710
+
711
+ if args.action == "restart":
712
+ daemon_d('stop', pidfilepath=basef)
713
+ args.action = "start"
583
714
  if args.action == "start":
584
- return start_gunicorn(daemon=True, baselog=basef)
715
+ return start_gunicorn(daemonized=True, baselog=basef)
585
716
  if args.action:
586
717
  return daemon_d(args.action, pidfilepath=basef)
587
- return start_gunicorn()
718
+ return start_gunicorn(baselog=basef)
719
+
588
720
 
589
721
  if __name__ == '__main__':
590
722
  main()
Binary file