flatwiki 0.1.0__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.
- flatwiki/__main__.py +5 -0
- flatwiki/cli.py +72 -0
- flatwiki/core.py +595 -0
- flatwiki-0.1.0.dist-info/METADATA +43 -0
- flatwiki-0.1.0.dist-info/RECORD +9 -0
- flatwiki-0.1.0.dist-info/WHEEL +5 -0
- flatwiki-0.1.0.dist-info/entry_points.txt +2 -0
- flatwiki-0.1.0.dist-info/licenses/LICENSE +661 -0
- flatwiki-0.1.0.dist-info/top_level.txt +1 -0
flatwiki/__main__.py
ADDED
flatwiki/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from argon2 import PasswordHasher
|
|
5
|
+
from flatwiki.core import run_server
|
|
6
|
+
|
|
7
|
+
ph = PasswordHasher()
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
if len(sys.argv) == 1:
|
|
11
|
+
run_server()
|
|
12
|
+
|
|
13
|
+
elif len(sys.argv) > 2:
|
|
14
|
+
print("Error: Too many arguments")
|
|
15
|
+
exit(1)
|
|
16
|
+
|
|
17
|
+
else:
|
|
18
|
+
match sys.argv[1]:
|
|
19
|
+
case "create-user":
|
|
20
|
+
username = input("username:")
|
|
21
|
+
password = input("password:")
|
|
22
|
+
hash = ph.hash(password)
|
|
23
|
+
|
|
24
|
+
CURRENT_DIR = os.getcwd()
|
|
25
|
+
CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
|
|
26
|
+
|
|
27
|
+
if not os.path.exists(CONFIG_PATH):
|
|
28
|
+
print(f"Error: Could not find 'config.json' in {CURRENT_DIR}")
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
32
|
+
try:
|
|
33
|
+
CONFIG = json.load(f)
|
|
34
|
+
except:
|
|
35
|
+
print("Error: Invalid JSON content in config.json")
|
|
36
|
+
exit(1)
|
|
37
|
+
|
|
38
|
+
if "admins" not in CONFIG:
|
|
39
|
+
CONFIG["admins"] = {}
|
|
40
|
+
|
|
41
|
+
CONFIG["admins"][username] = hash
|
|
42
|
+
|
|
43
|
+
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
44
|
+
CONFIG = json.dump(CONFIG, f, indent=4)
|
|
45
|
+
|
|
46
|
+
exit(0)
|
|
47
|
+
|
|
48
|
+
case "config":
|
|
49
|
+
CONFIG = {}
|
|
50
|
+
CONFIG["wiki_title"] = input("Wiki Title (the name of your wiki displayed on the website):")
|
|
51
|
+
CONFIG["port"] = input("Port (usually 80 for production or 8080 for testing):")
|
|
52
|
+
CONFIG["pages_directory"] = input("Pages Directory (directory to store pages in relative to current working directory; usually 'pages'):")
|
|
53
|
+
confirm = input("Please confirm. THIS ACTION WILL OVERWRITE ANY FILE IN THE CURRENT DIRECTORY NAMED 'config.json'! Type 'overwrite' to continue with this DESTRUCTIVE action:")
|
|
54
|
+
if confirm != "overwrite":
|
|
55
|
+
print("Safely cancelled.")
|
|
56
|
+
exit(0)
|
|
57
|
+
CURRENT_DIR = os.getcwd()
|
|
58
|
+
CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
|
|
59
|
+
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
60
|
+
json.dump(CONFIG, f, indent=4)
|
|
61
|
+
|
|
62
|
+
print("Success! Your config file has been built, now use the command 'flatwiki create-user' to add your first admin to this config file.")
|
|
63
|
+
exit(0)
|
|
64
|
+
|
|
65
|
+
case _:
|
|
66
|
+
print("Error: Unknown argument")
|
|
67
|
+
exit(1)
|
|
68
|
+
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
flatwiki/core.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from argon2 import PasswordHasher
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import markdown
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from flask import Flask, abort, render_template_string, request, session, redirect, url_for, send_from_directory
|
|
8
|
+
import secrets
|
|
9
|
+
from waitress import serve
|
|
10
|
+
|
|
11
|
+
ph = PasswordHasher()
|
|
12
|
+
|
|
13
|
+
app = Flask(__name__)
|
|
14
|
+
|
|
15
|
+
CURRENT_DIR = os.getcwd()
|
|
16
|
+
CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
|
|
17
|
+
|
|
18
|
+
if not os.path.exists(CONFIG_PATH):
|
|
19
|
+
print(f"Error: Could not find 'config.json' in {CURRENT_DIR}")
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
23
|
+
try:
|
|
24
|
+
CONFIG = json.load(f)
|
|
25
|
+
except:
|
|
26
|
+
print("Error: Invalid JSON content in config.json")
|
|
27
|
+
exit(1)
|
|
28
|
+
|
|
29
|
+
PAGES_DIR = os.path.join(CURRENT_DIR, CONFIG["pages_directory"])
|
|
30
|
+
WIKI_TITLE = CONFIG["wiki_title"]
|
|
31
|
+
PORT = CONFIG["port"]
|
|
32
|
+
|
|
33
|
+
ADMINS = CONFIG["admins"]
|
|
34
|
+
|
|
35
|
+
app.secret_key = secrets.token_hex(32)
|
|
36
|
+
|
|
37
|
+
os.makedirs(PAGES_DIR, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
@app.route('/admin/static/style.css')
|
|
40
|
+
def admin_style():
|
|
41
|
+
css = """
|
|
42
|
+
* {
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
margin: 0;
|
|
45
|
+
padding: 0;
|
|
46
|
+
}
|
|
47
|
+
body {
|
|
48
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
49
|
+
font-size: 13px;
|
|
50
|
+
color: #333;
|
|
51
|
+
background: linear-gradient(to bottom, #dbe3eb 0%, #f0f4f7 300px, #f5f5f5 100%);
|
|
52
|
+
background-repeat: no-repeat;
|
|
53
|
+
background-attachment: fixed;
|
|
54
|
+
min-height: 100vh;
|
|
55
|
+
}
|
|
56
|
+
a {
|
|
57
|
+
color: #005580;
|
|
58
|
+
text-decoration: none;
|
|
59
|
+
font-weight: bold;
|
|
60
|
+
}
|
|
61
|
+
a:hover {
|
|
62
|
+
text-decoration: underline;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#main-header {
|
|
66
|
+
background: linear-gradient(to bottom, #4c4d50 0%, #353638 50%, #222324 51%, #1b1c1d 100%);
|
|
67
|
+
color: #fff;
|
|
68
|
+
height: 50px;
|
|
69
|
+
display: flex;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
align-items: center;
|
|
72
|
+
padding: 0 20px;
|
|
73
|
+
border-bottom: 3px solid #ff9900; /* High-contrast orange or teal accent strip */
|
|
74
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
75
|
+
}
|
|
76
|
+
#main-header .logo {
|
|
77
|
+
font-size: 16px;
|
|
78
|
+
color: #fff;
|
|
79
|
+
text-shadow: 0 -1px 0 rgba(0,0,0,0.8);
|
|
80
|
+
}
|
|
81
|
+
#main-header .logo strong {
|
|
82
|
+
color: #ff9900;
|
|
83
|
+
}
|
|
84
|
+
#main-header .user-profile {
|
|
85
|
+
font-size: 11px;
|
|
86
|
+
color: #ddd;
|
|
87
|
+
text-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
|
88
|
+
}
|
|
89
|
+
#main-header .user-profile a {
|
|
90
|
+
color: #fff;
|
|
91
|
+
background: linear-gradient(to bottom, #555, #333);
|
|
92
|
+
padding: 3px 8px;
|
|
93
|
+
border: 1px solid #222;
|
|
94
|
+
border-radius: 3px;
|
|
95
|
+
font-weight: normal;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#app-container {
|
|
99
|
+
display: flex;
|
|
100
|
+
min-height: calc(100vh - 50px);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#sidebar {
|
|
104
|
+
width: 220px;
|
|
105
|
+
/* Vertical light-to-dark side panel gradient */
|
|
106
|
+
background: linear-gradient(to right, #e4e4e4 0%, #d4d4d4 100%);
|
|
107
|
+
border-right: 1px solid #b3b3b3;
|
|
108
|
+
padding: 10px 0;
|
|
109
|
+
box-shadow: inset -2px 0 5px rgba(0,0,0,0.05);
|
|
110
|
+
}
|
|
111
|
+
.nav-section h3 {
|
|
112
|
+
font-size: 11px;
|
|
113
|
+
text-transform: uppercase;
|
|
114
|
+
color: #555;
|
|
115
|
+
/* Textured header bars for sub-navigation */
|
|
116
|
+
background: linear-gradient(to bottom, #dadada, #c8c8c8);
|
|
117
|
+
padding: 6px 15px;
|
|
118
|
+
border-top: 1px solid #eee;
|
|
119
|
+
border-bottom: 1px solid #b5b5b5;
|
|
120
|
+
text-shadow: 0 1px 0 rgba(255,255,255,0.6);
|
|
121
|
+
margin-bottom: 5px;
|
|
122
|
+
}
|
|
123
|
+
.nav-section ul {
|
|
124
|
+
list-style: none;
|
|
125
|
+
margin-bottom: 15px;
|
|
126
|
+
}
|
|
127
|
+
.nav-section ul li a {
|
|
128
|
+
display: block;
|
|
129
|
+
padding: 8px 20px;
|
|
130
|
+
color: #444;
|
|
131
|
+
font-weight: normal;
|
|
132
|
+
text-shadow: 0 1px 0 rgba(255,255,255,0.5);
|
|
133
|
+
border-bottom: 1px solid #cbcbcb;
|
|
134
|
+
}
|
|
135
|
+
.nav-section ul li.active a,
|
|
136
|
+
.nav-section ul li a:hover {
|
|
137
|
+
background: linear-gradient(to bottom, #ffffff 0%, #e6e6e6 100%);
|
|
138
|
+
color: #000;
|
|
139
|
+
font-weight: bold;
|
|
140
|
+
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#main-content {
|
|
144
|
+
flex: 1;
|
|
145
|
+
padding: 20px;
|
|
146
|
+
}
|
|
147
|
+
.breadcrumb {
|
|
148
|
+
font-size: 11px;
|
|
149
|
+
color: #666;
|
|
150
|
+
margin-bottom: 12px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
h1 {
|
|
154
|
+
font-size: 20px;
|
|
155
|
+
font-weight: bold;
|
|
156
|
+
color: #222;
|
|
157
|
+
padding: 10px 15px;
|
|
158
|
+
margin-bottom: 20px;
|
|
159
|
+
/* Subtle header canvas gradient styling */
|
|
160
|
+
background: linear-gradient(to bottom, #ffffff, #e1e1e1);
|
|
161
|
+
border: 1px solid #ccc;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
text-shadow: 0 1px 0 #fff;
|
|
164
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.inner-content {
|
|
168
|
+
background: #fff;
|
|
169
|
+
border: 1px solid #b5b5b5;
|
|
170
|
+
border-radius: 4px;
|
|
171
|
+
padding: 15px;
|
|
172
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.action-bar {
|
|
176
|
+
background: linear-gradient(to bottom, #fafafa 0%, #eaeaea 100%);
|
|
177
|
+
padding: 10px;
|
|
178
|
+
border: 1px solid #cccccc;
|
|
179
|
+
border-bottom: 2px solid #b3b3b3;
|
|
180
|
+
margin-bottom: 20px;
|
|
181
|
+
display: flex;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
align-items: center;
|
|
184
|
+
}
|
|
185
|
+
.search-input {
|
|
186
|
+
padding: 5px 8px;
|
|
187
|
+
border: 1px solid #bbb;
|
|
188
|
+
border-radius: 3px;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.btn {
|
|
194
|
+
padding: 6px 14px;
|
|
195
|
+
font-size: 12px;
|
|
196
|
+
font-weight: bold;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
border-radius: 4px;
|
|
199
|
+
text-shadow: 0 1px 0 #fff;
|
|
200
|
+
border: 1px solid #adc2ce;
|
|
201
|
+
border-top: 1px solid #c2d5e0;
|
|
202
|
+
border-bottom: 1px solid #8fa2ad;
|
|
203
|
+
background: linear-gradient(to bottom, #ffffff 0%, #f3f3f3 50%, #ededed 51%, #e0e0e0 100%);
|
|
204
|
+
color: #444;
|
|
205
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
206
|
+
}
|
|
207
|
+
.btn:hover {
|
|
208
|
+
background: linear-gradient(to bottom, #f9f9f9 0%, #e3e3e3 50%, #dadada 51%, #cdcdcd 100%);
|
|
209
|
+
border-color: #7a8c97;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.btn-primary {
|
|
213
|
+
color: #fff;
|
|
214
|
+
text-shadow: 0 -1px 0 rgba(0,0,0,0.4);
|
|
215
|
+
border: 1px solid #1a7185;
|
|
216
|
+
border-top: 1px solid #54cbfa;
|
|
217
|
+
border-bottom: 1px solid #114f5e;
|
|
218
|
+
background: linear-gradient(to bottom, #49c0dc 0%, #2ca1be 50%, #1f8fa9 51%, #1a7e96 100%);
|
|
219
|
+
}
|
|
220
|
+
.btn-primary:hover {
|
|
221
|
+
background: linear-gradient(to bottom, #3caec8 0%, #2390ab 50%, #177d95 51%, #126b80 100%);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.data-table {
|
|
225
|
+
width: 100%;
|
|
226
|
+
border-collapse: collapse;
|
|
227
|
+
border: 1px solid #bbb;
|
|
228
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
229
|
+
}
|
|
230
|
+
.data-table th {
|
|
231
|
+
background: linear-gradient(to bottom, #ffffff 0%, #f0f0f0 50%, #e3e3e3 51%, #dfdfdf 100%);
|
|
232
|
+
border: 1px solid #bbb;
|
|
233
|
+
border-top: 1px solid #fff; /* Highlights upper boundaries */
|
|
234
|
+
padding: 10px;
|
|
235
|
+
text-align: left;
|
|
236
|
+
font-size: 12px;
|
|
237
|
+
color: #444;
|
|
238
|
+
font-weight: bold;
|
|
239
|
+
text-shadow: 0 1px 0 #fff;
|
|
240
|
+
}
|
|
241
|
+
.data-table td {
|
|
242
|
+
padding: 10px;
|
|
243
|
+
border: 1px solid #ddd;
|
|
244
|
+
font-size: 12px;
|
|
245
|
+
background-color: #fff;
|
|
246
|
+
}
|
|
247
|
+
.data-table tr.alt td {
|
|
248
|
+
background-color: #f6f8fa;
|
|
249
|
+
}
|
|
250
|
+
.data-table tbody tr:hover td {
|
|
251
|
+
background: linear-gradient(to bottom, #f2f9ff 0%, #e1f0fc 100%) !important;
|
|
252
|
+
color: #000;
|
|
253
|
+
}
|
|
254
|
+
.delete {
|
|
255
|
+
color: #a90000;
|
|
256
|
+
}
|
|
257
|
+
"""
|
|
258
|
+
return css, 200, {'Content-Type': 'text/css'}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
PUBLIC_LAYOUT = """
|
|
262
|
+
<!DOCTYPE html>
|
|
263
|
+
<html>
|
|
264
|
+
<head>
|
|
265
|
+
<title>{{ title }} - """ + WIKI_TITLE + """</title>
|
|
266
|
+
<style>
|
|
267
|
+
body { font-family: sans-serif; display: flex; margin: 0; padding: 0; }
|
|
268
|
+
#sidebar { width: 240px; background: #f4f5f7; height: 100vh; padding: 20px; border-right: 1px solid #e1e4e8; }
|
|
269
|
+
#content { flex: 1; padding: 40px; max-width: 800px; line-height: 1.6; }
|
|
270
|
+
a { color: #0366d6; text-decoration: none; }
|
|
271
|
+
ul { list-style: none; padding: 0; }
|
|
272
|
+
li { margin-bottom: 8px; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<div id="sidebar">
|
|
277
|
+
<h3>""" + WIKI_TITLE + """</h3>
|
|
278
|
+
<a href="/">Home</a><hr>
|
|
279
|
+
<ul>
|
|
280
|
+
{% for route, display in menu_items %}
|
|
281
|
+
<li><a href="/{{ route }}">{{ display }}</a></li>
|
|
282
|
+
{% endfor %}
|
|
283
|
+
</ul>
|
|
284
|
+
<br><a href="/admin" style="font-size:12px; color:#888;">Admin Login</a>
|
|
285
|
+
</div>
|
|
286
|
+
<div id="content">{{ content|safe }}</div>
|
|
287
|
+
</body>
|
|
288
|
+
</html>
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
ADMIN_LAYOUT = """
|
|
292
|
+
<!DOCTYPE html>
|
|
293
|
+
<html lang="en">
|
|
294
|
+
<head>
|
|
295
|
+
<meta charset="UTF-8">
|
|
296
|
+
<title>Admin Dashboard</title>
|
|
297
|
+
<link rel="stylesheet" href="/admin/static/style.css">
|
|
298
|
+
</head>
|
|
299
|
+
<body>
|
|
300
|
+
<header id="main-header">
|
|
301
|
+
<div class="logo">FLATWIKI <strong>ADMIN</strong></div>
|
|
302
|
+
<div class="user-profile"><a href="/logout">Logout</a></div>
|
|
303
|
+
</header>
|
|
304
|
+
<div id="app-container">
|
|
305
|
+
<nav id="sidebar">
|
|
306
|
+
<div class="nav-section">
|
|
307
|
+
<h3>Wiki Structure</h3>
|
|
308
|
+
<ul>
|
|
309
|
+
<li class="active"><a href="/admin">Dashboard</a></li>
|
|
310
|
+
</ul>
|
|
311
|
+
</div>
|
|
312
|
+
</nav>
|
|
313
|
+
<main id="main-content">
|
|
314
|
+
<div class="breadcrumb">
|
|
315
|
+
<a href="/">Home</a> » <a href="/admin">Dashboard</a>
|
|
316
|
+
</div>
|
|
317
|
+
<h1>Dashboard</h1>
|
|
318
|
+
<div class="inner-content">
|
|
319
|
+
<div class="action-bar">
|
|
320
|
+
<form method="POST" action="/admin/create" style="border: none; margin: 0; padding: 0;">
|
|
321
|
+
<input type="text" name="filename" placeholder="new-page-name" required class="search-input" autocomplete="off">
|
|
322
|
+
<button type="submit" class="btn btn-primary">Create New Page</button>
|
|
323
|
+
</form>
|
|
324
|
+
</div>
|
|
325
|
+
<table class="data-table">
|
|
326
|
+
<thead>
|
|
327
|
+
<tr>
|
|
328
|
+
<th width="5%">ID</th>
|
|
329
|
+
<th width="50%">Title</th>
|
|
330
|
+
<th width="25%">Last Modified</th>
|
|
331
|
+
<th width="20%">Actions</th>
|
|
332
|
+
</tr>
|
|
333
|
+
</thead>
|
|
334
|
+
<tbody>
|
|
335
|
+
{% for item in files_data %}
|
|
336
|
+
<tr class="{{ 'alt' if loop.index % 2 == 0 else '' }}">
|
|
337
|
+
<td>{{ loop.index }}</td>
|
|
338
|
+
<td><strong>{{ item.title }}</strong> ({{ item.filename }})</td>
|
|
339
|
+
<td>{{ item.modified }}</td>
|
|
340
|
+
<td>
|
|
341
|
+
<a href="/admin/edit/{{ item.filename }}">Edit</a> |
|
|
342
|
+
<form method="POST" action="/admin/delete/{{ item.filename }}" style="display:inline;">
|
|
343
|
+
<a href="#" class="delete"
|
|
344
|
+
onclick="if(confirm('Are you sure you want to delete this file permanently?')) { this.closest('form').submit(); } return false;">
|
|
345
|
+
Delete
|
|
346
|
+
</a>
|
|
347
|
+
</form>
|
|
348
|
+
</td>
|
|
349
|
+
</tr>
|
|
350
|
+
{% endfor %}
|
|
351
|
+
</tbody>
|
|
352
|
+
</table>
|
|
353
|
+
</div>
|
|
354
|
+
</main>
|
|
355
|
+
</div>
|
|
356
|
+
</body>
|
|
357
|
+
</html>
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
EDIT_LAYOUT = """
|
|
361
|
+
<!DOCTYPE html>
|
|
362
|
+
<html lang="en">
|
|
363
|
+
<head>
|
|
364
|
+
<meta charset="UTF-8">
|
|
365
|
+
<title>Editing {{ filename }}</title>
|
|
366
|
+
<link rel="stylesheet" href="/admin/static/style.css">
|
|
367
|
+
<style>
|
|
368
|
+
textarea { width: 100%; height: 450px; font-family: monospace; font-size: 14px; padding: 15px; border: 1px solid #bbb; border-radius: 4px; resize: vertical; margin-bottom: 20px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); box-sizing: border-box; }
|
|
369
|
+
.btn { text-decoration: none; display: inline-block; }
|
|
370
|
+
p.note { font-size: 12px; color: #666; margin-bottom: 20px; }
|
|
371
|
+
</style>
|
|
372
|
+
</head>
|
|
373
|
+
<body>
|
|
374
|
+
<header id="main-header">
|
|
375
|
+
<div class="logo">FLATWIKI <strong>ADMIN</strong></div>
|
|
376
|
+
<div class="user-profile"><a href="/logout">Logout</a></div>
|
|
377
|
+
</header>
|
|
378
|
+
<div id="app-container">
|
|
379
|
+
<nav id="sidebar">
|
|
380
|
+
<div class="nav-section">
|
|
381
|
+
<h3>Wiki Structure</h3>
|
|
382
|
+
<ul>
|
|
383
|
+
<li><a href="/admin">Dashboard</a></li>
|
|
384
|
+
<li class="active"><a href="#">{{ filename }}</a></li>
|
|
385
|
+
</ul>
|
|
386
|
+
</div>
|
|
387
|
+
</nav>
|
|
388
|
+
<main id="main-content">
|
|
389
|
+
<div class="breadcrumb">
|
|
390
|
+
<a href="/">Home</a> » <a href="/admin">Dashboard</a> » {{ filename }}
|
|
391
|
+
</div>
|
|
392
|
+
<h1>Editing: {{ filename }}</h1>
|
|
393
|
+
<div class="inner-content">
|
|
394
|
+
<p class="note">This editor supports standard Markdown formatting.</p>
|
|
395
|
+
<form method="POST">
|
|
396
|
+
<textarea name="content">{{ content }}</textarea>
|
|
397
|
+
<div style="display: flex; gap: 10px;">
|
|
398
|
+
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
399
|
+
<a href="/admin" class="btn">Cancel</a>
|
|
400
|
+
</div>
|
|
401
|
+
</form>
|
|
402
|
+
</div>
|
|
403
|
+
</main>
|
|
404
|
+
</div>
|
|
405
|
+
</body>
|
|
406
|
+
</html>
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
LOGIN_LAYOUT = """
|
|
410
|
+
<!DOCTYPE html>
|
|
411
|
+
<html lang="en">
|
|
412
|
+
<head>
|
|
413
|
+
<meta charset="UTF-8">
|
|
414
|
+
<title>Admin Login</title>
|
|
415
|
+
<link rel="stylesheet" href="/admin/static/style.css">
|
|
416
|
+
</head>
|
|
417
|
+
<body>
|
|
418
|
+
<header id="main-header">
|
|
419
|
+
<div class="logo">FLATWIKI <strong>ADMIN</strong></div>
|
|
420
|
+
</header>
|
|
421
|
+
<div style="display: flex; justify-content: center; align-items: center; min-height: calc(100vh - 50px);">
|
|
422
|
+
<div style="background: #fff; border: 1px solid #b5b5b5; border-radius: 4px; padding: 30px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); width: 360px;">
|
|
423
|
+
<h1 style="margin-bottom: 24px; text-align: center;">Admin Login</h1>
|
|
424
|
+
{% if error %}<div style="color: #a90000; font-size: 13px; margin-bottom: 15px; text-align: center;">{{ error }}</div>{% endif %}
|
|
425
|
+
<form method="POST" action="/login">
|
|
426
|
+
<div style="margin-bottom: 15px;">
|
|
427
|
+
<label style="display: block; font-size: 12px; color: #555; margin-bottom: 5px; font-weight: bold;">Username</label>
|
|
428
|
+
<input autocomplete="off" type="text" name="username" required style="width: 100%; padding: 8px 10px; border: 1px solid #bbb; border-radius: 3px; font-size: 13px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); box-sizing: border-box;">
|
|
429
|
+
</div>
|
|
430
|
+
<div style="margin-bottom: 20px;">
|
|
431
|
+
<label style="display: block; font-size: 12px; color: #555; margin-bottom: 5px; font-weight: bold;">Password</label>
|
|
432
|
+
<input autocomplete="off" type="password" name="password" required style="width: 100%; padding: 8px 10px; border: 1px solid #bbb; border-radius: 3px; font-size: 13px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); box-sizing: border-box;">
|
|
433
|
+
</div>
|
|
434
|
+
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 8px 14px;">Log In</button>
|
|
435
|
+
</form>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</body>
|
|
439
|
+
</html>
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def get_sorted_menu():
|
|
443
|
+
"""Generates an alphabetical page listing for the sidebar navbar display."""
|
|
444
|
+
if not os.path.exists(PAGES_DIR):
|
|
445
|
+
return []
|
|
446
|
+
files = [f for f in os.listdir(PAGES_DIR) if f.endswith(".md") and f != "index.md"]
|
|
447
|
+
files.sort()
|
|
448
|
+
|
|
449
|
+
menu = []
|
|
450
|
+
for f in files:
|
|
451
|
+
route = f.rsplit('.', 1)[0]
|
|
452
|
+
display = route.replace('-', ' ').replace('_', ' ').title()
|
|
453
|
+
menu.append((route, display))
|
|
454
|
+
return menu
|
|
455
|
+
|
|
456
|
+
@app.route('/admin/create', methods=['POST'])
|
|
457
|
+
def admin_create_file():
|
|
458
|
+
if not session.get('logged_in'):
|
|
459
|
+
return redirect(url_for('login'))
|
|
460
|
+
|
|
461
|
+
raw_name = request.form.get('filename', '').strip().lower()
|
|
462
|
+
safe_name = os.path.basename(raw_name).replace(' ', '-')
|
|
463
|
+
|
|
464
|
+
if not safe_name:
|
|
465
|
+
return redirect(url_for('admin_dashboard'))
|
|
466
|
+
|
|
467
|
+
if not safe_name.endswith('.md'):
|
|
468
|
+
safe_name += '.md'
|
|
469
|
+
|
|
470
|
+
file_path = os.path.join(PAGES_DIR, safe_name)
|
|
471
|
+
|
|
472
|
+
if not os.path.exists(file_path):
|
|
473
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
474
|
+
f.write(f"# {safe_name.rsplit('.', 1)[0].replace('-', ' ').title()}\nWrite content here...")
|
|
475
|
+
|
|
476
|
+
return redirect(url_for('admin_dashboard'))
|
|
477
|
+
|
|
478
|
+
@app.route('/admin/edit/<filename>', methods=['GET', 'POST'])
|
|
479
|
+
def admin_edit_file(filename):
|
|
480
|
+
if not session.get('logged_in'):
|
|
481
|
+
return redirect(url_for('login'))
|
|
482
|
+
|
|
483
|
+
safe_filename = os.path.basename(filename)
|
|
484
|
+
file_path = os.path.join(PAGES_DIR, safe_filename)
|
|
485
|
+
|
|
486
|
+
if not os.path.exists(file_path):
|
|
487
|
+
return "Error: File does not exist.", 404
|
|
488
|
+
|
|
489
|
+
if request.method == 'POST':
|
|
490
|
+
updated_markdown = request.form.get('content', '')
|
|
491
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
492
|
+
f.write(updated_markdown)
|
|
493
|
+
return redirect(url_for('admin_dashboard'))
|
|
494
|
+
|
|
495
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
496
|
+
file_content = f.read()
|
|
497
|
+
|
|
498
|
+
return render_template_string(EDIT_LAYOUT, filename=safe_filename, content=file_content)
|
|
499
|
+
|
|
500
|
+
@app.route('/admin/delete/<filename>', methods=['POST'])
|
|
501
|
+
def admin_delete_file(filename):
|
|
502
|
+
if not session.get('logged_in'):
|
|
503
|
+
return redirect(url_for('login'))
|
|
504
|
+
|
|
505
|
+
safe_filename = os.path.basename(filename)
|
|
506
|
+
file_path = os.path.join(PAGES_DIR, safe_filename)
|
|
507
|
+
|
|
508
|
+
if safe_filename == "index.md":
|
|
509
|
+
return "Error: Cannot delete the core homepage layout.", 403
|
|
510
|
+
|
|
511
|
+
if os.path.exists(file_path):
|
|
512
|
+
os.remove(file_path)
|
|
513
|
+
|
|
514
|
+
return redirect(url_for('admin_dashboard'))
|
|
515
|
+
|
|
516
|
+
@app.route('/login', methods=['GET', 'POST'])
|
|
517
|
+
def login():
|
|
518
|
+
error = None
|
|
519
|
+
if request.method == 'POST':
|
|
520
|
+
try:
|
|
521
|
+
ph.verify(ADMINS[request.form['username']], request.form['password'])
|
|
522
|
+
session['logged_in'] = True
|
|
523
|
+
return redirect(url_for('admin_dashboard'))
|
|
524
|
+
except:
|
|
525
|
+
error = "Invalid credentials."
|
|
526
|
+
|
|
527
|
+
return render_template_string(LOGIN_LAYOUT, error=error)
|
|
528
|
+
|
|
529
|
+
@app.route('/logout')
|
|
530
|
+
def logout():
|
|
531
|
+
session.pop('logged_in', None)
|
|
532
|
+
return redirect(url_for('serve_public_pages'))
|
|
533
|
+
|
|
534
|
+
@app.route('/admin')
|
|
535
|
+
def admin_dashboard():
|
|
536
|
+
if not session.get('logged_in'):
|
|
537
|
+
return redirect(url_for('login'))
|
|
538
|
+
|
|
539
|
+
if not os.path.exists(PAGES_DIR):
|
|
540
|
+
os.makedirs(PAGES_DIR, exist_ok=True)
|
|
541
|
+
|
|
542
|
+
raw_files = [f for f in os.listdir(PAGES_DIR) if f.endswith(".md")]
|
|
543
|
+
raw_files.sort()
|
|
544
|
+
|
|
545
|
+
files_data = []
|
|
546
|
+
for f in raw_files:
|
|
547
|
+
f_path = os.path.join(PAGES_DIR, f)
|
|
548
|
+
mtime = os.path.getmtime(f_path)
|
|
549
|
+
formatted_time = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')
|
|
550
|
+
|
|
551
|
+
files_data.append({
|
|
552
|
+
'filename': f,
|
|
553
|
+
'title': f.rsplit('.', 1)[0].replace('-', ' ').replace('_', ' ').title(),
|
|
554
|
+
'modified': formatted_time,
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
return render_template_string(ADMIN_LAYOUT, files_data=files_data)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@app.route('/', defaults={'path': 'index'})
|
|
561
|
+
@app.route('/<path:path>')
|
|
562
|
+
def serve_public_pages(path):
|
|
563
|
+
if path in ['admin', 'login', 'logout'] or path.startswith('admin/'):
|
|
564
|
+
abort(404)
|
|
565
|
+
|
|
566
|
+
safe_path = os.path.basename(path)
|
|
567
|
+
file_path = os.path.join(PAGES_DIR, f"{safe_path}.md")
|
|
568
|
+
|
|
569
|
+
if not os.path.exists(file_path):
|
|
570
|
+
if safe_path == 'index':
|
|
571
|
+
os.makedirs(PAGES_DIR, exist_ok=True)
|
|
572
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
573
|
+
f.write("# Welcome to your FlatWiki\nEdit this file or visit `/admin` to add more content.")
|
|
574
|
+
else:
|
|
575
|
+
abort(404)
|
|
576
|
+
|
|
577
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
578
|
+
md_text = f.read()
|
|
579
|
+
|
|
580
|
+
html_content = markdown.markdown(md_text)
|
|
581
|
+
current_title = "Home" if safe_path == "index" else safe_path.replace('-', ' ').replace('_', ' ').title()
|
|
582
|
+
|
|
583
|
+
return render_template_string(
|
|
584
|
+
PUBLIC_LAYOUT,
|
|
585
|
+
title=current_title,
|
|
586
|
+
content=html_content,
|
|
587
|
+
menu_items=get_sorted_menu()
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def run_server():
|
|
591
|
+
print(f"FlatWiki Engine Native Service active at http://127.0.0.1:{PORT}")
|
|
592
|
+
serve(app, host="0.0.0.0", port=PORT, threads=4)
|
|
593
|
+
|
|
594
|
+
if __name__ == '__main__':
|
|
595
|
+
run_server()
|