copyparty 1.13.8__tar.gz → 1.14.1__tar.gz

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.
Files changed (123) hide show
  1. {copyparty-1.13.8 → copyparty-1.14.1}/PKG-INFO +55 -3
  2. {copyparty-1.13.8 → copyparty-1.14.1}/README.md +54 -2
  3. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/__main__.py +58 -2
  4. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/__version__.py +3 -3
  5. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/authsrv.py +224 -11
  6. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/httpcli.py +183 -26
  7. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/httpsrv.py +12 -2
  8. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/svchub.py +77 -5
  9. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/tcpsrv.py +19 -1
  10. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/u2idx.py +19 -3
  11. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/up2k.py +68 -13
  12. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/util.py +1 -1
  13. copyparty-1.14.1/copyparty/web/browser.css.gz +0 -0
  14. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/browser.html +4 -4
  15. copyparty-1.14.1/copyparty/web/browser.js.gz +0 -0
  16. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/browser2.html +2 -2
  17. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/md.html +2 -2
  18. copyparty-1.14.1/copyparty/web/shares.css.gz +0 -0
  19. copyparty-1.14.1/copyparty/web/shares.html +74 -0
  20. copyparty-1.14.1/copyparty/web/shares.js.gz +0 -0
  21. copyparty-1.14.1/copyparty/web/splash.css.gz +0 -0
  22. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/splash.html +13 -6
  23. copyparty-1.14.1/copyparty/web/splash.js.gz +0 -0
  24. copyparty-1.14.1/copyparty/web/util.js.gz +0 -0
  25. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/PKG-INFO +55 -3
  26. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/SOURCES.txt +3 -0
  27. copyparty-1.13.8/copyparty/web/browser.css.gz +0 -0
  28. copyparty-1.13.8/copyparty/web/browser.js.gz +0 -0
  29. copyparty-1.13.8/copyparty/web/splash.css.gz +0 -0
  30. copyparty-1.13.8/copyparty/web/splash.js.gz +0 -0
  31. copyparty-1.13.8/copyparty/web/util.js.gz +0 -0
  32. {copyparty-1.13.8 → copyparty-1.14.1}/LICENSE +0 -0
  33. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/__init__.py +0 -0
  34. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/bos/__init__.py +0 -0
  35. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/bos/bos.py +0 -0
  36. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/bos/path.py +0 -0
  37. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/broker_mp.py +0 -0
  38. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/broker_mpw.py +0 -0
  39. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/broker_thr.py +0 -0
  40. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/broker_util.py +0 -0
  41. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/cert.py +0 -0
  42. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/cfg.py +0 -0
  43. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/dxml.py +0 -0
  44. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/fsutil.py +0 -0
  45. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/ftpd.py +0 -0
  46. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/httpconn.py +0 -0
  47. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/ico.py +0 -0
  48. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/mdns.py +0 -0
  49. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/metrics.py +0 -0
  50. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/mtag.py +0 -0
  51. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/multicast.py +0 -0
  52. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/pwhash.py +0 -0
  53. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/res/COPYING.txt +0 -0
  54. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/res/__init__.py +0 -0
  55. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/res/insecure.pem +0 -0
  56. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/smbd.py +0 -0
  57. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/ssdp.py +0 -0
  58. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/star.py +0 -0
  59. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/__init__.py +0 -0
  60. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/__init__.py +0 -0
  61. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/bimap.py +0 -0
  62. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/bit.py +0 -0
  63. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/buffer.py +0 -0
  64. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/dns.py +0 -0
  65. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/label.py +0 -0
  66. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/lex.py +0 -0
  67. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/dnslib/ranges.py +0 -0
  68. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/ifaddr/__init__.py +0 -0
  69. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/ifaddr/_posix.py +0 -0
  70. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/ifaddr/_shared.py +0 -0
  71. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/ifaddr/_win32.py +0 -0
  72. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/qrcodegen.py +0 -0
  73. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/stolen/surrogateescape.py +0 -0
  74. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/sutil.py +0 -0
  75. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/szip.py +0 -0
  76. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/tftpd.py +0 -0
  77. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/th_cli.py +0 -0
  78. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/th_srv.py +0 -0
  79. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/a/__init__.py +0 -0
  80. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/a/partyfuse.py +0 -0
  81. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/a/u2c.py +0 -0
  82. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/a/webdav-cfg.bat +0 -0
  83. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/baguettebox.js.gz +0 -0
  84. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/cf.html +0 -0
  85. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dbg-audio.js.gz +0 -0
  86. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dd/2.png +0 -0
  87. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dd/3.png +0 -0
  88. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dd/4.png +0 -0
  89. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dd/5.png +0 -0
  90. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/dd/__init__.py +0 -0
  91. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/__init__.py +0 -0
  92. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/busy.mp3.gz +0 -0
  93. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/easymde.css.gz +0 -0
  94. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/easymde.js.gz +0 -0
  95. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/marked.js.gz +0 -0
  96. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/mini-fa.css.gz +0 -0
  97. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/mini-fa.woff +0 -0
  98. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/prism.css.gz +0 -0
  99. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/prism.js.gz +0 -0
  100. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/prismd.css.gz +0 -0
  101. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/scp.woff2 +0 -0
  102. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/sha512.ac.js.gz +0 -0
  103. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/deps/sha512.hw.js.gz +0 -0
  104. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/md.css.gz +0 -0
  105. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/md.js.gz +0 -0
  106. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/md2.css.gz +0 -0
  107. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/md2.js.gz +0 -0
  108. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/mde.css.gz +0 -0
  109. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/mde.html +0 -0
  110. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/mde.js.gz +0 -0
  111. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/msg.css.gz +0 -0
  112. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/msg.html +0 -0
  113. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/svcs.html +0 -0
  114. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/svcs.js.gz +0 -0
  115. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/ui.css.gz +0 -0
  116. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/up2k.js.gz +0 -0
  117. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty/web/w.hash.js.gz +0 -0
  118. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/dependency_links.txt +0 -0
  119. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/entry_points.txt +0 -0
  120. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/requires.txt +0 -0
  121. {copyparty-1.13.8 → copyparty-1.14.1}/copyparty.egg-info/top_level.txt +0 -0
  122. {copyparty-1.13.8 → copyparty-1.14.1}/pyproject.toml +0 -0
  123. {copyparty-1.13.8 → copyparty-1.14.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: copyparty
3
- Version: 1.13.8
3
+ Version: 1.14.1
4
4
  Summary: Portable file server with accelerated resumable uploads, deduplication, WebDAV, FTP, zeroconf, media indexer, video thumbnails, audio transcoding, and write-only folders
5
5
  Author-email: ed <copyparty@ocv.me>
6
6
  License: MIT
@@ -96,6 +96,7 @@ turn almost any device into a file server with resumable uploads/downloads using
96
96
  * [self-destruct](#self-destruct) - uploads can be given a lifetime
97
97
  * [race the beam](#race-the-beam) - download files while they're still uploading ([demo video](http://a.ocv.me/pub/g/nerd-stuff/cpp/2024-0418-race-the-beam.webm))
98
98
  * [file manager](#file-manager) - cut/paste, rename, and delete files/folders (if you have permission)
99
+ * [shares](#shares) - share a file or folder by creating a temporary link
99
100
  * [batch rename](#batch-rename) - select some files and press `F2` to bring up the rename UI
100
101
  * [media player](#media-player) - plays almost every audio format there is
101
102
  * [audio equalizer](#audio-equalizer) - and [dynamic range compressor](https://en.wikipedia.org/wiki/Dynamic_range_compression)
@@ -130,6 +131,7 @@ turn almost any device into a file server with resumable uploads/downloads using
130
131
  * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/))
131
132
  * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/))
132
133
  * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such
134
+ * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords
133
135
  * [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar
134
136
  * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed
135
137
  * [themes](#themes)
@@ -798,6 +800,33 @@ file selection: click somewhere on the line (not the link itsef), then:
798
800
  you can move files across browser tabs (cut in one tab, paste in another)
799
801
 
800
802
 
803
+ ## shares
804
+
805
+ share a file or folder by creating a temporary link
806
+
807
+ when enabled in the server settings (`--shr`), click the bottom-right `share` button to share the folder you're currently in, or select a file first to share only that file
808
+
809
+ this feature was made with [identity providers](#identity-providers) in mind -- configure your reverseproxy to skip the IdP's access-control for a given URL prefix and use that to safely share specific files/folders sans the usual auth checks
810
+
811
+ when creating a share, the creator can choose any of the following options:
812
+
813
+ * password-protection
814
+ * expire after a certain time
815
+ * allow visitors to upload (if the user who creates the share has write-access)
816
+
817
+ semi-intentional limitations:
818
+
819
+ * cleanup of expired shares only works when global option `e2d` is set, and/or at least one volume on the server has volflag `e2d`
820
+ * only folders from the same volume are shared; if you are sharing a folder which contains other volumes, then the contents of those volumes will not be available
821
+ * no option to "delete after first access" because tricky
822
+ * when linking something to discord (for example) it'll get accessed by their scraper and that would count as a hit
823
+ * browsers wouldn't be able to resume a broken download unless the requester's IP gets allowlisted for X minutes (ref. tricky)
824
+
825
+ the links are created inside a specific toplevel folder which must be specified with server-config `--shr`, for example `--shr /share/` (this also enables the feature)
826
+
827
+ users can delete their own shares in the controlpanel, and a list of privileged users (`--shr-adm`) are allowed to see and/or delet any share on the server
828
+
829
+
801
830
  ## batch rename
802
831
 
803
832
  select some files and press `F2` to bring up the rename UI
@@ -1409,6 +1438,29 @@ there is a [docker-compose example](./docs/examples/docker/idp-authelia-traefik)
1409
1438
 
1410
1439
  a more complete example of the copyparty configuration options [look like this](./docs/examples/docker/idp/copyparty.conf)
1411
1440
 
1441
+ but if you just want to let users change their own passwords, then you probably want [user-changeable passwords](#user-changeable-passwords) instead
1442
+
1443
+
1444
+ ## user-changeable passwords
1445
+
1446
+ if permitted, users can change their own passwords in the control-panel
1447
+
1448
+ * not compatible with [identity providers](#identity-providers)
1449
+
1450
+ * must be enabled with `--chpw` because account-sharing is a popular usecase
1451
+
1452
+ * if you want to enable the feature but deny password-changing for a specific list of accounts, you can do that with `--chpw-no name1,name2,name3,...`
1453
+
1454
+ * to perform a password reset, edit the server config and give the user another password there, then do a [config reload](#server-config) or server restart
1455
+
1456
+ * the custom passwords are kept in a textfile at filesystem-path `--chpw-db`, by default `chpw.json` in the copyparty config folder
1457
+
1458
+ * if you run multiple copyparty instances with different users you *almost definitely* want to specify separate DBs for each instance
1459
+
1460
+ * if [password hashing](#password-hashing) is enbled, the passwords in the db are also hashed
1461
+
1462
+ * ...which means that all user-defined passwords will be forgotten if you change password-hashing settings
1463
+
1412
1464
 
1413
1465
  ## using the cloud as storage
1414
1466
 
@@ -1513,7 +1565,7 @@ some reverse proxies (such as [Caddy](https://caddyserver.com/)) can automatical
1513
1565
  * **warning:** nginx-QUIC (HTTP/3) is still experimental and can make uploads much slower, so HTTP/1.1 is recommended for now
1514
1566
  * depending on server/client, HTTP/1.1 can also be 5x faster than HTTP/2
1515
1567
 
1516
- for improved security (and a tiny performance boost) consider listening on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1`
1568
+ for improved security (and a 10% performance boost) consider listening on a unix-socket with `-i unix:770:www:/tmp/party.sock` (permission `770` means only members of group `www` can access it)
1517
1569
 
1518
1570
  example webserver configs:
1519
1571
 
@@ -1954,7 +2006,7 @@ some notes on hardening
1954
2006
  * cors doesn't work right otherwise
1955
2007
  * if you allow anonymous uploads or otherwise don't trust the contents of a volume, you can prevent XSS with volflag `nohtml`
1956
2008
  * this returns html documents as plaintext, and also disables markdown rendering
1957
- * when running behind a reverse-proxy, listen on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1` for tighter access control (plus you get a tiny performance boost for free)
2009
+ * when running behind a reverse-proxy, listen on a unix-socket for tighter access control (and more performance); see [reverse-proxy](#reverse-proxy) or `--help-bind`
1958
2010
 
1959
2011
  safety profiles:
1960
2012
 
@@ -42,6 +42,7 @@ turn almost any device into a file server with resumable uploads/downloads using
42
42
  * [self-destruct](#self-destruct) - uploads can be given a lifetime
43
43
  * [race the beam](#race-the-beam) - download files while they're still uploading ([demo video](http://a.ocv.me/pub/g/nerd-stuff/cpp/2024-0418-race-the-beam.webm))
44
44
  * [file manager](#file-manager) - cut/paste, rename, and delete files/folders (if you have permission)
45
+ * [shares](#shares) - share a file or folder by creating a temporary link
45
46
  * [batch rename](#batch-rename) - select some files and press `F2` to bring up the rename UI
46
47
  * [media player](#media-player) - plays almost every audio format there is
47
48
  * [audio equalizer](#audio-equalizer) - and [dynamic range compressor](https://en.wikipedia.org/wiki/Dynamic_range_compression)
@@ -76,6 +77,7 @@ turn almost any device into a file server with resumable uploads/downloads using
76
77
  * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/))
77
78
  * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/))
78
79
  * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such
80
+ * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords
79
81
  * [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar
80
82
  * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed
81
83
  * [themes](#themes)
@@ -744,6 +746,33 @@ file selection: click somewhere on the line (not the link itsef), then:
744
746
  you can move files across browser tabs (cut in one tab, paste in another)
745
747
 
746
748
 
749
+ ## shares
750
+
751
+ share a file or folder by creating a temporary link
752
+
753
+ when enabled in the server settings (`--shr`), click the bottom-right `share` button to share the folder you're currently in, or select a file first to share only that file
754
+
755
+ this feature was made with [identity providers](#identity-providers) in mind -- configure your reverseproxy to skip the IdP's access-control for a given URL prefix and use that to safely share specific files/folders sans the usual auth checks
756
+
757
+ when creating a share, the creator can choose any of the following options:
758
+
759
+ * password-protection
760
+ * expire after a certain time
761
+ * allow visitors to upload (if the user who creates the share has write-access)
762
+
763
+ semi-intentional limitations:
764
+
765
+ * cleanup of expired shares only works when global option `e2d` is set, and/or at least one volume on the server has volflag `e2d`
766
+ * only folders from the same volume are shared; if you are sharing a folder which contains other volumes, then the contents of those volumes will not be available
767
+ * no option to "delete after first access" because tricky
768
+ * when linking something to discord (for example) it'll get accessed by their scraper and that would count as a hit
769
+ * browsers wouldn't be able to resume a broken download unless the requester's IP gets allowlisted for X minutes (ref. tricky)
770
+
771
+ the links are created inside a specific toplevel folder which must be specified with server-config `--shr`, for example `--shr /share/` (this also enables the feature)
772
+
773
+ users can delete their own shares in the controlpanel, and a list of privileged users (`--shr-adm`) are allowed to see and/or delet any share on the server
774
+
775
+
747
776
  ## batch rename
748
777
 
749
778
  select some files and press `F2` to bring up the rename UI
@@ -1355,6 +1384,29 @@ there is a [docker-compose example](./docs/examples/docker/idp-authelia-traefik)
1355
1384
 
1356
1385
  a more complete example of the copyparty configuration options [look like this](./docs/examples/docker/idp/copyparty.conf)
1357
1386
 
1387
+ but if you just want to let users change their own passwords, then you probably want [user-changeable passwords](#user-changeable-passwords) instead
1388
+
1389
+
1390
+ ## user-changeable passwords
1391
+
1392
+ if permitted, users can change their own passwords in the control-panel
1393
+
1394
+ * not compatible with [identity providers](#identity-providers)
1395
+
1396
+ * must be enabled with `--chpw` because account-sharing is a popular usecase
1397
+
1398
+ * if you want to enable the feature but deny password-changing for a specific list of accounts, you can do that with `--chpw-no name1,name2,name3,...`
1399
+
1400
+ * to perform a password reset, edit the server config and give the user another password there, then do a [config reload](#server-config) or server restart
1401
+
1402
+ * the custom passwords are kept in a textfile at filesystem-path `--chpw-db`, by default `chpw.json` in the copyparty config folder
1403
+
1404
+ * if you run multiple copyparty instances with different users you *almost definitely* want to specify separate DBs for each instance
1405
+
1406
+ * if [password hashing](#password-hashing) is enbled, the passwords in the db are also hashed
1407
+
1408
+ * ...which means that all user-defined passwords will be forgotten if you change password-hashing settings
1409
+
1358
1410
 
1359
1411
  ## using the cloud as storage
1360
1412
 
@@ -1459,7 +1511,7 @@ some reverse proxies (such as [Caddy](https://caddyserver.com/)) can automatical
1459
1511
  * **warning:** nginx-QUIC (HTTP/3) is still experimental and can make uploads much slower, so HTTP/1.1 is recommended for now
1460
1512
  * depending on server/client, HTTP/1.1 can also be 5x faster than HTTP/2
1461
1513
 
1462
- for improved security (and a tiny performance boost) consider listening on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1`
1514
+ for improved security (and a 10% performance boost) consider listening on a unix-socket with `-i unix:770:www:/tmp/party.sock` (permission `770` means only members of group `www` can access it)
1463
1515
 
1464
1516
  example webserver configs:
1465
1517
 
@@ -1900,7 +1952,7 @@ some notes on hardening
1900
1952
  * cors doesn't work right otherwise
1901
1953
  * if you allow anonymous uploads or otherwise don't trust the contents of a volume, you can prevent XSS with volflag `nohtml`
1902
1954
  * this returns html documents as plaintext, and also disables markdown rendering
1903
- * when running behind a reverse-proxy, listen on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1` for tighter access control (plus you get a tiny performance boost for free)
1955
+ * when running behind a reverse-proxy, listen on a unix-socket for tighter access control (and more performance); see [reverse-proxy](#reverse-proxy) or `--help-bind`
1904
1956
 
1905
1957
  safety profiles:
1906
1958
 
@@ -521,6 +521,41 @@ def showlic() :
521
521
 
522
522
  def get_sects():
523
523
  return [
524
+ [
525
+ "bind",
526
+ "configure listening",
527
+ dedent(
528
+ """
529
+ \033[33m-i\033[0m takes a comma-separated list of interfaces to listen on;
530
+ IP-addresses and/or unix-sockets (Unix Domain Sockets)
531
+
532
+ the default (\033[32m-i ::\033[0m) means all IPv4 and IPv6 addresses
533
+
534
+ \033[32m-i 0.0.0.0\033[0m listens on all IPv4 NICs/subnets
535
+ \033[32m-i 127.0.0.1\033[0m listens on IPv4 localhost only
536
+ \033[32m-i 127.1\033[0m listens on IPv4 localhost only
537
+ \033[32m-i 127.1,192.168.123.1\033[0m = IPv4 localhost and 192.168.123.1
538
+
539
+ \033[33m-p\033[0m takes a comma-separated list of tcp ports to listen on;
540
+ the default is \033[32m-p 3923\033[0m but as root you can \033[32m-p 80,443,3923\033[0m
541
+
542
+ when running behind a reverse-proxy, it's recommended to
543
+ use unix-sockets for improved performance and security;
544
+
545
+ \033[32m-i unix:770:www:\033[33m/tmp/a.sock\033[0m listens on \033[33m/tmp/a.sock\033[0m with
546
+ permissions \033[33m0770\033[0m; only accessible to members of the \033[33mwww\033[0m
547
+ group. This is the best approach. Alternatively,
548
+
549
+ \033[32m-i unix:777:\033[33m/tmp/a.sock\033[0m sets perms \033[33m0777\033[0m so anyone can
550
+ access it; bad unless it's inside a restricted folder
551
+
552
+ \033[32m-i unix:\033[33m/tmp/a.sock\033[0m keeps umask-defined permissions
553
+ (usually \033[33m0600\033[0m) and the same user/group as copyparty
554
+
555
+ \033[33m-p\033[0m (tcp ports) is ignored for unix sockets
556
+ """
557
+ ),
558
+ ],
524
559
  [
525
560
  "accounts",
526
561
  "accounts and volumes",
@@ -931,6 +966,15 @@ def add_fs(ap):
931
966
  ap2.add_argument("--mtab-age", metavar="SEC", type=int, default=60, help="rebuild mountpoint cache every \033[33mSEC\033[0m to keep track of sparse-files support; keep low on servers with removable media")
932
967
 
933
968
 
969
+ def add_share(ap):
970
+ db_path = os.path.join(E.cfg, "shares.db")
971
+ ap2 = ap.add_argument_group('share-url options')
972
+ ap2.add_argument("--shr", metavar="URL", default="", help="base url for shared files, for example [\033[32m/share\033[0m] (must be a toplevel subfolder)")
973
+ ap2.add_argument("--shr-db", metavar="PATH", default=db_path, help="database to store shares in")
974
+ ap2.add_argument("--shr-adm", metavar="U,U", default="", help="comma-separated list of users allowed to view/delete any share")
975
+ ap2.add_argument("--shr-v", action="store_true", help="debug")
976
+
977
+
934
978
  def add_upload(ap):
935
979
  ap2 = ap.add_argument_group('upload options')
936
980
  ap2.add_argument("--dotpart", action="store_true", help="dotfile incomplete uploads, hiding them from clients unless \033[33m-ed\033[0m")
@@ -963,8 +1007,8 @@ def add_upload(ap):
963
1007
 
964
1008
  def add_network(ap):
965
1009
  ap2 = ap.add_argument_group('network options')
966
- ap2.add_argument("-i", metavar="IP", type=u, default="::", help="ip to bind (comma-sep.) and/or [\033[32munix:/tmp/a.sock\033[0m], default: all IPv4 and IPv6")
967
- ap2.add_argument("-p", metavar="PORT", type=u, default="3923", help="ports to bind (comma/range); ignored for unix-sockets")
1010
+ ap2.add_argument("-i", metavar="IP", type=u, default="::", help="IPs and/or unix-sockets to listen on (see \033[33m--help-bind\033[0m). Default: all IPv4 and IPv6")
1011
+ ap2.add_argument("-p", metavar="PORT", type=u, default="3923", help="ports to listen on (comma/range); ignored for unix-sockets")
968
1012
  ap2.add_argument("--ll", action="store_true", help="include link-local IPv4/IPv6 in mDNS replies, even if the NIC has routable IPs (breaks some mDNS clients)")
969
1013
  ap2.add_argument("--rproxy", metavar="DEPTH", type=int, default=1, help="which ip to associate clients with; [\033[32m0\033[0m]=tcp, [\033[32m1\033[0m]=origin (first x-fwd, unsafe), [\033[32m2\033[0m]=outermost-proxy, [\033[32m3\033[0m]=second-proxy, [\033[32m-1\033[0m]=closest-proxy")
970
1014
  ap2.add_argument("--xff-hdr", metavar="NAME", type=u, default="x-forwarded-for", help="if reverse-proxied, which http header to read the client's real ip from")
@@ -1024,6 +1068,16 @@ def add_auth(ap):
1024
1068
  ap2.add_argument("--bauth-last", action="store_true", help="keeps basic-authentication enabled, but only as a last-resort; if a cookie is also provided then the cookie wins")
1025
1069
 
1026
1070
 
1071
+ def add_chpw(ap):
1072
+ db_path = os.path.join(E.cfg, "chpw.json")
1073
+ ap2 = ap.add_argument_group('user-changeable passwords options')
1074
+ ap2.add_argument("--chpw", action="store_true", help="allow users to change their own passwords")
1075
+ ap2.add_argument("--chpw-no", metavar="U,U,U", type=u, action="append", help="do not allow password-changes for this comma-separated list of usernames")
1076
+ ap2.add_argument("--chpw-db", metavar="PATH", type=u, default=db_path, help="where to store the passwords database (if you run multiple copyparty instances, make sure they use different DBs)")
1077
+ ap2.add_argument("--chpw-len", metavar="N", type=int, default=8, help="minimum password length")
1078
+ ap2.add_argument("--chpw-v", metavar="LVL", type=int, default=2, help="verbosity of summary on config load [\033[32m0\033[0m] = nothing at all, [\033[32m1\033[0m] = number of users, [\033[32m2\033[0m] = list users with default-pw, [\033[32m3\033[0m] = list all users")
1079
+
1080
+
1027
1081
  def add_zeroconf(ap):
1028
1082
  ap2 = ap.add_argument_group("Zeroconf options")
1029
1083
  ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)")
@@ -1432,11 +1486,13 @@ def run_argparse(
1432
1486
  add_tls(ap, cert_path)
1433
1487
  add_cert(ap, cert_path)
1434
1488
  add_auth(ap)
1489
+ add_chpw(ap)
1435
1490
  add_qr(ap, tty)
1436
1491
  add_zeroconf(ap)
1437
1492
  add_zc_mdns(ap)
1438
1493
  add_zc_ssdp(ap)
1439
1494
  add_fs(ap)
1495
+ add_share(ap)
1440
1496
  add_upload(ap)
1441
1497
  add_db_general(ap, hcores)
1442
1498
  add_db_metadata(ap)
@@ -1,8 +1,8 @@
1
1
  # coding: utf-8
2
2
 
3
- VERSION = (1, 13, 8)
4
- CODENAME = "race the beam"
5
- BUILD_DT = (2024, 8, 13)
3
+ VERSION = (1, 14, 1)
4
+ CODENAME = "one step forward"
5
+ BUILD_DT = (2024, 8, 19)
6
6
 
7
7
  S_VERSION = ".".join(map(str, VERSION))
8
8
  S_BUILD_DT = "{0:04d}-{1:02d}-{2:02d}".format(*BUILD_DT)
@@ -4,6 +4,7 @@ from __future__ import print_function, unicode_literals
4
4
  import argparse
5
5
  import base64
6
6
  import hashlib
7
+ import json
7
8
  import os
8
9
  import re
9
10
  import stat
@@ -37,6 +38,7 @@ from .util import (
37
38
  uncyg,
38
39
  undot,
39
40
  unhumanize,
41
+ vjoin,
40
42
  vsplit,
41
43
  )
42
44
 
@@ -334,6 +336,7 @@ class VFS(object):
334
336
  self.histtab = {} # all realpath->histpath
335
337
  self.dbv = None # closest full/non-jump parent
336
338
  self.lim = None # upload limits; only set for dbv
339
+ self.shr_src = None # source vfs+rem of a share
337
340
  self.aread = {}
338
341
  self.awrite = {}
339
342
  self.amove = {}
@@ -358,6 +361,8 @@ class VFS(object):
358
361
  self.all_aps = []
359
362
  self.all_vps = []
360
363
 
364
+ self.get_dbv = self._get_dbv
365
+
361
366
  def __repr__(self) :
362
367
  return "VFS(%s)" % (
363
368
  ", ".join(
@@ -519,7 +524,15 @@ class VFS(object):
519
524
 
520
525
  return vn, rem
521
526
 
522
- def get_dbv(self, vrem ) :
527
+ def _get_share_src(self, vrem ) :
528
+ src = self.shr_src
529
+ if not src:
530
+ return self._get_dbv(vrem)
531
+
532
+ shv, srem = src
533
+ return shv, vjoin(srem, vrem)
534
+
535
+ def _get_dbv(self, vrem ) :
523
536
  dbv = self.dbv
524
537
  if not dbv:
525
538
  return self, vrem
@@ -800,6 +813,7 @@ class AuthSrv(object):
800
813
  self.vfs = VFS(log_func, "", "", AXS(), {})
801
814
  self.acct = {}
802
815
  self.iacct = {}
816
+ self.defpw = {}
803
817
  self.grps = {}
804
818
  self.re_pwd = None
805
819
 
@@ -1345,7 +1359,7 @@ class AuthSrv(object):
1345
1359
  flags[name] = vals
1346
1360
  self._e("volflag [{}] += {} ({})".format(name, vals, desc))
1347
1361
 
1348
- def reload(self) :
1362
+ def reload(self, verbosity = 9) :
1349
1363
  """
1350
1364
  construct a flat list of mountpoints and usernames
1351
1365
  first from the commandline arguments
@@ -1353,9 +1367,9 @@ class AuthSrv(object):
1353
1367
  before finally building the VFS
1354
1368
  """
1355
1369
  with self.mutex:
1356
- self._reload()
1370
+ self._reload(verbosity)
1357
1371
 
1358
- def _reload(self) :
1372
+ def _reload(self, verbosity = 9) :
1359
1373
  acct = {} # username:password
1360
1374
  grps = {} # groupname:usernames
1361
1375
  daxs = {}
@@ -1433,6 +1447,8 @@ class AuthSrv(object):
1433
1447
  raise
1434
1448
 
1435
1449
  self.setup_pwhash(acct)
1450
+ defpw = acct.copy()
1451
+ self.setup_chpw(acct)
1436
1452
 
1437
1453
  # case-insensitive; normalize
1438
1454
  if WINDOWS:
@@ -1448,9 +1464,8 @@ class AuthSrv(object):
1448
1464
  vfs = VFS(self.log_func, absreal("."), "", axs, {})
1449
1465
  elif "" not in mount:
1450
1466
  # there's volumes but no root; make root inaccessible
1451
- vfs = VFS(self.log_func, "", "", AXS(), {})
1452
- vfs.flags["tcolor"] = self.args.tcolor
1453
- vfs.flags["d2d"] = True
1467
+ zsd = {"d2d": True, "tcolor": self.args.tcolor}
1468
+ vfs = VFS(self.log_func, "", "", AXS(), zsd)
1454
1469
 
1455
1470
  maxdepth = 0
1456
1471
  for dst in sorted(mount.keys(), key=lambda x: (x.count("/"), len(x))):
@@ -1479,6 +1494,52 @@ class AuthSrv(object):
1479
1494
  vol.all_vps.sort(key=lambda x: len(x[0]), reverse=True)
1480
1495
  vol.root = vfs
1481
1496
 
1497
+ enshare = self.args.shr
1498
+ shr = enshare[1:-1]
1499
+ shrs = enshare[1:]
1500
+ if enshare:
1501
+ import sqlite3
1502
+
1503
+ shv = VFS(self.log_func, "", shr, AXS(), {"d2d": True})
1504
+ par = vfs.all_vols[""]
1505
+
1506
+ db_path = self.args.shr_db
1507
+ db = sqlite3.connect(db_path)
1508
+ cur = db.cursor()
1509
+ now = time.time()
1510
+ for row in cur.execute("select * from sh"):
1511
+ s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row
1512
+ if s_t1 and s_t1 < now:
1513
+ continue
1514
+
1515
+ if self.args.shr_v:
1516
+ t = "loading %s share [%s] by [%s] => [%s]"
1517
+ self.log(t % (s_pr, s_k, s_un, s_vp))
1518
+
1519
+ if s_pw:
1520
+ sun = "s_%s" % (s_k,)
1521
+ acct[sun] = s_pw
1522
+ else:
1523
+ sun = "*"
1524
+
1525
+ s_axs = AXS(
1526
+ [sun] if "r" in s_pr else [],
1527
+ [sun] if "w" in s_pr else [],
1528
+ [sun] if "m" in s_pr else [],
1529
+ [sun] if "d" in s_pr else [],
1530
+ )
1531
+
1532
+ # don't know the abspath yet + wanna ensure the user
1533
+ # still has the privs they granted, so nullmap it
1534
+ shv.nodes[s_k] = VFS(
1535
+ self.log_func, "", "%s/%s" % (shr, s_k), s_axs, par.flags.copy()
1536
+ )
1537
+
1538
+ vfs.nodes[shr] = vfs.all_vols[shr] = shv
1539
+ for vol in shv.nodes.values():
1540
+ vfs.all_vols[vol.vpath] = vol
1541
+ vol.get_dbv = vol._get_share_src
1542
+
1482
1543
  zss = set(acct)
1483
1544
  zss.update(self.idp_accs)
1484
1545
  zss.discard("*")
@@ -1497,7 +1558,7 @@ class AuthSrv(object):
1497
1558
  for usr in unames:
1498
1559
  for vp, vol in vfs.all_vols.items():
1499
1560
  zx = getattr(vol.axs, axs_key)
1500
- if usr in zx:
1561
+ if usr in zx and (not enshare or not vp.startswith(shrs)):
1501
1562
  umap[usr].append(vp)
1502
1563
  umap[usr].sort()
1503
1564
  setattr(vfs, "a" + perm, umap)
@@ -1547,6 +1608,8 @@ class AuthSrv(object):
1547
1608
 
1548
1609
  for usr in acct:
1549
1610
  if usr not in associated_users:
1611
+ if enshare and usr.startswith("s_"):
1612
+ continue
1550
1613
  if len(vfs.all_vols) > 1:
1551
1614
  # user probably familiar enough that the verbose message is not necessary
1552
1615
  t = "account [%s] is not mentioned in any volume definitions; see --help-accounts"
@@ -1982,7 +2045,7 @@ class AuthSrv(object):
1982
2045
  have_e2t = False
1983
2046
  t = "volumes and permissions:\n"
1984
2047
  for zv in vfs.all_vols.values():
1985
- if not self.warn_anonwrite:
2048
+ if not self.warn_anonwrite or verbosity < 5:
1986
2049
  break
1987
2050
 
1988
2051
  t += '\n\033[36m"/{}" \033[33m{}\033[0m'.format(zv.vpath, zv.realpath)
@@ -2011,7 +2074,7 @@ class AuthSrv(object):
2011
2074
 
2012
2075
  t += "\n"
2013
2076
 
2014
- if self.warn_anonwrite:
2077
+ if self.warn_anonwrite and verbosity > 4:
2015
2078
  if not self.args.no_voldump:
2016
2079
  self.log(t)
2017
2080
 
@@ -2035,7 +2098,7 @@ class AuthSrv(object):
2035
2098
 
2036
2099
  try:
2037
2100
  zv, _ = vfs.get("", "*", False, True, err=999)
2038
- if self.warn_anonwrite and os.getcwd() == zv.realpath:
2101
+ if self.warn_anonwrite and verbosity > 4 and os.getcwd() == zv.realpath:
2039
2102
  t = "anyone can write to the current directory: {}\n"
2040
2103
  self.log(t.format(zv.realpath), c=1)
2041
2104
 
@@ -2062,6 +2125,7 @@ class AuthSrv(object):
2062
2125
 
2063
2126
  self.vfs = vfs
2064
2127
  self.acct = acct
2128
+ self.defpw = defpw
2065
2129
  self.grps = grps
2066
2130
  self.iacct = {v: k for k, v in acct.items()}
2067
2131
 
@@ -2082,6 +2146,155 @@ class AuthSrv(object):
2082
2146
  MIMES[ext] = mime
2083
2147
  EXTS.update({v: k for k, v in MIMES.items()})
2084
2148
 
2149
+ if enshare:
2150
+ # hide shares from controlpanel
2151
+ vfs.all_vols = {
2152
+ x: y
2153
+ for x, y in vfs.all_vols.items()
2154
+ if x != shr and not x.startswith(shrs)
2155
+ }
2156
+
2157
+ assert cur # type: ignore
2158
+ assert shv # type: ignore
2159
+ for row in cur.execute("select * from sh"):
2160
+ s_k, s_pw, s_vp, s_pr, s_st, s_un, s_t0, s_t1 = row
2161
+ shn = shv.nodes.get(s_k, None)
2162
+ if not shn:
2163
+ continue
2164
+
2165
+ try:
2166
+ s_vfs, s_rem = vfs.get(
2167
+ s_vp, s_un, "r" in s_pr, "w" in s_pr, "m" in s_pr, "d" in s_pr
2168
+ )
2169
+ except Exception as ex:
2170
+ t = "removing share [%s] by [%s] to [%s] due to %r"
2171
+ self.log(t % (s_k, s_un, s_vp, ex), 3)
2172
+ shv.nodes.pop(s_k)
2173
+ continue
2174
+
2175
+ shn.shr_src = (s_vfs, s_rem)
2176
+ shn.realpath = s_vfs.canonical(s_rem)
2177
+
2178
+ if self.args.shr_v:
2179
+ t = "mapped %s share [%s] by [%s] => [%s] => [%s]"
2180
+ self.log(t % (s_pr, s_k, s_un, s_vp, shn.realpath))
2181
+
2182
+ # transplant shadowing into shares
2183
+ for vn in shv.nodes.values():
2184
+ svn, srem = vn.shr_src # type: ignore
2185
+ if srem:
2186
+ continue # free branch, safe
2187
+ ap = svn.canonical(srem)
2188
+ if bos.path.isfile(ap):
2189
+ continue # also fine
2190
+ for zs in svn.nodes.keys():
2191
+ # hide subvolume
2192
+ vn.nodes[zs] = VFS(self.log_func, "", "", AXS(), {})
2193
+
2194
+ def chpw(self, broker , uname, pw) :
2195
+ if not self.args.chpw:
2196
+ return False, "feature disabled in server config"
2197
+
2198
+ if uname == "*" or uname not in self.defpw:
2199
+ return False, "not logged in"
2200
+
2201
+ if uname in self.args.chpw_no:
2202
+ return False, "not allowed for this account"
2203
+
2204
+ if len(pw) < self.args.chpw_len:
2205
+ t = "minimum password length: %d characters"
2206
+ return False, t % (self.args.chpw_len,)
2207
+
2208
+ hpw = self.ah.hash(pw) if self.ah.on else pw
2209
+
2210
+ if hpw == self.acct[uname]:
2211
+ return False, "that's already your password my dude"
2212
+
2213
+ if hpw in self.iacct:
2214
+ return False, "password is taken"
2215
+
2216
+ with self.mutex:
2217
+ ap = self.args.chpw_db
2218
+ if not bos.path.exists(ap):
2219
+ pwdb = {}
2220
+ else:
2221
+ with open(ap, "r", encoding="utf-8") as f:
2222
+ pwdb = json.load(f)
2223
+
2224
+ pwdb = [x for x in pwdb if x[0] != uname]
2225
+ pwdb.append((uname, self.defpw[uname], hpw))
2226
+
2227
+ with open(ap, "w", encoding="utf-8") as f:
2228
+ json.dump(pwdb, f, separators=(",\n", ": "))
2229
+
2230
+ self.log("reinitializing due to password-change for user [%s]" % (uname,))
2231
+
2232
+ if not broker:
2233
+ # only true for tests
2234
+ self._reload()
2235
+ return True, "new password OK"
2236
+
2237
+ broker.ask("_reload_blocking", False, False).get()
2238
+ return True, "new password OK"
2239
+
2240
+ def setup_chpw(self, acct ) :
2241
+ ap = self.args.chpw_db
2242
+ if not self.args.chpw or not bos.path.exists(ap):
2243
+ return
2244
+
2245
+ with open(ap, "r", encoding="utf-8") as f:
2246
+ pwdb = json.load(f)
2247
+
2248
+ useen = set()
2249
+ urst = set()
2250
+ uok = set()
2251
+ for usr, orig, mod in pwdb:
2252
+ useen.add(usr)
2253
+ if usr not in acct:
2254
+ # previous user, no longer known
2255
+ continue
2256
+ if acct[usr] != orig:
2257
+ urst.add(usr)
2258
+ continue
2259
+ uok.add(usr)
2260
+ acct[usr] = mod
2261
+
2262
+ if not self.args.chpw_v:
2263
+ return
2264
+
2265
+ for usr in acct:
2266
+ if usr not in useen:
2267
+ urst.add(usr)
2268
+
2269
+ for zs in uok:
2270
+ urst.discard(zs)
2271
+
2272
+ if self.args.chpw_v == 1 or (self.args.chpw_v == 2 and not urst):
2273
+ t = "chpw: %d changed, %d unchanged"
2274
+ self.log(t % (len(uok), len(urst)))
2275
+ return
2276
+
2277
+ elif self.args.chpw_v == 2:
2278
+ t = "chpw: %d changed" % (len(uok))
2279
+ if urst:
2280
+ t += ", \033[0munchanged:\033[35m %s" % (", ".join(list(urst)))
2281
+
2282
+ self.log(t, 6)
2283
+ return
2284
+
2285
+ msg = ""
2286
+ if uok:
2287
+ t = "\033[0mchanged: \033[32m%s"
2288
+ msg += t % (", ".join(list(uok)),)
2289
+ if urst:
2290
+ t = "%s\033[0munchanged: \033[35m%s"
2291
+ msg += t % (
2292
+ ", " if msg else "",
2293
+ ", ".join(list(urst)),
2294
+ )
2295
+
2296
+ self.log("chpw: " + msg, 6)
2297
+
2085
2298
  def setup_pwhash(self, acct ) :
2086
2299
  self.ah = PWHash(self.args)
2087
2300
  if not self.ah.on: