Compare commits

..

2 Commits

93 changed files with 648 additions and 5461 deletions

View File

@ -1,11 +0,0 @@
kind: pipeline
name: default
steps:
- name: test
image: crystallang/crystal:latest
environment:
commands:
- make deps
- make test
- make build

5
.gitignore vendored
View File

@ -1,12 +1,7 @@
/doc/
/docs/
/lib/
/bin/
/.shards/
/.crystal/
.env
/wikicr
/data/
/meta/

View File

@ -3,24 +3,22 @@ NAME=wikicr
all: deps_opt build
run:
crystal run src/$(NAME).cr --error-trace
crystal run src/$(NAME).cr
build:
crystal build src/$(NAME).cr --stats --error-trace
crystal build src/$(NAME).cr --stats
release:
crystal build src/$(NAME).cr --stats --release
test:
crystal spec --error-trace
crystal spec
deps:
shards install
crystal deps install
deps_update:
shards update
crystal deps update
deps_opt:
@[ -d lib/ ] || make deps
doc:
crystal docs
clean:
rm $(NAME)
format:
crystal tool format
.PHONY: all run build release test deps deps_update clean doc format
.PHONY: all run build release test deps deps_update clean doc

186
README.md
View File

@ -1,169 +1,79 @@
**Migrated to <https://git.sceptique.eu/Sceptique/wikicr>**
# wikicr
[![Build Status](https://drone.sceptique.eu/api/badges/Sceptique/wikicr/status.svg)](https://drone.sceptique.eu/Sceptique/wikicr)
Wiki in crystal and markdown
The pages of the wiki are written in markdown and committed on the git repository where it is started.
The pages of the wiki are written in markdown and commited on the git repository where it is started.
## How to install
### Dependencies
Verify that you have crystal v1.0.0 or greater installed, as well as shards and git.
### Get the application
git clone https://github.com/Nephos/wikicr.git
cd wikicr
### Test the application
make test
### Build the binary
## Installation
make
### Run the server
## Usage
./wikicr --port 3000
INVERT_THEME=true ./wikicr --port 3000 # dark mode
wikicr --help
-b HOST, --bind HOST Host to bind (defaults to 0.0.0.0)
-p PORT, --port PORT Port to listen for connections (defaults to 3000)
-s, --ssl Enables SSL
--ssl-key-file FILE SSL key file
--ssl-cert-file FILE SSL certificate file
-h, --help Shows this help
### Verify your files
### Configuration
A directory `meta/` should be created into wikicr.
It must contains several files and directories (acl, index, users, ...).
You may want to save this directory because it contains meta-data about the pages.
#### Environment variables
Another `data/` should be a git repository (initialized at the first start).
Those files are the ALL the "displayed data" of the wiki.
- WIKI_DATA (default "data/"): set the directory where the data will be stored. It will be removed in the future for a configuation manager
- WIKI_SECRET: random value that init the sessions (`crystal eval 'require "secure_random"; puts SecureRandom.hex(64)'`)
## Security and ACLs
* Admin panel to manage the directories and pages
* Rules on directories are terminated with a \*
* If several rules conflict, take the more specific one
* Directories with the longer name prevails
* Files rules prevails over directory rules
## Administration and usage tutorial
### Edit / Create a page
<img width=240 src="https://i.imgur.com/5bfJstb.png" />
### Show a page
<img width=240 src="https://i.imgur.com/gllJ8Nr.png" />
### Remove a page
Simply edit the page to remove and delete all the content.
The page will be deleted completely.
### Administrate users and acls
<img width=240 src="https://i.imgur.com/1zWiAV3.png" />
### Custom Markdown
A special markdown (wikimd) is used in the pages. It provides several interesting features:
#### Internal links
An internal link will search through the index of pages to find a matching one and render a valid link to it.
```markdown
blabla [[my page]] blabla
```
##### Notes about the wikimd
- internal link algorithm have been benchmarked a bit
[benchmark link](https://gist.github.com/Nephos/ad292a3e2acc9201e6ea6342eb85dacb)
The algorithm has been improved since, but it gave me a first idea of what to do.
## Development and Roadmap
### You want to add or modify something ?
Don't hesitate to open an issue, I'm always happy to discuss about my projects
or include other developers than me.
## Contributing
1. Open an issue to see what you want to implement and how to do it.
2. Fork it ( https://github.com/Nephos/wikicr/fork )
3. Create your feature branch (git checkout -b my-new-feature)
4. Commit your changes (git commit -am 'Add some feature')
5. Push to the branch (git push origin my-new-feature)
6. Create a new Pull Request
## Development
### Operations
For now, there is no "important" core operation to add (they are all already implemented).
However, there are still lots of improvements to write on the current implementation,
documentation, security check, error management and consistency of the code.
- [x] (core) View wiki pages
- [x] (core) Write new wiki page, edit existing ones
- [x] (core) Chroot the files to data/: avoid writing / reading files outside of data/
- [x] (core) If page does not exists, form to create it: if a file does not exist, display the edit form
- [x] (core) Delete pages: remove the content of a page should remove the file
- [x] (core) Index of pages: each modification of a page should update and index with all these pages (with first h1 and url)
- [x] (core) Choose between sqlite3 and the file system for the index: sqlite = sql, fs = easier
- [x] (core) Move page (rename): box with "mv X Y" and git commit
- [x] (core) Lock system for acl/users/index: A thread safe method should be provided to avoid conflict when executing read+write or write operations
* [x] (core) View wiki pages
* [x] (core) Write new wiki page, edit existing ones
* [x] (core) Chroot the files to data/: avoid writing / reading files outside of data/
* [x] (core) If page does not exists, form to create it: if a file does not exist, display the edit form
* [x] (core) Delete pages: remove the content of a page should remove the file
* [ ] (core) Index of pages: each modification of a page should update and index with all thes pages (with first h1 and url)
* [ ] (core) Choose between sqlite3 and the filesystem for the index: sqlite = sql, fs = easier
* [ ] (core) Move page (rename): box with "mv X Y" and git commit
### Git
At the beginning, I tried to used libgit2. However, it seems to be a bad idea
because the lib was not documented (no tutorial or at least not up-to-date, API
not very well documented, etc.) so I want to write a little git-* wrapper to
handle some operations (add, commit, revert, etc.).
It is not something very likely to be done first (even if it's a lot of important
features) because it is boring and requires to take care of the security issues.
I must have to replace the "system" calls (in backquote) with `Proccess.new.run`.
- [x] (git) Commit when write on a file: every modification on data/ should be committed
- [ ] (git) List of revisions on a file (using git): list the revision of a file
- [ ] (git) Revert a revision (avoid vandalism): button to remove a revision (git revert)
* [ ] (git) Commit when write on a file: every modification on data/ should be commited
* [ ] (git) List of revisions on a file (using git): list the revision of a file
* [ ] (git) Revert a revision (avoid vandalism): button to remove a revision (git revert)
### Web
There is some important features in order to have a good interface and a fluent
wiki experience. That's not the stuff I prefer because it requires some css/js
(front-end stuff).
There is also work around string matching to write a valid research engine.
This is the most important feature to add right now.
- [x] (web) Add content table: if titles are written, give a content table with them and links to anchors
- [x] (web) Sitemap: add a list of all the files available
- [x] (web) User login / registration: keep a file with login:group:bcryptpassords
- [x] (web) User ACL basic (read / write): the groups have rights on directories (globing)
- [x] (web) Groups ACL on EVERY wiki url
- [ ] (web) Search a page: an input that search a page (content, title) with auto-completion
- [ ] (web) Template loader (files in public/): load css, js etc. from public/
- [ ] (web) File upload and lists: page that add a file in uploads/
- [ ] (web) Tags for pages (index): extended markdown and index to keep a list of pages
* [ ] (web) Add content table: if titles are written, give a content table with them and links to anchors
* [x] (web) Sitemap: add a list of all the files available
* [ ] (web) Search a page: an input that search a page (content, title) with autocompletion
* [x] (web) User login / registration: keep a file with login:group:bcryptpassords
* [ ] (web) User LDAP basic (read / write): the groups have rights on directories
* [ ] (web) Tags for pages (index): extended markdown and index to keep a list of pages
* [ ] (web) Template loader (files in public/): load css, js etc. from public/
* [ ] (web) File upload and lists: page that add a file in uploads/
### Advanced usage
The current implementation of Markdown in crystal is limited and may be fully rewritten with more standard features in some weeks or months.
For now, I choose to use Markd, another markdown parser, and wrote a wikimd wrapper (Wikicr::Page::Markdown).
It allows me to expand the default markdown by writting HTML inside the markdown to render.
The rest is boring stuff (code factorization, make everything configurable, document the code, add a lot of specs, ...).
- [x] (edit) Handle `[[tag]]`: markdown extended to search in the page index (url and title)
- [x] (edit) Handle `[[tag|title]]`: same than internal links but with a fixed title
- [ ] (core) Index the internal links of a page to update them if a page is move or the title changed.
- [ ] (web) Configuration page: title of the wiki, rights of the files, etc. should be configurable
- [ ] (conf) Handle environment variables in a .env file
- [ ] (core) Extensions loader (.so files + extended markdown ?): extend the wiki features with hooks
* [ ] (core) Extensions loader (.so files + extended markdown ?): extend the wiki features with hooks
* [ ] (web) Configuration page: title of the wiki, rights of the files, etc. should be configurable
* [ ] (edit) Handle `[[tag]]`: markdown extended to search in the page index (url and title)
* [ ] (conf) Handle environemnt variables in a .env file
### Other
- [x] Improve the controller/routes architecture
* [ ] take a look at https://github.com/kemalyst/kemalyst
## Contributing
1. Fork it ( https://github.com/Nephos/wikicr/fork )
2. Create your feature branch (git checkout -b my-new-feature)
3. Commit your changes (git commit -am 'Add some feature')
4. Push to the branch (git push origin my-new-feature)
5. Create a new Pull Request
## Contributors
- [Sceptique](https://git.sceptique.eu/Sceptique) Arthur Poulet - creator, maintainer
- [Nephos](https://github.com/Nephos) Arthur Poulet - creator, maintainer

View File

@ -1,9 +0,0 @@
require "./routes"
Kemal::Session.config do |config|
config.cookie_name = "session_id"
config.secret = ENV["WIKI_SECRET"]? || Random::Secure.base64(64)
config.gc_interval = 2.minutes # 2 minutes
end
Kemal.run

View File

@ -1,7 +0,0 @@
FROM crystallang/crystal:lastest
WORKDIR /app/user
ADD . /app/user
RUN crystal deps

View File

@ -1,39 +0,0 @@
class Router
{% for verb in {:get, :post, :delete, :patch, :put, :head} %}
macro {{verb.id}}(route, controller, method)
::{{verb.id}}(\{{route}}) do |env|
context = \{{controller}}.new(env)
# puts "Before init"
# pp env.request.cookies
# pp env.response.cookies
context.cookies.fill_from_client_headers(env.request.headers)
# puts "After init"
# pp env.request.cookies
# pp env.response.cookies
output = context.\{{method.id}}()
# puts "After controller"
# pp env.request.cookies
# pp env.response.cookies
#context.cookies.add_response_headers(env.response.headers)
output
end
end
{% end %}
end
Router.get "/", HomeController, :index
Router.get "/sitemap", PagesController, :sitemap
Router.get "/pages/search", PagesController, :search
Router.get "/pages/*path", PagesController, :show
Router.post "/pages/*path", PagesController, :update
Router.get "/users/login", UsersController, :login
Router.post "/users/login", UsersController, :login_validates
Router.get "/users/register", UsersController, :register
Router.post "/users/register", UsersController, :register_validates
Router.get "/admin/users", AdminController, :users_show
Router.post "/admin/users/create", AdminController, :user_create
Router.post "/admin/users/delete", AdminController, :user_delete
Router.get "/admin/acls", AdminController, :acls_show
Router.post "/admin/acls/update", AdminController, :acl_update
Router.post "/admin/acls/create", AdminController, :acl_create
Router.post "/admin/acls/delete", AdminController, :acl_delete

View File

@ -1,17 +0,0 @@
version: '2'
services:
web:
build: .
image: wikicr
command: './wikicr'
working_dir: /app/user
environment:
ports:
- '3000:3000'
depends_on:
volumes:
- '.:/app/user'
volumes:
db:

Binary file not shown.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 434 KiB

View File

@ -1,68 +1,13 @@
form#edit-page textarea {
overflow: hidden;
outline: none;
display: block;
border-radius: 4px;
border: 4px solid #557766;
form#edit-page {
textarea {
overflow: hidden;
outline: none;
display: block;
border-radius: 4px;
border: 4px solid #556677;
padding: 10px;
resize: vertical;
}
ul, ol {
padding: 0px 0px 0px 0px;
margin: 0px 0px 0px 1.5em;
}
#page-body {
box-shadow: 0 0 0.5em #999;
padding: 10px 20px;
}
#page-body-meta {
box-shadow: -2px 2px 2px #ccc;
padding: 0 0.5em 0.5em 1.5em;
margin: 0 0 0.5em 1.0em;
font-size: 12px;
}
.page-body-meta-title {
font-size: 16px;
font-weight: bold;
}
#page-table-of-content ul, #page-table-of-content ol {
padding: 0px 0px 0px 0px;
margin: 0px 0px 0px 0.5em;
}
#page-table-of-content ol {
counter-reset: item;
}
#page-table-of-content li {
display: block;
}
#page-table-of-content li:before {
content: counters(item, ".") ".";
counter-increment: item;
padding-right:10px;
margin-left:-20px;
}
h1:hover > a.anchor::before,
h2:hover > a.anchor::before,
h3:hover > a.anchor::before,
h4:hover > a.anchor::before,
h5:hover > a.anchor::before,
h6:hover > a.anchor::before {
font-family: "FontAwesome";
content: "\f0c1";
margin: -20px; /* this put the link in the padding of the content <insert circus music here> <laugh> */
}
a.anchor {
text-decoration: none;
font-size: 14pt;
padding: 10px;
margin: 50px auto;
resize: vertical;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,41 +0,0 @@
@supports (backdrop-filter: invert(1)) {
#mybpwaycfxccmnp-dblt-backdrop-filter {
display: block !important;
position: fixed !important;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
margin: 0 !important;
pointer-events: none !important;
z-index: 2147483647 !important;
backdrop-filter: invert(1) hue-rotate(180deg) !important;
}
img:not(.mwe-math-fallback-image-inline):not([alt="inline_formula"]),
video {
filter: invert(1) hue-rotate(180deg) !important;
}
}
@supports not (backdrop-filter: invert(1)) {
html,
img:not(.mwe-math-fallback-image-inline):not([alt="inline_formula"]),
video,
div#viewer.pdfViewer div.page {
filter: invert(1) hue-rotate(180deg) !important;
}
:fullscreen video,
video:fullscreen {
filter: none !important;
}
html {
background-color: black !important;
}
}
button,
input,
optgroup,
select,
textarea {
background-color: white;
color: black;
}

View File

@ -1,15 +0,0 @@
<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM "http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
<!-- Read this: www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html -->
<!-- Most restrictive policy: -->
<site-control permitted-cross-domain-policies="none"/>
<!-- Least restrictive policy: -->
<!--
<site-control permitted-cross-domain-policies="all"/>
<allow-access-from domain="*" to-ports="*" secure="false"/>
<allow-http-request-headers-from domain="*" headers="*" secure="false"/>
-->
</cross-domain-policy>

View File

@ -1,3 +0,0 @@
# http://www.robotstxt.org
User-agent: *
Disallow:

View File

@ -1,38 +1,30 @@
version: 2.0
version: 1.0
shards:
backtracer:
git: https://github.com/sija/backtracer.cr.git
version: 1.2.1
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.2.0
git:
github: mosop/git
commit: 27cc31b40fc358457b37ab12b2b572c97200755b
kemal:
git: https://github.com/kemalcr/kemal.git
version: 1.1.0+git.commit.5e2efec0503622267b57d03fe7d5151000d3bf47
kemal-flash:
git: https://github.com/nephos/kemal-flash.git
version: 0.1.0+git.commit.d1ea6ce9caaed840a062e07a9223cc404cf3adc7
github: kemalcr/kemal
commit: eb3e83d5903ce8f18d796a97ae200009fdf2c9a2
kemal-session:
git: https://github.com/kemalcr/kemal-session.git
version: 1.0.0+git.commit.2ebaf68ed48da763a9cdd0f8dcbc6347981e115d
github: kemalcr/kemal-session
commit: bbef25f36fe6505d3221de9952f94a1023225b85
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.6.1
markd:
git: https://github.com/nephos/markd.git
version: 0.4.1+git.commit.8b67caf4e6ecd89d9dc533187acacf7bc835d05f
github: jeromegn/kilt
version: 0.4.0
radix:
git: https://github.com/luislavena/radix.git
version: 0.4.1
github: luislavena/radix
version: 0.3.8
safec:
github: mosop/safec
version: 0.2.1
slang:
git: https://github.com/jeromegn/slang.git
version: 1.7.3+git.commit.901df64c9a1c1df91d3acd00762509c9b6d3cfd8
github: jeromegn/slang
commit: b817c89c7e5ae39562710c0d6c7f42cee685e14f

View File

@ -1,28 +1,27 @@
name: wikicr
version: 0.2.0
version: 0.1.0
authors:
- Arthur Poulet <arthur.poulet@sceptique.eu>
- Arthur Poulet <arthur.poulet@mailoo.org>
crystal: ~> 1.0.0
targets:
wikicr:
main: src/wikicr.cr
license: MIT
crystal: 0.22.0
license: GPL-3.0
dependencies:
kemal:
github: kemalcr/kemal
branch: master
kilt:
github: jeromegn/kilt
slang:
github: jeromegn/slang
branch: master
kemal-session:
github: kemalcr/kemal-session
branch: master
kemal-flash:
github: Nephos/kemal-flash
branch: bugfix/update-crystal-json
markd:
github: Nephos/markd
branch: feature-toc-fix
slang:
github: jeromegn/slang
branch: master
git:
github: mosop/git
branch: master

View File

@ -1,69 +0,0 @@
describe Acl do
it "test the users permissions" do
acls = Acl::Groups.new File.tempfile("spec").to_s
g1 = Acl::Group.new(
name: "user",
default: Acl::Perm::Read,
permissions: {
"/tmp/protected" => Acl::Perm::None,
"/tmp/write/*" => Acl::Perm::Write,
"/match/*" => Acl::Perm::Write,
"/match/not-file" => Acl::Perm::None,
"/match/not-dir/*" => Acl::Perm::None,
})
g2 = Acl::Group.new(
name: "admin",
permissions: {
"/match/*" => Acl::Perm::Read,
},
default: Acl::Perm::Write)
acls.add g1
acls.add g2
u1 = Wikicr::User.new "u1", "", %w(user)
u2 = Wikicr::User.new "u2", "", %w(user admin)
# simple
acls.permitted?(u1, "/", Acl::Perm::Read).should be_true
acls.permitted?(u1, "/tmp", Acl::Perm::Read).should be_true
acls.permitted?(u1, "/tmp", Acl::Perm::Write).should be_false
acls.permitted?(u1, "/tmp/protected", Acl::Perm::Read).should be_false
acls.permitted?(u2, "/tmp/protected", Acl::Perm::Read).should be_true
# matching
acls.permitted?(u1, "/tmp/write/test", Acl::Perm::Read).should be_true
acls.permitted?(u1, "/tmp/write/test", Acl::Perm::Write).should be_true
acls.permitted?(u1, "/match/write-ok", Acl::Perm::Read).should be_true
acls.permitted?(u1, "/match/write-ok", Acl::Perm::Write).should be_true
# TODO: enable those tests
acls.permitted?(u1, "/match/not-file", Acl::Perm::Write).should be_false
acls.permitted?(u1, "/match/not-dir/any", Acl::Perm::Write).should be_false
acls.permitted?(u1, "/match/not-file", Acl::Perm::Read).should be_false
acls.permitted?(u1, "/match/not-dir/any", Acl::Perm::Read).should be_false
end
it "test the paths matching" do
Acl::Path.new("/*").acl_match?("/a/test").should eq(true)
Acl::Path.new("/a*").acl_match?("/a/test").should eq(true)
Acl::Path.new("/a/test*").acl_match?("/a/test").should eq(true)
Acl::Path.new("/a/test*").acl_match?("/a/test/").should eq(true)
Acl::Path.new("/a/test*").acl_match?("/b/test").should eq(false)
Acl::Path.new("/a/test*").acl_match?("/a/other").should eq(false)
end
it "groups having" do
acls = Acl::Groups.new File.tempfile("spec").to_s
acls.add "guest"
acls.add "admin"
acls["guest"]["/*"] = Acl::Perm::Read
acls["guest"]["/write/*"] = Acl::Perm::Write
acls["guest"]["/write/admin"] = Acl::Perm::Read
acls["admin"]["/*"] = Acl::Perm::Write
acls.groups_having_any_access_to("/", Acl::Perm::Read).should eq(["guest", "admin"])
acls.groups_having_any_access_to("/", Acl::Perm::Write).should eq(["admin"])
acls.groups_having_any_access_to("/write", Acl::Perm::Write).should eq(["admin"])
acls.groups_having_any_access_to("/write/anypage", Acl::Perm::Write).should eq(["guest", "admin"])
acls.groups_having_any_access_to("/write/admin", Acl::Perm::Write).should eq(["admin"])
acls.groups_having_direct_access_to("/*", Acl::Perm::Read).should eq(["guest", "admin"])
acls.groups_having_direct_access_to("/*", Acl::Perm::Write).should eq(["admin"])
end
end

View File

@ -1,114 +0,0 @@
describe Acl::Group do
it "test initialize" do
# First initializer, simple
g1 = Acl::Group.new("name1",
{Acl::Path.new("/") => Acl::Perm::Read},
Acl::Perm::None
)
# First initializer, named arguments
g2 = Acl::Group.new(
name: "name2",
permissions: {Acl::Path.new("/o") => Acl::Perm::Write},
default: Acl::Perm::Read
)
# Sec initializer, simple
g3 = Acl::Group.new("name3",
{"/" => Acl::Perm::Read},
Acl::Perm::None
)
# Sec initializer, named arguments
g4 = Acl::Group.new(
name: "name4",
permissions: {"/o" => Acl::Perm::Write},
default: Acl::Perm::Read
)
g1.name.should eq "name1"
g2.name.should eq "name2"
g3.name.should eq "name3"
g4.name.should eq "name4"
g1.default.should eq Acl::Perm::None
g2.default.should eq Acl::Perm::Read
g3.default.should eq Acl::Perm::None
g4.default.should eq Acl::Perm::Read
end
it "test permitted?" do
g = Acl::Group.new(
name: "guest",
permissions: {
Acl::Path.new("/public*") => Acl::Perm::Write,
Acl::Path.new("/restricted*") => Acl::Perm::Read,
Acl::Path.new("/users*") => Acl::Perm::Read,
Acl::Path.new("/users/guest") => Acl::Perm::Write,
Acl::Path.new("/users/protected/*") => Acl::Perm::None,
},
default: Acl::Perm::None
)
g.permitted?("/", Acl::Perm::Read).should be_false
g.permitted?("/users", Acl::Perm::Read).should be_true
g.permitted?("/users/guest", Acl::Perm::Read).should be_true
g.permitted?("/users/admin", Acl::Perm::Read).should be_true
g.permitted?("/users/guest", Acl::Perm::Write).should be_true
g.permitted?("/users/admin", Acl::Perm::Write).should be_false
g.permitted?("/users/protected/any", Acl::Perm::Read).should be_false
g.permitted?("/public", Acl::Perm::Read).should be_true
g.permitted?("/public", Acl::Perm::Write).should be_true
g.permitted?("/public/some", Acl::Perm::Write).should be_true
g.permitted?("/public/some", Acl::Perm::Write).should be_true
g.permitted?("/publicXXX/some", Acl::Perm::Write).should be_true
end
it "advanced test permitted?" do
g = Acl::Group.new(
name: "guest",
permissions: {
Acl::Path.new("/write/*") => Acl::Perm::Write,
Acl::Path.new("/read/*") => Acl::Perm::Read,
Acl::Path.new("/none/*") => Acl::Perm::None,
Acl::Path.new("/read/none") => Acl::Perm::None,
Acl::Path.new("/write/none") => Acl::Perm::None,
Acl::Path.new("/none/readonly") => Acl::Perm::Read,
Acl::Path.new("/write/readonly") => Acl::Perm::Read,
Acl::Path.new("/read/writeonly") => Acl::Perm::Write,
Acl::Path.new("/none/writeonly") => Acl::Perm::Write,
},
default: Acl::Perm::Read
)
g.permitted?("/", Acl::Perm::Read).should be_true
g.permitted?("/", Acl::Perm::Write).should be_false
g.permitted?("/none/any", Acl::Perm::None).should be_true
g.permitted?("/none/any", Acl::Perm::Read).should be_false
g.permitted?("/read/any", Acl::Perm::Read).should be_true
g.permitted?("/read/any", Acl::Perm::Write).should be_false
g.permitted?("/write/any", Acl::Perm::Write).should be_true
g.permitted?("/read/none", Acl::Perm::Read).should be_false
g.permitted?("/write/none", Acl::Perm::Read).should be_false
g.permitted?("/none/readonly", Acl::Perm::Read).should be_true
g.permitted?("/write/readonly", Acl::Perm::Write).should be_false
g.permitted?("/none/writeonly", Acl::Perm::Write).should be_true
g.permitted?("/read/writeonly", Acl::Perm::Write).should be_true
end
it "test permissions access" do
g = Acl::Group.new(
name: "guest",
permissions: {
"/public*" => Acl::Perm::Write,
"/restricted*" => Acl::Perm::Read,
"/users*" => Acl::Perm::Read,
"/users/guest" => Acl::Perm::Write,
},
default: Acl::Perm::None
)
g["/"]?.should eq nil
g["/"].should eq g.default
# TODO: enable this test after changing the design of operator [] to do not match path
# g["/public"]?.should eq nil
g["/public*"]?.should eq Acl::Perm::Write
g["/"] = Acl::Perm::Read
g["/"]?.should eq Acl::Perm::Read
g.delete("/")
g["/"]?.should eq nil
end
end

View File

@ -1,26 +0,0 @@
describe Wikicr::MarkdPatch do
page = Wikicr::Page.new "", real_url: false
index = Wikicr::Page::Index.new "/tmp/specs.index.yaml"
it "basic markd patching" do
raw = "hello <<test1>>"
html = %Q{<p>hello <a href="/test1" title="test1">test1</a></p>\n}
Wikicr::MarkdPatch.to_html(raw, page, index).should eq(html)
end
it "wiki tag markd patching" do
raw = "hello {{test2}}"
html = %Q{<p>hello <a href="/test2" title="test2">test2</a></p>\n}
Wikicr::MarkdPatch.to_html(raw, page, index).should eq(html)
raw = "hello {{title 2|test2}}"
html = %Q{<p>hello <a href="/test2" title="title 2">title 2</a></p>\n}
Wikicr::MarkdPatch.to_html(raw, page, index).should eq(html)
end
it "section anchors" do
raw = "# titleA\n1234567890\n## titleB"
html = %Q{<h1><a id="anchor-titleA" class="anchor" href="#anchor-titleA"></a>titleA</h1>\n<p>1234567890</p>\n<h2><a id="anchor-titleB" class="anchor" href="#anchor-titleB"></a>titleB</h2>\n}
Wikicr::MarkdPatch.to_html(raw, page, index).should eq(html)
end
end

9
spec/mdwikiface_spec.cr Normal file
View File

@ -0,0 +1,9 @@
require "./spec_helper"
describe Wikicr do
# TODO: Write tests
it "works" do
false.should eq(true)
end
end

View File

@ -1,64 +0,0 @@
page = Wikicr::Page.new ""
describe Wikicr::Page::Index do
it "one by title or url" do
index = Wikicr::Page::Index.new(file: "placeholder")
index.entries["page1"] = Wikicr::Page::Index::Entry.new path: "path1", url: "url1", title: "title1"
index.entries["page2"] = Wikicr::Page::Index::Entry.new path: "path2", url: "url2", title: "title2"
index.entries["page3"] = Wikicr::Page::Index::Entry.new path: "path3", url: "url3", title: "Title3"
index.entries["page4"] = Wikicr::Page::Index::Entry.new path: "path4", url: "url4", title: "Title3"
title2 = index.one_by_title_or_url text: "title2", context: page
title2.should eq index.entries["page2"]
url2 = index.one_by_title_or_url text: "url2", context: page
url2.should eq index.entries["page2"]
urlnil = index.one_by_title_or_url text: "urlnil", context: page
index.entries.each { |_, entry| urlnil.should_not eq(entry) }
end
it "all by tags" do
index = Wikicr::Page::Index.new(file: "placeholder")
index.entries["entry1"] = Wikicr::Page::Index::Entry.new(
path: "path 1",
url: "url 1",
title: "title 1",
tags: ["tag1", "tag12", "tag13", "tag14"]
)
index.entries["entry2"] = Wikicr::Page::Index::Entry.new(
path: "path 2",
url: "url 2",
title: "title 2",
tags: ["tag12"]
)
index.entries["entry3"] = Wikicr::Page::Index::Entry.new(
path: "path 3",
url: "url 3",
title: "title 3",
tags: ["tag13", "tag134"]
)
index.entries["entry4"] = Wikicr::Page::Index::Entry.new(
path: "path 4",
url: "url 4",
title: "title 4",
tags: ["tag14", "tag134"]
)
expected = {"entry1" => index.entries["entry1"]}
index.all_by_tags(tags_line: "tag1", context: page).should eq(expected)
expected = {"entry1" => index.entries["entry1"], "entry2" => index.entries["entry2"]}
index.all_by_tags(tags_line: "tag12", context: page).should eq(expected)
expected = {"entry2" => index.entries["entry2"]}
index.all_by_tags(tags_line: "tag12 -tag1", context: page).should eq(expected)
expected = {"entry1" => index.entries["entry1"]}
index.all_by_tags(tags_line: "tag12 +tag13", context: page).should eq(expected)
expected = {"entry1" => index.entries["entry1"], "entry2" => index.entries["entry2"], "entry3" => index.entries["entry3"]}
index.all_by_tags(tags_line: "tag12 tag13", context: page).should eq(expected)
end
end

View File

@ -1,15 +0,0 @@
describe Wikicr::Page do
it "test basic" do
page = Wikicr::Page.new(url: "home", real_url: false, parse_title: false)
page.url.should eq "home"
page.real_url.should eq "/pages/home"
page.path.should eq File.expand_path("data/home.md")
end
it "test another basic" do
page = Wikicr::Page.new(url: "/pages/home", real_url: true, parse_title: false)
page.url.should eq "home"
page.real_url.should eq "/pages/home"
page.path.should eq File.expand_path("data/home.md")
end
end

View File

@ -1,10 +1,2 @@
require "spec"
require "../src/lib/lockable"
require "../src/lib/options"
require "../src/lib/file_tree"
require "../src/lib/acl"
require "../src/lib/page"
require "../src/lib/wikimd/**"
require "../src/lib/users/**"
require "../src/wikicr"

View File

@ -1,6 +0,0 @@
require "./spec_helper"
require "./acl_spec"
require "./group_spec"
require "./markdown_spec"
require "./page_spec"

14
src/controllers.cr Normal file
View File

@ -0,0 +1,14 @@
macro new_render(name)
macro render_{{name.id}}(page)
render {{ "src/views/" + name.stringify + "s/\{\{page}}.html.slang" }}, "src/views/layout.html.slang"
end
end
new_render(page)
new_render(user)
# macro render_page(page)
# render {{ "src/views/pages/" + page + ".html.slang" }}, "src/views/layout.html.slang"
# end
require "./controllers/*"

View File

@ -1,85 +0,0 @@
require "./application_controller"
class AdminController < ApplicationController
# get /admin/users
def users_show
acl_permit! :write
users = Wikicr::USERS.load!
render "users_show.slang"
end
# post /admin/users/create
def user_create
acl_permit! :write
username = params.body["username"]
password = params.body["password"]
groups = params.body["groups"].split(",").map(&.strip)
begin
user = Wikicr::USERS.register! username, password, groups
flash["success"] = "The user #{user.name} has been added."
rescue err
flash["danger"] = "Cannot register this account: #{err.message}."
end
redirect_to "/admin/users"
end
# post /admin/users/delete
def user_delete
acl_permit! :write
Wikicr::USERS.transaction! { |users| users.delete params.body["username"] }
flash["success"] = "The user #{params.body["username"]} has been deleted."
redirect_to "/admin/users"
end
# get /admin/acls
def acls_show
acl_permit! :read
acls = Wikicr::ACL.load!
render "acls_show.slang"
end
# post /admin/acls/create
def acl_create
acl_permit! :write
group = params.body["group"]
path = params.body["path"]
perm_str = params.body["perm"]
perm = Acl::PERM_STR[perm_str]
Wikicr::ACL.transaction! do |acls|
acls.add Acl::Group.new(group) if acls[group]?.nil?
acls[group][path] = perm
end
flash["success"] = "ACL #{group} :: #{path} :: #{perm} has been added"
redirect_to "/admin/acls"
end
# post /admin/acls/update
def acl_update
acl_permit! :write
begin
group = params.body["group"]
path = params.body["path"]
perm_str = params.body["change"]
perm = Acl::PERM_STR[perm_str]
Wikicr::ACL.transaction! do |acls|
acls[group][path] = perm
end
flash["success"] = "ACL #{group} :: #{path} :: #{perm} has been updated."
rescue err
flash["danger"] = "Unable to process that: #{err.message}."
end
redirect_to "/admin/acls"
end
# post /admin/acls/delete
def acl_delete
acl_permit! :write
group = params.body["group"]
path = params.body["path"]
Wikicr::ACL.transaction! do |acls|
acls[group].delete path
end
flash["success"] = "ACL #{group} :: #{path} has been deleted."
redirect_to "/admin/acls"
end
end

View File

@ -1,31 +0,0 @@
# This files creates the main controller which is inherited by any other controller.
# It also loads the controller and helpers.
require "http"
require "./application_controller/**"
require "./helpers/**"
# The ApplicationController is the class that handles the environment:
# it handles the session, request, response, params, flash notices, cookies, redirections, and rendering.
class ApplicationController
LAYOUT = "application.slang"
include ApplicationController::Render
include ApplicationController::Session
include ApplicationController::Request
include ApplicationController::Response
include ApplicationController::Params
include ApplicationController::Flash
include ApplicationController::Cookies
include ApplicationController::Redirect
include Wikicr::Helpers::User
include Wikicr::Helpers::Page
include Wikicr::Helpers::History
getter env : HTTP::Server::Context
def initialize(@env)
end
end
require "./**"

View File

@ -1,13 +0,0 @@
class ApplicationController
module Cookies
delegate :cookies, to: @env.response
def set_cookie(**cookie)
cookies << HTTP::Cookie.new(**cookie)
end
def delete_cookie(name)
set_cookie(name: name, value: "", expires: Time.local)
end
end
end

View File

@ -1,5 +0,0 @@
class ApplicationController
module Flash
delegate :flash, to: @env
end
end

View File

@ -1,5 +0,0 @@
class ApplicationController
module Params
delegate :params, to: @env
end
end

View File

@ -1,8 +0,0 @@
class ApplicationController
module Redirect
# TODO: improve that
def redirect_to(path, *args_to_hanle, **stuff_to_handle)
@env.redirect path
end
end
end

View File

@ -1,52 +0,0 @@
class ApplicationController
# This code belongs to the Amber Project: https://github.com/Amber-Crystal/amber/blob/master/src/amber/controller/render.cr
#
# The MIT License (MIT)
#
# Copyright (c) 2017 Elias Perez
#
# 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.
module Render
macro render_template(filename, path = "src/views")
{% if filename.id.split("/").size > 2 %}
Kilt.render("{{filename.id}}")
{% else %}
Kilt.render("#{{{path}}}/{{filename.id}}")
{% end %}
end
macro render(filename, layout = true, path = "src/views", folder = __FILE__)
# NOTE: content is basically yield rails layouts.
{% if filename.id.split("/").size > 1 %}
content = render_template("#{{{filename}}}", {{path}})
{% else %}
{% if folder.id.ends_with?(".ecr") %}
content = render_template("#{{{folder.split("/")[-2]}}}/#{{{filename}}}", {{path}})
{% else %}
content = render_template("#{{{folder.split("/").last.gsub(/\_controller\.cr|\.cr/, "")}}}/#{{{filename}}}", {{path}})
{% end %}
{% end %}
{% if layout && !filename.id.split("/").last.starts_with?("_") %}
content = render_template("layouts/#{{{layout.class_name == "StringLiteral" ? layout : LAYOUT}}}", {{path}})
{% end %}
content
end
end
end

View File

@ -1,5 +0,0 @@
class ApplicationController
module Request
delegate :request, to: @env
end
end

View File

@ -1,5 +0,0 @@
class ApplicationController
module Response
delegate :response, to: @env
end
end

View File

@ -1,5 +0,0 @@
class ApplicationController
module Session
delegate :session, to: @env
end
end

15
src/controllers/errors.cr Normal file
View File

@ -0,0 +1,15 @@
get "/" do |env|
env.redirect "/pages/"
end
get "/*" do |env|
env.redirect "/pages/"
end
error 404 do |env|
"Page not found"
end
error 403 do
"Forbidden"
end

View File

@ -1,44 +0,0 @@
module Wikicr::Helpers::History
class HistoryStorage < Array(String)
SEPARATOR = "|"
KEEP_ENTRIES = 6
@app : ApplicationController
def initialize(@app)
super(1)
end
def parse(history_string : String) : self
history_string.split(SEPARATOR).each do |page|
push(page)
end
self
end
def <<(page : Wikicr::Page) : self
push(page.real_url)
uniq!
shift_amount = size - KEEP_ENTRIES
shift(shift_amount) if shift_amount > 0
@app.set_cookie name: "user.history", value: URI.encode(to_s), expires: 14.days.from_now, path: "/pages"
self
end
def to_s
join(SEPARATOR)
end
end
def history : HistoryStorage
current_history = cookies["user.history"]?
if current_history
HistoryStorage.new(self).parse(URI.decode(current_history.value))
else
HistoryStorage.new(self)
end
end
end

View File

@ -1,42 +0,0 @@
module Wikicr::Helpers::Page
# TODO: move that
def add_page(page, stack = [] of String)
String.build do |str|
Slang.embed("src/views/pages/sitemap.directory.slang", "str")
end
end
# TODO: move that
def create_toc_line(line, current_id, ends = true)
"<li><a href=\"#anchor-#{line}\">#{line}</a>#{ends ? "</li>" : nil}\n"
end
# TODO: move that
def add_toc_level(b, index_entry, current_id = 0, last_head = 0)
return if index_entry.size == current_id
current_entry = index_entry[current_id]
current_head = current_entry[0]
current_head_value = current_entry[1]
next_entry = index_entry[current_id + 1]?
next_head = next_entry ? next_entry[0] : 7
close_li = next_head <= current_head
if current_head > last_head
b << "<ol>\n" << create_toc_line(current_head_value, current_id, close_li)
elsif current_head < last_head
b << "</ol></li>\n" << create_toc_line(current_head_value, current_id, close_li)
else
b << create_toc_line(current_head_value, current_id, close_li)
end
return add_toc_level(b, index_entry, current_id + 1, current_head)
end
def add_toc(index_entry)
# (index_entry.values.map(&.size).sum + index_entry.size * 9)
toc = String.build do |b|
add_toc_level(b, index_entry)
end
String.build do |str|
Slang.embed("src/views/pages/toc.slang", "str")
end
end
end

View File

@ -1,57 +0,0 @@
module Wikicr::Helpers::User
# The username+token are set into the cookies in order to allow future auto-login
def set_login_cookies_for(username)
Wikicr::USERS.transaction!(read: true) { |users| users[username].generate_new_token! }
token = Wikicr::USERS[username].token.to_s
set_cookie name: "user.name", value: username, expires: 14.days.from_now
set_cookie name: "user.token", value: token, expires: 14.days.from_now
end
# If a cookie is set but the user is not signed in, try to use it and renew the cookie
def uses_login_cookies
unless user_signed_in?
if (name = cookies["user.name"]?) && (token = cookies["user.token"]?)
if (user = Wikicr::USERS.auth_token?(name.value, token.value))
session.string("user.name", user.name)
set_login_cookies_for(user.name)
else
puts "Invalid cookies creditentials"
delete_cookie "user.name"
delete_cookie "user.token"
end
end
end
end
# Nil if not signed in, else it returns the user name
macro user_signed_in?
session.string?("user.name")
end
# Nil if not signed in, else it returns the user name
macro user_name?
session.string?("user.name")
end
# If the user is connected return an `Wikicr::User`, else the default user (guest)
macro current_user
%name = user_signed_in?
if (%name.nil?)
Wikicr::USERS.default || raise "User not signed in"
else
Wikicr::USERS.find(%name)
end
end
macro acl_permit!(perm)
uses_login_cookies
if Wikicr::ACL.permitted?(current_user, request.path, Acl::PERM[{{perm}}])
puts "PERMITTED #{current_user.name} #{request.path} #{Acl::PERM[{{perm}}]}"
else
puts "NOT PERMITTED #{current_user.name} #{request.path} #{Acl::PERM[{{perm}}]}"
flash["danger"] = "You are not permitted to access this resource (#{request.path}, #{{{perm}}})."
redirect_to "/pages/home"
return # Stop the action
end
end
end

View File

@ -1,12 +0,0 @@
require "./application_controller"
class HomeController < ApplicationController
# get /
def index
if Wikicr::ACL.permitted?(current_user, "/pages/home", Acl::Perm::Read)
redirect_to "/pages/home"
else
"Not authorized"
end
end
end

52
src/controllers/pages.cr Normal file
View File

@ -0,0 +1,52 @@
require "markdown"
before_all do |env|
# env.session = Session.new(env.cookies)
end
private def fetch_params(env)
path = env.params.url["path"]
{
path: path, # basic path from the params unmodified
display_path: path, # TODO: clean the path
title: path.split("/").last, # keep only the name of the file
page: Wikicr::Page.new(path), # page handler
}
# file_path: Wikicr::Page.new(path).jail.file, # jail the path (safety)
end
get "/pages/search" do |env|
user_must_be_logged!(env)
query = env.params.query["q"]
# TODO: a real search
env.redirect query.empty? ? "/pages" : query
end
get "/pages/" do |env|
user_must_be_logged!(env)
env.redirect("/pages/home")
end
get "/pages/*path" do |env|
user_must_be_logged!(env)
locals = fetch_params(env).to_h
locals[:body] = (locals[:page].as(Wikicr::Page).read(current_user(env)) rescue "")
if (env.params.query["edit"]?) || !locals[:page].as(Wikicr::Page).exists?(current_user(env))
render_page(edit)
else
locals[:body_html] = Markdown.to_html(locals[:body].as(String))
render_page(show)
end
end
post "/pages/*path" do |env|
user_must_be_logged!(env)
locals = fetch_params(env).to_h
if (env.params.body["body"]?.to_s.empty?)
locals[:page].as(Wikicr::Page).delete(current_user(env)) rescue nil
env.redirect "/pages/"
else
locals[:page].as(Wikicr::Page).write env.params.body["body"], current_user(env)
env.redirect "/pages/#{locals[:path]}"
end
end

View File

@ -1,109 +0,0 @@
require "./application_controller"
require "../lib/wikimd/*"
class PagesController < ApplicationController
# get /sitemap
def sitemap
acl_permit! :read
pages = Wikicr::FileTree.build Wikicr::OPTIONS.basedir
render "sitemap.slang"
end
# get /pages/search?q=
def search
query = params.query["q"]
page = Wikicr::Page.new(query)
# TODO: a real search
redirect_to query.empty? ? "/pages/home" : page.real_url
end
# get /pages/*path
def show
acl_permit! :read
flash["danger"] = params.query["flash.danger"] if params.query["flash.danger"]?
page = Wikicr::Page.new url: params.url["path"], parse_title: true
if (params.query["edit"]?) || !page.exists?
show_edit(page)
else
show_show(page)
end
end
private def show_edit(page)
body = page.read rescue ""
flash["info"] = "The page #{page.url} does not exist yet." if !page.exists?
acl_permit! :write
render "edit.slang"
end
private def show_show(page)
index = Wikicr::PAGES.load!
body_html = Wikicr::MarkdPatch.to_html(input: page.read, index: index, context: page)
groups_read = Wikicr::ACL.groups_having_any_access_to page.real_url, Acl::Perm::Read, true
groups_write = Wikicr::ACL.groups_having_any_access_to page.real_url, Acl::Perm::Write, true
history << page
render "show.slang"
end
# post /pages/*path
def update
acl_permit! :write
page = Wikicr::Page.new url: params.url["path"], parse_title: true
if params.body["rename"]?
update_rename(page)
elsif (params.body["body"]?.to_s.empty?)
update_delete(page)
else
update_edit(page)
end
end
private def update_rename(page)
if !params.body["new_path"]?.to_s.strip.empty?
# TODO: verify if the user can write on new_path
# TODO: if new_path do not begin with /, relative rename to the current path
renamed_page = page # will be change in the transaction
Wikicr::PAGES.transaction! do |index|
index.delete page
renamed_page = page.rename current_user, params.body["new_path"]
renamed_page.parse_tags! index
index.add renamed_page
end
flash["success"] = "The page #{page.url} has been moved to #{renamed_page.url}."
redirect_to renamed_page.real_url # "/pages/#{params.body["new_path"]}"
else
redirect_to page.real_url
end
end
private def update_delete(page)
begin
Wikicr::PAGES.transaction! { |index| index.delete page }
page.delete current_user
flash["success"] = "The page #{page.url} has been deleted."
redirect_to "/pages/home"
rescue err
# TODO: what if the page is not deleted but not indexed anymore ?
# Wikicr::PAGES.transaction! { |index| index.add page }
flash["danger"] = "Error: cannot remove #{page.url}, #{err.message}"
redirect_to page.real_url
end
end
private def update_edit(page)
begin
page.write current_user, params.body["body"]
page.parse_title!
Wikicr::PAGES.transaction! do |index|
page.parse_tags! index
index.add page
end
flash["success"] = "The page #{page.url} has been updated."
redirect_to page.real_url
rescue err
flash["danger"] = "Error: cannot update #{page.url}, #{err.message}"
redirect_to page.real_url
end
end
end

View File

@ -0,0 +1,10 @@
def add_page(page, stack = [] of String)
String.build do |str|
Slang.embed("src/views/sitemap.directory.html.slang", "str")
end
end
get "/sitemap" do |env|
locals = {title: "sitemap", pages: Wikicr::SFile.build(Wikicr::OPTIONS.basedir)}
render "src/views/sitemap.html.slang", "src/views/layout.html.slang"
end

45
src/controllers/users.cr Normal file
View File

@ -0,0 +1,45 @@
module Wikicr
Dir.mkdir_p("meta")
File.touch("meta/users")
USERS = Wikicr::Users.new("meta/users")
end
private def fetch_params(env)
{
username: (env.params.body["username"]?),
password: (env.params.body["password"]?),
}
end
get "/users/login" do |env|
locals = {title: "Login"}
render_user(login)
end
post "/users/login" do |env|
locals = fetch_params(env)
user = Wikicr::USERS.auth! locals[:username].to_s, locals[:password].to_s
# TODO: make a notification
if user.nil?
env.redirect "/users/login"
else
env.session.string("username", user.name)
env.redirect "/pages"
end
end
get "/users/register" do |env|
locals = {title: "Register"}
render_user(register)
end
post "/users/register" do |env|
locals = fetch_params(env)
# TODO: make a notification
begin
user = Wikicr::USERS.register! locals[:username].to_s, locals[:password].to_s
env.redirect "/users/login"
rescue
env.redirect "/users/register"
end
end

View File

@ -1,45 +0,0 @@
require "./application_controller"
class UsersController < ApplicationController
# get /users/login
def login
acl_permit! :read
render "login.slang"
end
# post /users/login
def login_validates
acl_permit! :write
user = Wikicr::USERS.auth! params.body["username"].to_s, params.body["password"].to_s
# TODO: make a notification
if user.nil?
flash["danger"] = "User or password doesn't match."
redirect_to "/users/login"
else
flash["success"] = "You are connected!"
session.string("user.name", user.name)
set_login_cookies_for(user.name)
redirect_to "/pages/home"
end
end
# get /users/register
def register
acl_permit! :read
render "register.slang"
end
# post /users/register
def register_validates
acl_permit! :write
# TODO: make a notification
begin
user = Wikicr::USERS.register! params.body["username"].to_s, params.body["password"].to_s
flash["success"] = "You are registrated under the username #{user.name}. You can connect now."
redirect_to "/users/login"
rescue err
flash["danger"] = "Cannot register this account: #{err.message}."
redirect_to "/users/register"
end
end
end

2
src/lib.cr Normal file
View File

@ -0,0 +1,2 @@
require "./lib/options"
require "./lib/*"

View File

@ -1,50 +0,0 @@
require "./options"
require "./git"
require "./lockable"
require "./errors"
require "./**"
# This file initialize the state of the wiki, which is stored into the main module Wikicr
# It may need to execute an initialisation to generate default values for `Acl::Groups`, `Wikicr::Users`, `Page::Index`
module Wikicr
# The dir *meta* contains users account (with encrypted password),
# the index of the pages (with table of content, links, titles, ...)
# and user permissions
Dir.mkdir_p "meta"
# Define a default user that should be used for anonymous clients
DEFAULT_USER = Wikicr::User.new "guest", "guest", %w(guest)
# The list of the users is stored into *meta/users*. This file is updated when
# an user is created/modified/deleted, but the data are stored into RAM for
# reading.
USERS = Wikicr::Users.new("meta/users", DEFAULT_USER).load!
# The list of the permissions (group => path+permission) is stored into the
# file *meta/acl. Similar behaviour than `USERS`.
ACL = Acl::Groups.new("meta/acl").load!
# If there is no "guest", we assume that the ACL have not been initialized yet
# and we create a group "guest" and "user".
# TODO: a proper "installation" procedure should be made to avoid these kind
# of operation in a scope
if ACL["guest"]?.nil?
ACL.add("guest")
ACL["guest"]["/users/*"] = Acl::Perm::Write
ACL["guest"]["/sitemap"] = Acl::Perm::Read
ACL["guest"]["/pages*"] = Acl::Perm::Read
ACL["guest"]["/"] = Acl::Perm::Read
ACL.add("user")
ACL["user"]["/*"] = Acl::Perm::Read
ACL["user"]["/users/login"] = Acl::Perm::None
ACL["user"]["/users/register"] = Acl::Perm::None
ACL["user"]["/pages*"] = Acl::Perm::Write
ACL.save!
end
# The list of the pages (index) with a lot of meta-data. Same behaviour than
# `USERS` and `ACL`.
PAGES = Wikicr::Page::Index.new("meta/index").load!
end

View File

@ -1,15 +0,0 @@
require "./acl/**"
module Acl
PERM = {
none: Acl::Perm::None,
read: Acl::Perm::Read,
write: Acl::Perm::Write,
}
PERM_STR = {
"none" => Acl::Perm::None,
"read" => Acl::Perm::Read,
"write" => Acl::Perm::Write,
}
end

View File

@ -1,11 +0,0 @@
require "./perm"
require "./group"
# Entity that have access to the Acl system.
module Acl::Entity
# Returns `true` if the *group* is owned by the `Entity`, else `false`
abstract def has_group?(group : String) : Bool
# Returns the list of the group names of the `Entity`
abstract def groups : Array(String)
end

View File

@ -1,108 +0,0 @@
require "yaml"
require "./perm"
require "./path"
# The Group is identified by a *name* and has *permissions* on a set of paths.
# It is used by `Groups`.
# NOTE: I did not used `Hash().new(default)` because it is annoying with passing the permissions in the constructor
class Acl::Group
include YAML::Serializable
property name : String
property permissions : Hash(Acl::Path, Acl::Perm)
property default : Acl::Perm
# Create a new named Group with optional parameters.
#
# - *name* is the name of the group (arbitrary `String`).
# - *permissions* is a hash of ``{Acl::Path.new("path") => `Perm`}``.
# - *default* is the value used for every path not defined in the *permissions*.
#
# ```
# guest = Acl::Group.new(name: "guest", default: Acl::Perm::None, permissions: {Acl::Path.new "/public" => Acl::Perm::Read})
# user = Acl::Group.new(name: "user", default: Acl::Perm::Read, permissions: {Acl::Path.new "/protected" => Acl::Perm::None})
# admin = Acl::Group.new(name: "admin", default: Acl::Perm::Write)
# ```
def initialize(@name,
@permissions : Hash(Acl::Path, Acl::Perm) = Hash(Acl::Path, Acl::Perm).new,
@default : Acl::Perm = Acl::Perm::None)
end
# ```
# guest = Acl::Group.new(name: "guest", default: Acl::Perm::None, permissions: {"/public" => Acl::Perm::Read})
# user = Acl::Group.new(name: "user", default: Acl::Perm::Read, permissions: {"/protected" => Acl::Perm::None})
# admin = Acl::Group.new(name: "admin", default: Acl::Perm::Write)
# ```
def initialize(@name,
permissions : Hash(String, Acl::Perm),
@default : Acl::Perm = Acl::Perm::None)
@permissions = permissions.map { |k, v| {Acl::Path.new(k), v} }.to_h
end
# Check if the group as the `Acl::Perm` required to have access to a given path.
#
# - *path* is the path that must be checked
# - *access* is the minimal `Acl::Perm` required for a given operation
# ```
# guest = Acl::Group.new(name: "guest", default: Acl::Perm::None, permissions: {"/public" => Acl::Perm::Read})
# guest.permitted "/public", Acl::Perm::Read # => true
# guest.permitted "/public", Acl::Perm::Write # => false
# guest.permitted "/other", Acl::Perm::Read # => false
# ```
def permitted?(path : String, access : Acl::Perm) : Bool
matched_permissions = @permissions.select { |pe, _| pe.acl_match?(path) }
if matched_permissions.empty?
default.to_i >= access.to_i
else
# keep the longuest path
match = matched_permissions.reduce { |l, r| l[0] >= r[0] ? l : r }
match[1].to_i >= access.to_i
end
end
# Tries to match the *path* with the permissions of this group.
# If select every matching path and get the maximum permission among them.
def matching?(path : String) : Acl::Perm?
founds = @permissions.select { |ppath, pgroup| ppath.acl_match?(path) }
return nil if founds.empty?
found_min_size = founds.reduce { |left, right| left[0].size >= right[0].size ? left : right }
found_min_size[1]
end
# Same than Path[String]? but returns the defaut value if not found
def matching(path : String) : Acl::Perm
acl = self.matching?(path)
return @default if acl.nil?
acl
end
# Tries to find the exact *path* with the permissions of this group.
def []?(path : String) : Acl::Perm?
found = @permissions.find { |ppath, pgroup| ppath == path }
found && found[1]
end
# Same than Path[String]? but returns the defaut value if not found
def [](path : String) : Acl::Perm
acl = self[path]?
return @default if acl.nil?
acl
end
# If a path exists, replace it with the given permission *acl*, else create it.
def []=(path : String, acl : Acl::Perm)
replace = @permissions.find { |ppath, _| ppath == path }
if replace
@permissions[replace[0]] = acl
else
@permissions[Acl::Path.new(path)] = acl
end
end
# Remove the permissions associated to the path
def delete(path : String)
@permissions.reject! { |current_path| current_path.to_s == path }
self
end
end

View File

@ -1,178 +0,0 @@
require "yaml"
require "./perm"
require "./group"
require "./entity"
require "../lockable"
# The Groups is used to handle a set of uniq `Group`, by *name*.
# It simplifies indexing, serializing, and interaction with other entities that may own several groups.
class Acl::Groups < Lockable
include YAML::Serializable
property file : String
property groups : Hash(String, Acl::Group)
# The only parameter **file** is a path to synchronize the data with an harddrive
# to keep data when stopping/restarting the wiki.
#
# Usage:
# ```
# acls = Acl::Groups.new
# g1 = Acl::Group.new(name: "user", default: Acl::Perm::Read, permissions: {"/tmp/protected" => Acl::Perm::None})
# g2 = Acl::Group.new(name: "admin", default: Acl::Perm::Write)
# acls.add g1
# acls.add g2
# ```
def initialize(@file)
@groups = {} of String => Acl::Group
end
# Erase the data of the file on harddrive with the current state in memory
def save!
File.write(@file, to_yaml)
self
end
# Read the file on harddrive and erase the current state in memory
# NOTE: do nothing if the file does not exists
def load!
if File.exists?(@file) && (new_groups = Acl::Groups.read(@file) rescue nil)
@groups = new_groups.groups
# @file = groups.file
else
@groups = {} of String => Acl::Group
end
self
end
# Unserialize a raw data in a file
def self.read(file : String) : Acl::Groups
Acl::Groups.from_yaml File.read(file)
end
# Check if an `Entity` has a group with the required permissions to operate.
#
# ```
# acls = Groups.new...user = User.new...acls.permitted?(user, "/my/path", Perm::Read)
# ```
def permitted?(entity : Acl::Entity, path : String, access : Acl::Perm)
entity.groups.any? do |group|
@groups[group]? ? @groups[group].permitted?(path, access) : false
end
end
# def if_permitted(entity : Acl::Entity, path : String, access : Acl::Perm)
# yield block if permitted? entity, path, access
# end
# Add a group in the current state.
# NOTE: Overwrite conflicting existing group
def add(group : String)
@groups[group] = Group.new(group)
self
end
# Add a group in the current state.
# NOTE: Overwrite conflicting existing group
def add(group : Acl::Group)
@groups[group.name] = group
group
end
# Remove an existing group
# NOTE: do nothing if the group is not found
def delete(group : String)
@groups.delete group
self
end
# Remove an existing group
# NOTE: do nothing if the group is not found
def delete(group : Acl::Group)
@groups.delete group.name
self
end
# Access an existing group
# NOTE: raise an error if the group is not found
def [](group : String) : Acl::Group
@groups[group]
end
# Access an existing group
# NOTE: raise an error if the group is not found
def [](group : Acl::Group) : Acl::Group
@groups[group.name]
end
# Access an existing group
# NOTE: nil if not found
def []?(group : String) : Acl::Group?
@groups[group]?
end
# Access an existing group
# NOTE: nil if not found
def []?(group : Acl::Group) : Acl::Group?
@groups[group.name]?
end
# Test if a group already exists in the current state
def group_exists?(group : String) : Bool
@groups.keys.includes? group
end
# Test if a group already exists in the current state
def group_exists?(group : Acl::Groum) : Bool
group_exists? group.name
end
# List the groups having at least the permission *acl_min* on a path
def groups_having_direct_access_to(path : String, acl_min : Acl::Perm, not_more : Bool = false) : Array(String)
@groups.select do |_, group|
current_acl = (group[path]? || Acl::Perm::None).to_i
if not_more
current_acl == acl_min.to_i
else
current_acl >= acl_min.to_i
end
end.keys
end
# Similar to `#groups_having_direct_access_to` but it only check exact path, without globbing matching
def groups_having_any_access_to(path : String, acl_min : Acl::Perm, not_more : Bool = false) : Array(String)
@groups.select do |_, group|
current_acl = (group.matching?(path) || Acl::Perm::None).to_i
if not_more
current_acl == acl_min.to_i
else
current_acl >= acl_min.to_i
end
end.keys
end
# The quickest way possible to give a permission to a list of groups to a resource
def add_permissions_to(path : String, groups : Array(String), acl : Acl::Perm)
groups.each do |group|
self.add group unless group_exists? group
old_acl = self[group][path]?
self[group][path] = acl if old_acl.nil? || old_acl.to_i < acl.to_i
end
self
end
# Remove all positive permissions on a path for every existin group
def clear_permissions_of(path : String)
self.clear_permissions_of(path, Acl::Perm::Read)
self.clear_permissions_of(path, Acl::Perm::Write)
end
# Remove a given permission on a path for every existin group
def clear_permission_of(path : String, acl : Acl::Perm)
@groups.each do |_, group|
group.delete(path) if group[path]? == acl
end
self
end
end

View File

@ -1,44 +0,0 @@
require "yaml"
# An Acl Path identify one or several resources based on joker `*`
# in order to match several files.
class Acl::Path
include YAML::Serializable
property value : String
@[YAML::Field(key: "regex", ignore: true)]
getter regex : Regex?
def self.value_to_regex(value : String)
value_regex = value.gsub "*", ".*"
Regex.new "^#{value_regex}$"
end
def initialize(@value : String)
@regex = Acl::Path.value_to_regex @value
end
def acl_match?(other_path : String) : Bool
@regex ||= Acl::Path.value_to_regex @value
!!@regex.as(Regex).match other_path
end
def to_s
@value
end
def size
# +3 = ".md".size
@value.includes?("*") ? @value.size : @value.size + 3
end
def ==(rhs)
self.to_s == rhs.to_s
end
{% for op in [">", "<", ">=", "<="] %}
def {{op.id}}(rhs : Path)
self.size {{op.id}} rhs.size
end
{% end %}
end

View File

@ -1,14 +0,0 @@
# Permission levels of the Acl system.
#
# The values are ordered and hide a bitmask (read=1, write=2, read+write=3) but for simplicity at usage,
# since nothing can have the value 2, we don't explicitly have a write-only value at 2.
enum Acl::Perm
# level 0. Cannot read, cannot write.
None = 0
# level 1. Can read, cannot write.
Read = 1
# level 3. Can read, can write.
Write = 3
end

View File

@ -1,51 +0,0 @@
# A `FileTree` is a tree structure representing a file with a name and subfiles.
# It is used to map the wiki for the "sitemap" feature.
# TODO: it should be fully replaced by the index
class Wikicr::FileTree
getter name : String
getter files : Array(FileTree)
# Build a FileTree that represents the real structure of the "to_scan"
# It is recursive and may be very time consuming, so there is a limit of depth
#
# ```
# FileTree.build("./data/")
# ```
def self.build(to_scan : String, max_depth : Int = 32) : FileTree
# Stop the recursion
return FileTree.new to_scan if max_depth < 1
# Save the current directory before getting in
dir_current = Dir.current
Dir.cd to_scan
# List the files, and filter them
all_files = Dir.entries "."
all_files.select! { |file| !(file =~ /^\./) }
all_files.sort!
# Separate files and directory
# For the directories, call this function recursively
files = all_files
.select { |file| !File.directory? file }
.map { |file| FileTree.new(file).as(FileTree) }
directories = all_files
.select { |file| File.directory? file }
.map { |file| FileTree.new(file, FileTree.build(file, max_depth - 1).files).as(FileTree) }
# Generate the file with the list of the files and the directories
structure = FileTree.new to_scan, files + directories
# Get out of the parent and return the current directory object
Dir.cd dir_current
structure
end
def initialize(@name, @files = [] of FileTree)
end
# If the file contains other files, then it is a "directory"
def directory? : Bool
!@files.empty?
end
end

View File

@ -1,30 +1,17 @@
require "./options"
module Wikicr::Git
module Wikicr
REPO = Git::Repo.open(Wikicr::OPTIONS.basedir)
extend self
# Initialize the data repository (where the pages are stored).
def init!
Dir.mkdir_p Wikicr::OPTIONS.basedir
current = Dir.current
Dir.cd Wikicr::OPTIONS.basedir
`git init .`
Dir.cd current
def repo
REPO
end
# Save the modifications on the *file* into the git repository
# TODO: lock before commit
# TODO: security of jailed_file and self.name ?
def commit!(user : Wikicr::User, message, files : Array(String) = [] of String)
dir = Dir.current
begin
Dir.cd Wikicr::OPTIONS.basedir
puts `git add -- #{files.join(" ")}`
puts `git commit --no-gpg-sign --author "#{user.name} <#{user.name}@localhost>" -m "#{message}" -- #{files.join(" ")}`
ensure
Dir.cd dir
end
def init!
end
end
Wikicr::Git.init!
require "./users"
Wikicr::Page.new("testX").write("OK", Wikicr::USERS.read!.find("arthur"))

15
src/lib/index.cr Normal file
View File

@ -0,0 +1,15 @@
require "./page"
require "./sfile"
# Index handler, to build and parse index files that keep a list of files and meta-data
class Wikicr::Page::Index
# getter dir : String
# getter pages : Array(SFile)
# def initialize(@dir)
# @pages = Dir.entries(@dir).map { |f| SFile.build(f) }
# end
# def build!
# end
end

View File

@ -1,28 +0,0 @@
# Lockable is an abstract class that provides a function `#transaction!` that
# allows the class to execute some code that requires to do not conflict with
# other operations. It is usually linked with an IO (a file).
abstract class Lockable
abstract def load!
abstract def save!
@[YAML::Field(key: "lock", ignore: true)]
@lock : Mutex = Mutex.new
# Execute some operation on the object, and then save it.
# The content can be loaded before executing the operations optionnaly.
#
# ```
# someLockableObject.transaction(read: true) { |obj| obj.update_operation(...) }
# ```
def transaction!(read = false)
@lock.synchronize do
begin
self.load! if read == true
yield self
ensure
self.save!
self
end
end
end
end

View File

@ -1,12 +1,12 @@
module Wikicr
class Options
getter basedir : String
class Wikicr::Options
getter basedir : String
def initialize
@basedir = File.expand_path ENV.fetch("WIKI_DATA", "data"), Dir.current
Dir.mkdir_p @basedir rescue nil
end
def initialize
@basedir = File.expand_path(ENV["WIKI_DATA"]? || "data", Dir.current)
Dir.mkdir_p(@basedir) rescue nil
end
end
module Wikicr
OPTIONS = Wikicr::Options.new
end

View File

@ -1,147 +1,108 @@
require "uri"
require "./errors"
require "./page/*"
require "./sfile"
# A `Page` is the representation in the wiki of something that can be accessed
# from an url /pages/*path.
#
# It is used to associate path, url and data.
# Is is can also jails the path into the *OPTIONS.basedir* to be sure that
# there is no attack by writing files outside of the directory where the pages
# must be stored.
class Wikicr::Page
include Wikicr::Page::TableOfContentReader
include Wikicr::Page::TagsReader
# A Page is a file and an url part
# Is is used to jail files into the OPTIONS.basedir
struct Wikicr::Page
getter file : String
getter name : String
# Directory where the pages are stored
PAGES_SUB_DIRECTORY = "pages/"
# Beginning of the url of a page
URL_PREFIX = "/pages"
# Path of the file that contains the page
getter path : String
# Url of the page (without any prefix)
getter url : String
# Complete Url of the page
getter real_url : String
# Title of the page
getter title : String
# A way to hold the tags without having to compute it again and again
# you need to fill it yourself (with the wikimd patch)
property tags : Array(String)
def initialize(url : String, real_url : Bool = false, parse_title : Bool = false)
@tags = [] of String
url = Page.sanitize(url)
if real_url
@real_url = url
@url = @real_url[URL_PREFIX.size..-1].strip "/"
else
@url = url.strip "/"
@real_url = File.expand_path @url, URL_PREFIX
end
@path = Page.url_to_file @url
@title = File.basename @url
@title = Page.parse_title(@path) || @title if parse_title && File.exists? @path
end
def self.parse_title(path : String) : String?
title = File.read(path).split("\n").find { |l| l.starts_with? "# " }
title && title.strip("# ").strip
end
# basic treatment of the url
# it is NOT a security measure
def self.sanitize(url : String)
Index::Entry.title_to_slug url
end
def parse_title!
@title = Page.parse_title(@path) || @title if File.exists?(@path)
def initialize(@name)
@file = Page.name_to_file(@name)
end
# translate a name ("/test/title" for example)
# into a file path ("/srv/data/test/ttle.md)
def self.url_to_file(url : String)
page_dir = File.expand_path Wikicr::OPTIONS.basedir, PAGES_SUB_DIRECTORY
page_file = File.expand_path Page.sanitize(url), page_dir
page_file + ".md"
def self.name_to_file(name : String)
File.expand_path(name + ".md", Wikicr::OPTIONS.basedir)
end
# verify if the *file* is in the current dir (avoid ../ etc.)
# it will raise a `Error403` if the file is out of the basedir
def jail
# :unused:
# # translate a file into a name
# # @see #name_to_file
# def self.file_to_name(file : String)
# file.chomp(".md")[Wikicr::OPTIONS.basedir.size..-1]
# end
# :unused:
# # set a new file name, an update the file path
# def name=(name)
# @name = name
# @file = Page.name_to_file @name
# end
# :unused:
# # set a new file path, and update the file name
# def file=(file)
# @file = File.expand_path file
# @name = Page.file_to_name @file
# end
# verify if the file is in the current dir (avoid ../ etc.)
def jail(user : User)
chroot = Wikicr::OPTIONS.basedir
# TODO: consider security of ".git/"
# TODO: read ACL for user
# the @file is already expanded (File.expand_path) in the constructor
if Wikicr::OPTIONS.basedir != @path[0..(Wikicr::OPTIONS.basedir.size - 1)]
raise Error403.new "Out of chroot (#{@path} on #{Wikicr::OPTIONS.basedir})"
if chroot != @file[0..(chroot.size - 1)]
raise Error403.new "Out of chroot (#{@file} on #{chroot})"
end
self
end
# Get the directory of the *file* (~/data/test/home becomes ~/data/test)
def dirname
File.dirname @path
File.dirname self.file
end
# Url without the page itself (/pages/test/home becomes /test)
def url_dirname
File.dirname @url
def read(user : User)
self.jail user
File.read self.file
end
# Real url without the page itself (/pages/test/home becomes /pages/test)
def real_url_dirname
File.dirname @real_url
end
# Reads the *file* and returns the content.
def read
self.jail
File.read @path
end
# TODO: verify if the new_page already exists
# Move the current page into another place and commit
def rename(user : Wikicr::User, new_url) : Page
self.jail
new_page = Wikicr::Page.new new_url
new_page.jail
Dir.mkdir_p File.dirname new_page.path
File.rename self.path, new_page.path
Wikicr::Git.commit! user, message: "rename #{@url}", files: [@path, new_page.path]
new_page
end
# Writes into the *file*, and commit.
def write(user : Wikicr::User, body)
self.jail
def write(body, user : User)
self.jail user
Dir.mkdir_p self.dirname
is_new = File.exists? @path
File.write @path, body
Wikicr::Git.commit! user, message: (is_new ? "create #{@url}" : "update #{@url}"), files: [@path]
File.write self.file, body
commit!(user)
end
# Deletes the *file*, and commit
def delete(user : Wikicr::User)
self.jail
File.delete @path
Wikicr::Git.commit! user, message: "delete #{@url}", files: [@path]
private def commit!(user)
puts "--------------- COMMIT ! ------------"
# You can check git_repository_head_unborn() to see if HEAD points at a reference or not.
tree_id = Pointer(Git::C::Oid).null
parent_id = Pointer(Git::C::Oid).null
commit_id = Pointer(Git::C::Oid).null
tree = nil.as(Git::C::X_Tree)
parent = nil.as(Git::C::X_Commit)
index = nil.as(Git::C::X_Index)
puts "repository_index"
puts Git::C.repository_index(pointerof(index), Wikicr.repo.safe)
pp index, index.address, index.value
puts "index_write_tree"
puts Git::C.index_write_tree(tree.as(Pointer(Git::C::Oid)), index)
pp tree
puts "reference_name_to_id"
puts Git::C.reference_name_to_id(parent_id, Wikicr.repo.safe, "HEAD")
puts "commit_lookup"
puts Git::C.commit_lookup(pointerof(parent), Wikicr.repo.safe, parent_id)
sign = Pointer(Git::C::Signature).null
puts "signature_now"
puts Git::C.signature_now(pointerof(sign), user.name, "#{user.name}@localhost")
puts "commit_create"
puts Git::C.commit_create(commit_id, Wikicr.repo.safe, "HEAD", sign, sign, "UTF-8", "update #{self.name}", tree.value, 1, pointerof(parent))
end
# Checks if the *file* exists
def exists?
self.jail
File.exists? @path
def delete(user : User)
self.jail user
File.delete self.file
end
def exists?(user : User)
self.jail user
File.exists? self.file
end
end
# require "./users"
# require "./git"
# Wikicr::Page.new("testX").write("OK", Wikicr::USERS.load!.find("arthur.poulet@mailoo.org"))

View File

@ -1,158 +0,0 @@
require "yaml"
require "./index/entry"
# And Index is an object that associate a file with a lot of meta-data
# like related url, the title, the table of content, ...
class Wikicr::Page
class Index < Lockable
alias Entries = Hash(String, Entry) # path, entry
include YAML::Serializable
property file : String
property entries : Entries
# property find_index : Hash(String, String)
# property find_tags : Hash(String, Array(String))
def initialize(@file : String)
@entries = Entries.new
# @find_index = of String => String
# @find_tags = of String => Array(String)
end
# Find a matching *text* into the Index.
# If no matching content, return a default value.
def one_by_title_or_url(text : String, context : Page, raise_not_found : Bool = false) : Index::Entry
found = find_by_title(text, context) || find_by_url(text, context)
if found.nil?
raise "Page not found" if raise_not_found
STDERR.puts "warning: no page \"#{text}\" found"
Index::Entry.from_context(context, text)
else
return found
end
end
# Find a specific url into the Index.
# If no matching content, return a default value.
# If the page is not found, the title of the entry will be the default_title or the url
def one_by_url(url : String, context : Page, default_title : String? = nil, raise_not_found : Bool = false) : Index::Entry
found = find_by_url(url, context)
if found.nil?
raise "Page not found" if raise_not_found
STDERR.puts "warning: no page \"#{url}\" found"
title = default_title || url
Index::Entry.from_context(context, title, url)
else
return found
end
end
TAG_SIGN_REQUIRE = '+'
TAG_SIGN_FORBIDE = '-'
TAG_SIGNS = {
TAG_SIGN_REQUIRE,
TAG_SIGN_FORBIDE,
}
# Find a matching *text* into the Index.
# If no matching content, return a default value.
# @param tag_line must be a "tag +andtagx -butnottagy ortag3"
def all_by_tags(tags_line : String, context : Page) : Entries
tags = tags_line.split(' ')
required_tags = tags.select { |tag| tag[0] == TAG_SIGN_REQUIRE }.map { |tag| tag[1..-1] }
forbidden_tags = tags.select { |tag| tag[0] == TAG_SIGN_FORBIDE }.map { |tag| tag[1..-1] }
at_least_one_tags = tags.select { |tag| !TAG_SIGNS.includes? tag[0] }
@entries.select do |url, entry|
(entry.tags & required_tags).size == required_tags.size &&
(entry.tags & forbidden_tags).size == 0 &&
(entry.tags & at_least_one_tags).size > 0
end
end
# Find the closest `Index`' `Entry` to *text* based on the entries title
# and searching for the closer url as possible to the context
private def find_by_title(text : String, context : Page) : Entry?
# exact_matched = @entries.select{|_, entry| entry.title == text }.values
# return choose_closer_url(exact_matched, context) unless exact_matched.empty?
slug_matched = @entries.select { |_, entry|
entry.slug == Index::Entry.title_to_slug(text)
}.values
return choose_closer_url(slug_matched, context) unless slug_matched.empty?
nil
end
# Find the url which is the closest as possible than the context url (start with the maxmimum common chars).
private def choose_closer_url(entries : Array(Entry), context : Page) : Entry
raise "Cannot handle empty array" if entries.empty?
entries.reduce { |lhs, rhs| Index.url_closeness(context.url, lhs.url) >= Index.url_closeness(context.url, rhs.url) ? lhs : rhs }
end
# Computes the amount of common chars at the beginning of each string
def self.url_closeness(from : String, to : String)
from.size.times do |i|
return i if from[i] != to[i]
end
return from.size
end
private def find_by_url(text : String, context : Page) : Entry?
slug_matched = @entries.select { |_, entry|
entry.url == Index::Entry.title_to_slug(text) ||
entry.url == File.join(context.url_dirname, Index::Entry.title_to_slug(text))
}.values
return choose_closer_url(slug_matched, context) unless slug_matched.empty?
nil
end
# Access to an existing `Entry`.
def [](page : Wikicr::Page) : Index::Entry
@entries[page.path]
end
# Access to an existing `Entry`.
def []?(page : Wikicr::Page) : Index::Entry?
@entries[page.path]?
end
# Add a new `Entry`.
def add(page : Wikicr::Page)
@entries[page.path] = Entry.new(
path: page.path,
url: page.url,
title: page.title,
read_toc: true,
tags: page.tags,
)
self
end
# Remove an `Entry` from the `Index` based on its path.
def delete(page : Wikicr::Page)
@entries.delete page.path
self
end
# Replace the old Index using the state registrated into the *file*.
def load!
if File.exists?(@file) && (new_index = Index.read(@file) rescue nil)
@entries = new_index.entries
# @file = index.file
else
@entries = {} of String => Entry
end
self
end
def self.read(file : String)
Index.from_yaml File.read(file)
end
# Save the current state into the file
def save!
File.write @file, self.to_yaml
self
end
end
end

View File

@ -1,40 +0,0 @@
require "../table_of_content"
require "../tags"
require "../../lockable"
# And Index is an object that associate a file with a lot of meta-data
# like related url, the title, the table of content, ...
class Wikicr::Page
class Index < Lockable
class Entry
alias Tag = String
alias Tags = Array(Tag)
include YAML::Serializable
property path : String # path of the file /srv/wiki/data/xxx
property url : String # real url of the page /pages/xxx
property title : String # Any title
property slug : String # Exact matching title
property toc : Page::TableOfContentReader::Toc
property tags : Tags
def initialize(@path, @url, @title, @tags : Tags = Tags.new, read_toc : Bool = false)
@slug = Entry.title_to_slug title
@toc = read_toc ? Page::TableOfContentReader.toc(@path) : Page::TableOfContentReader::Toc.new
end
def self.from_context(context : Page, title : String, url : String? = nil)
url = url || title
new(
title: title,
url: File.join(context.real_url_dirname, Entry.title_to_slug(url)),
path: File.join(context.dirname, "#{Entry.title_to_slug(url)}.md"),
)
end
def self.title_to_slug(title : String) : String
title.gsub(/[\s\.]/, '-').gsub(/-+/, '-').downcase
end
end
end
end

View File

@ -1,32 +0,0 @@
class Wikicr::Page
module TableOfContentReader
alias TocLine = {Int32, String}
alias Toc = Array(TocLine)
# The table of content of the file
def toc : Toc
TableOfContentReader.toc @path
end
def self.toc(path : String) : Toc
toc = Toc.new
File.open path, "r" do |f|
while line = f.gets
toc_line = get_toc_line line
toc << toc_line.as(TocLine) unless toc_line.nil?
end
end
pp toc
toc
end
# Parse a markdown line, and return a TocLine if it is a title
def self.get_toc_line(line : String) : TocLine?
if match = line.match /^(\#{1,6})\s(.+)/
title_num = match[1].size
title = match[2]
{title_num, title}
end
end
end
end

View File

@ -1,11 +0,0 @@
class Wikicr::Page
module TagsReader
def parse_tags!(index : Index)
Wikicr::MarkdPatch.to_html(input: self.read, index: index, context: self, parse_tags: true)
end
# def self.all(path : String) : Index::Entry::Tags
# Index::Entry::Tags.new
# end
end
end

26
src/lib/session-init.cr Normal file
View File

@ -0,0 +1,26 @@
Session.config do |config|
config.cookie_name = "session_id"
config.secret = ENV["WIKI_SECRET"]
config.gc_interval = 2.minutes # 2 minutes
end
macro user_must_be_logged!(env)
if user_signed_in?(env)
puts "You are authenticated"
# continue
else
puts "You are not authenticated"
env.redirect "/users/login"
next
end
end
macro user_signed_in?(env)
env.session.string?("username")
end
macro current_user(env)
%name = user_signed_in?(env)
raise "User not signed in" if %name.nil?
Wikicr::USERS.find(%name)
end

36
src/lib/sfile.cr Normal file
View File

@ -0,0 +1,36 @@
# a SFile is a file structure with a name and subfiles
class Wikicr::SFile
getter name : String
getter files : Array(SFile)
# Build a SFile that represents the real structure of the "to_scan"
# It is recursive and may be very time consuming, so there is a limit of depth
def self.build(to_scan : String, max_depth : Int = 32) : SFile
return SFile.new(to_scan) if max_depth < 1
dir_current = Dir.current
Dir.cd to_scan
all_files = Dir.entries(".")
all_files.select! { |file| !(file =~ /^\./) }
files = all_files
.select { |file| !File.directory? file }
.map { |file| SFile.new(file).as(SFile) }
directories = all_files
.select { |file| File.directory? file }
.map { |file| SFile.new(file, SFile.build(file, max_depth - 1).files).as(SFile) }
structure = SFile.new(to_scan, files + directories)
Dir.cd dir_current
structure
end
def initialize(@name, @files = [] of SFile)
end
# if the file contains other files, then it is a "directory"
def directory? : Bool
!@files.empty?
end
end

43
src/lib/user.cr Normal file
View File

@ -0,0 +1,43 @@
require "crypto/bcrypt/password"
class Wikicr::User
class Invalid < Exception
end
SEP = ':'
getter name : String
getter password : String
getter groups : Array(String)
def initialize(@name, @password, @groups = [] of String)
end
def initialize(line : String)
split = line.split SEP
raise Invalid.new("Cannot parse this line (split.size = #{split.size}, should be 3)") if split.size != 3
@name = split[0]
@password = split[1]
@groups = split[2].split(",")
end
def encrypt!
@password = Crypto::Bcrypt::Password.create(@password).to_s
self
end
def password_encrypted
Crypto::Bcrypt::Password.new(@password)
end
def to_s
"#{name}#{SEP}#{password}#{SEP}#{groups.join(",")}"
end
def to_s(io : IO)
io << name << SEP
io << password << SEP
groups.each { |g| io << g; io << ',' if g != groups.last }
io << '\n'
end
end

114
src/lib/users.cr Normal file
View File

@ -0,0 +1,114 @@
require "./user"
# Handle an user list and file associated
# TODO: mutex on add/remove/update
class Wikicr::Users
class AlreadyExist < Exception
end
class NotExist < Exception
end
getter file : String
@list : Hash(String, User)
def initialize(@file)
@list = {} of String => User
# TODO: set UNIX permissions
File.touch(@file)
end
# read the users from the file (erase the modifications !)
def read!
@list = File.read(@file).split("\n")
.select { |line| !line.empty? }
.map { |line| u = User.new(line); {u.name, u} }.to_h
self
end
# save the users into the file
def save!
File.open(@file, "w") do |fd|
@list.each { |name, user| user.to_s(fd) }
end
self
end
# add an user to the list
def add(u : User)
raise AlreadyExist.new "User #{u.name} already exists" if (@list[u.name]?)
@list[u.name] = u
self
end
# remove an user from the list
# @see .remove(String)
def remove(u : User)
remove u.name
end
# remove an user from the list
def remove(name : String)
raise NotExist.new "User #{name} is not in the list" if (!@list[name]?)
@list.remove(name)
self
end
# replace an entry
def update(name : String, u : User)
raise NotExist.new "User #{name} is not in the list" if (!@list[name]?)
# if the name change
if name != u.name
add u # if it fails, remove will fail too
remove name
else
@list[u.name] = u
end
self
end
# find an user based on its name
def find(name : String) : User
raise NotExist.new "User #{name} is not in the list" if (!@list[name]?)
@list[name]
end
##################
# HIGH LEVEL API #
##################
# find an user by its name and check the password
def auth?(name : String, password : String) : User?
user = find(name)
user.password_encrypted == password ? user : nil
end
def auth!(name : String, password : String) : User?
self.read!
auth?(name, password)
end
def register!(name : String, password : String, groups : Array(String) = %w(user))
user = User.new(name, password, groups).encrypt!
self.read!
self.add(user)
self.save!
user
end
end
# file = "/tmp/users"
# File.touch(file)
# include Wikicr
# users = Users.new(file)
# users.read!
# pp users
# user = User.new("arthur", "passwd", %w(admin,user)).encrypt
# users.add user
# users.save!
# p users
# pp Crypto::Bcrypt::Password.new(user.password) == "passwd"
# pp users.auth?("arthur", "passwd")
# pp users.auth?("arthur", "passwdx")
# pp users.auth?("arthurx", "passwd") # raise here

View File

@ -1,55 +0,0 @@
require "crypto/bcrypt/password"
require "../acl/entity"
# An `User` is a couple name/password/groups/token.
# The *name* and *groups* is public, the *password* and the *token* are private.
# The password is used to recognize an user when login in with a form for example.
# The token is used to recognize an user by a cookie for exemple.
class Wikicr::User
class Invalid < Exception
end
include YAML::Serializable
property name : String
property password : String
property groups : Array(String)
property token : String?
# ```
# User.new "admin", "password", %w(admin user)
# User.new "nephos", "password", %w(user guest)
# ```
def initialize(@name, @password, @groups = [] of String, @token : String? = nil)
raise "Invalid name #{@name}" if !@name =~ /^[A-Za-z0-9 _.-]+$/ # Security: Avoid escaping and injection of code
end
# Encrypts the passwod using `Crypto::Bcrypt`.
#
# ```
# Wikicr::User.new("admin", "password", %w(admin)).encrypt!.password # => "$2a$11$G2i2.Km1DRbJtqDBFRhKXuSn8IwNVt7AypAP328T1OYq0wBugkgCm"
# ```
def encrypt!
@password = Crypto::Bcrypt::Password.create(@password).to_s
self
end
# Reads the password using `Crypto::Bcrypt`
def password_encrypted
Crypto::Bcrypt::Password.new @password
end
def generate_new_token!
@token = Random::Secure.base64 64
end
#########################
# Implement Acl::Entity #
#########################
include Acl::Entity
# getter groups : Array(String)
def has_group?(group : String) : Bool
@groups.includes? group
end
end

View File

@ -1,171 +0,0 @@
require "./user"
# The class `Users` handles a list of `User`, with add, remove, update an find operation.
# An instance of `Users` must be linked with a file which can be read of updated
#
# TODO: mutex on add/remove/update
class Wikicr::Users < Lockable
class AlreadyExist < Exception
end
class NotExist < Exception
end
# getter file : String
# getter default : User?
# @list : Hash(String, User)
include YAML::Serializable
property file : String
property default : User?
property list : Hash(String, User)
def initialize(@file, @default : User? = nil)
@list = {} of String => User
# TODO: set UNIX permissions
File.touch(@file)
end
# read the users from the file (erase the modifications !)
def load!
if File.exists?(@file) && (new_users = Users.from_yaml(File.read(@file)) rescue nil)
@list = new_users.list
@default = new_users.default
# @file = new_users.file
else
@list = {} of String => User
end
self
end
# save the users into the file
def save!
File.write @file, self.to_yaml
end
# add an user to the list
def add(u : User)
raise AlreadyExist.new "User #{u.name} already exists" if (@list[u.name]?)
@list[u.name] = u
self
end
# Removes an user from the list
# @see #delete(String)
def delete(u : User)
delete u.name
self
end
# Remove an user from the list
def delete(name : String)
raise NotExist.new "User #{name} is not in the list" if (!@list[name]?)
@list.delete name
self
end
# replace an entry
def update(name : String, u : User)
raise NotExist.new "User #{name} is not in the list" if (!@list[name]?)
# if the name change
if name != u.name
add u # if it fails, remove will fail too
remove name
else
@list[u.name] = u
end
self
end
# find an user based on its name
def find(name : String) : User
user = @list[name]?
raise NotExist.new "User #{name} is not in the list" unless user
user
end
# find an user based on its name
def find?(name : String) : User?
user = @list[name]?
end
def [](name : String) : User
find name
end
def []?(name : String) : User?
find? name
end
def each
@list.each { |_, user| yield user }
end
def map
@list.map { |_, user| yield user }
end
def map!
@list.map! { |name, user| {name, yield user} }
end
##################
# HIGH LEVEL API #
##################
# No file operation.
#
# Finds an user by its name and check the password.
#
# Returns nil if it fails
def auth?(name : String, password : String) : User?
user = find(name)
user && user.password_encrypted.verify(password) ? user : nil
end
def auth_token?(name : String, token : String) : User?
user = find?(name)
user && user.token == token ? user : nil
end
# Operation read (erase the internal list).
#
# see `#auth?`
def auth!(name : String, password : String) : User?
self.load!
auth?(name, password)
end
def auth_token!(name : String, token : String) : User?
self.load!
auth_token?(name, token)
end
# Operation read and write (erase the internal list and the file)
#
# Registers a new user by create a new `User` with `name`, `password` and `groups`
# and then update the file with the new list of users.
def register!(name : String, password : String, groups : Array(String) = %w(user))
user = User.new(name, password, groups).encrypt!
self.transaction! do |users|
users.add user
end
user
end
end
# file = "/tmp/users"
# File.touch(file)
# include Wikicr
# users = Users.new(file)
# users.load!
# pp users
# user = User.new("arthur", "passwd", %w(admin,user)).encrypt
# users.add user
# users.save!
# p users
# pp Crypto::Bcrypt::Password.new(user.password) == "passwd"
# pp users.auth?("arthur", "passwd")
# pp users.auth?("arthur", "passwdx")
# pp users.auth?("arthurx", "passwd") # raise here

View File

@ -1,202 +0,0 @@
require "markd"
require "../page/index"
module Wikicr::MarkdPatch
module Rule
WIKI_TAG_OPENNER = /(\{\{|<<)/
WIKI_TAG_CLOSER = /(\}\}|>>)/
# default must be matched last because it matches all the other tags
WIKI_TAG = {
autolink: /^#{WIKI_TAG_OPENNER}(link:)([[:graph:] ]+)#{WIKI_TAG_CLOSER}/i,
tag: /^#{WIKI_TAG_OPENNER}(tag:)([[:graph:] ]+)#{WIKI_TAG_CLOSER}/i,
table: /^#{WIKI_TAG_OPENNER}(table:)([[:graph:] ]+)#{WIKI_TAG_CLOSER}/i,
default: /^#{WIKI_TAG_OPENNER}([[:graph:] ]+)#{WIKI_TAG_CLOSER}/i,
}
end
class Parser::Inline < ::Markd::Parser::Inline
# patch the autolink <...>
# if we match a wiki tag use it, else we can fallback on default markdown behavior
private def auto_link(node : ::Markd::Node)
# puts "> Parser::Inline.auto_link Here you go <"
begin
wiki_tag(node, raise_when_no_match: true)
rescue
super
end
end
# generate a wiki tag if matching a rule
private def wiki_tag(node : ::Markd::Node, raise_when_no_match : Bool = false)
if text = match(Rule::WIKI_TAG[:tag])
node.append_child(wiki_keyword(text))
elsif text = match(Rule::WIKI_TAG[:autolink])
node.append_child(wiki_internal_link(text))
elsif text = match(Rule::WIKI_TAG[:default])
node.append_child(wiki_internal_link(text, prefix: 0))
else
raise "No Match" if raise_when_no_match
node
end
end
# generate a keyword to group pages
# TODO: link to a wiki page that generate a list of all pages using this keyword
private def wiki_keyword(text : String, prefix : Int = 4) : ::Markd::Node
# puts "> Parser::Inline.wiki Here you go <"
input_tags = text[(2 + prefix)..-3].strip
tags_node = ::Markd::Node.new(::Markd::Node::Type::HTMLInline)
input_tags.split(' ').each do |input_tag|
puts "page context.tags = #{page_context.tags}"
page_context.tags << input_tag
puts "added << #{input_tag}, now #{page_context.tags}"
tags_node.text += "<a class=\"badge badge-primary\" href=\"/tags/#{input_tag}\">#{input_tag}</a>"
end
tags_node.text += "\n"
tags_node
end
# generate an internal link using the page_index to find a page
# the page is related to the page context (option of the parser)
# if the page is not found it will link to a new page based on the find algorithm
private def wiki_internal_link(text : String, prefix : Int = 5) : ::Markd::Node
# puts "> Parser::Inline.wiki Here you go <"
input_text = text[(2 + prefix)..-3]
input_array = input_text.split('|', 2)
target_page =
if input_array.size == 2
# we have a {{title|url}}
input_title = input_array[0]
input_url = input_array[1]
page_index.one_by_url input_url, page_context, input_title
else
# we have a {{title}} only
page_index.one_by_title_or_url input_text, page_context
end
node = ::Markd::Node.new(::Markd::Node::Type::Link)
node.data["title"] = target_page.title
node.data["destination"] = target_page.url
node.append_child(text(target_page.title))
node
end
# rewrite to add {
private def process_line(node : Node)
char = char_at?(@pos)
return false unless char && char != Char::ZERO
res = case char
when '\n'
newline(node)
when '\\'
backslash(node)
when '`'
backtick(node)
when '*', '_'
handle_delim(char, node)
when '\'', '"'
@options.smart? && handle_delim(char, node)
when '['
open_bracket(node)
when '!'
bang(node)
when ']'
close_bracket(node)
when '<'
auto_link(node) || html_tag(node)
when '&'
entity(node)
when '{'
wiki_tag(node) || string(node)
else
string(node)
end
unless res
@pos += 1
node.append_child(text(char))
end
true
end
# rewritte to add {
private def main_char?(char)
case char
when '\n', '`', '[', ']', '\\', '!', '<', '&', '*', '_', '\'', '"', '{'
false
else
true
end
end
def page_index
@options.as(Options).page_index
end
def page_context
@options.as(Options).page_context
end
end
class Parser::Block < ::Markd::Parser::Block
def initialize(@options : Options)
super(@options)
@inline_lexer = Parser::Inline.new(@options.as(Options))
end
end
class Options < ::Markd::Options
property page_index : Wikicr::Page::Index
property page_context : Wikicr::Page
property parse_tags : Bool
def initialize(
@time = false,
@gfm = false,
@toc = true,
@smart = false,
@source_pos = false,
@safe = false,
@prettyprint = false,
@base_url = nil,
@page_index = nil,
@page_context = nil,
@parse_tags = false
)
end
end
module Parser
include ::Markd::Parser
def self.parse(source : String, options = Options.new)
Block.parse(source, options)
end
end
include Markd
def self.to_html(source : String, options = Options.new) : String
return "" if source.empty?
document = Parser.parse(source, options)
renderer = HTMLRenderer.new(options)
renderer.render(document)
end
def self.to_html(
input : String,
context : Wikicr::Page,
index : Wikicr::Page::Index,
parse_tags : Bool = false
) : String
context.tags = [] of String # reset the tags first
to_html(
input,
Options.new(page_index: index, page_context: context, parse_tags: parse_tags),
)
end
end

View File

@ -1,3 +1,3 @@
module Wikicr
VERSION = {{ `git tag|sort -h`.split("\n")[-2] }}
VERSION = "0.1.0"
end

View File

@ -1,62 +0,0 @@
h1.page-header ACLs management
h2 Create a new ACL
form#acl-create method="post" action="/admin/acls/create"
fieldset
div.input-group.col-xs-12
label Group
input.form-control name="group" type="text" required="true"
div.input-group.col-xs-12
label Path
input.form-control name="path" type="text" required="true"
div.input-group.col-xs-12
label Permission
select.form-control name="perm" value="None" required="true"
option value="none" None
option value="read" Read
option value="write" Write
div.input-group
input.form-control.btn.btn-md.btn-success name="create" type="submit" value="create"
h2 Manage existing ACL
table.table.table-condensed
thead
tr
th Concerned
th Path
th Permission
th Action
tbody
- acls.groups.each do |_, group|
- group.permissions.each do |path, perm|
tr
td.col-xs-2 = group.name
td.col-xs-6 = path.to_s
- case perm.to_s
- when "Write"
td.col-xs-1.bg-success = perm.to_s
- when "Read"
td.col-xs-1.bg-info = perm.to_s
- when "None"
td.col-xs-1.bg-warning = perm.to_s
td.col-xs-3
form.col-xs-3#admin-acl method="post" action="/admin/acls/update"
input type="hidden" name="group" value=group.name
input type="hidden" name="path" value=path.to_s
button.btn.btn-xs.btn-success type="submit" name="change" value="write"
span Write
form.col-xs-3#admin-acl method="post" action="/admin/acls/update"
input type="hidden" name="group" value=group.name
input type="hidden" name="path" value=path.to_s
button.btn.btn-xs.btn-info type="submit" name="change" value="read"
Span Read
form.col-xs-3#admin-acl method="post" action="/admin/acls/update"
input type="hidden" name="group" value=group.name
input type="hidden" name="path" value=path.to_s
button.btn.btn-xs.btn-warning type="submit" name="change" value="none"
Span None
form.col-xs-3#admin-acl method="post" action="/admin/acls/delete"
input type="hidden" name="group" value=group.name
input type="hidden" name="path" value=path.to_s
button.btn.btn-xs.btn-danger type="submit" name="change" value="delete"
Span Delete

View File

@ -1,35 +0,0 @@
h1.page-header Users Management
h2 Create a new User
form#register-new-user method="post" action="/admin/users/create"
fieldset
legend
| Admin
div.input-group.col-xs-12
label Username
input.form-control name="username" type="text"
div.input-group.col-xs-12
label Password
input.form-control name="password" type="password"
div.input-group.col-xs-12
label Groups
input.form-control name="groups" type="text"
div.input-group
input.form-control.btn.btn-md.btn-success name="register" type="submit" value="register"
h2 Manage existing users
table.table.table-condensed
thead
tr
th Username
th Groups
th Action
tbody
- users.each do |user|
tr
td= user.name
td= user.groups.join(", ")
td
form method="post" action="/admin/users/delete"
input type="hidden" name="username" value=user.name
input.form-control.btn.btn-md.btn-danger name="delete" type="submit" value="delete"

View File

@ -0,0 +1,40 @@
html
head
title
= locals[:title]
/ jQuery
script src="/assets/javascript/jquery-3.2.1.min.js"
/ Bootstrap
link rel="stylesheet" href="/assets/stylesheet/bootstrap.min.css"
link rel="stylesheet" href="/assets/stylesheet/bootstrap-theme.min.css"
script src="/assets/javascript/bootstrap.min.js"
/ Custom
link rel="stylesheet" href="/assets/stylesheet/base.css"
script src="/assets/javascript/base.js"
/ link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"
/ link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous"
/ script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"
body
nav
.pull-left
a.btn.btn-md href="/" Home
a.btn.btn-md href="/sitemap" Sitemap
- if env.session.string?("username")
= env.session.string("username")
- else
a.btn.btn-md href="/users/register" Register
a.btn.btn-md href="/users/login" Login
.pull-right
form#search-page action="/pages/search" method="get" role="form"
.form-inline
input.form-control name="q" type="text" placeholder="Search query"
button.btn.btn-md
span.glyphicon.glyphicon-search
| Search
div.container
.col-xs-12
h1.small.page-header
| #{locals[:title]}
#content
== content

View File

@ -1,38 +0,0 @@
/ - active = context.request.path == "/" ? "active" : ""
/ a class="nav-item #{active}" href="/" Home
nav class="navbar navbar-default"
.col-xs-6
.pull-left
- if user_signed_in?
a.btn.btn-md href="/" = current_user.name
a.btn.btn-md href="/" Home
a.btn.btn-md href="/sitemap" Sitemap
- if !user_signed_in?
a.btn.btn-md href="/users/register" Register
a.btn.btn-md href="/users/login" Login
- if Wikicr::ACL.permitted?(current_user, "/admin/users", Acl::Perm::Read)
a.btn.btn-md href="/admin/users" Users
- if Wikicr::ACL.permitted?(current_user, "/admin/acls", Acl::Perm::Read)
a.btn.btn-md href="/admin/acls" ACLs
.col-xs-6
.pull-right
form#search-page action="/pages/search" method="get" role="form"
.input-group.input-group-md
input.input-md.form-control name="q" placeholder="Search query" autocomplete="on" list="search-list"
datalist#search-list
- Wikicr::PAGES.entries.values.each do |entry|
option value=entry.url = entry.title
span.input-group-btn
button.btn.btn-md
span.glyphicon.glyphicon-search
| Search
nav(aria-label="breadcrumb")
ol.breadcrumb
li.breadcrumb-item.active
| History
- history.each do |page_url|
li.breadcrumb-item
a href=page_url
- root_page = Wikicr::Page.new "/"
= Wikicr::PAGES.load!.one_by_url(page_url[7..-1], root_page).title

View File

@ -1,34 +0,0 @@
doctype html
html
head
title Wikicr
meta charset="utf-8"
meta http-equiv="X-UA-Compatible" content="IE=edge"
meta name="viewport" content="width=device-width, initial-scale=1"
/ jQuery
script src="/assets/javascript/jquery-3.2.1.min.js"
/ FontAwesome
link rel="stylesheet" href="/assets/stylesheet/font-awesome.min.css"
/ Bootstrap
link rel="stylesheet" href="/assets/stylesheet/bootstrap.min.css"
link rel="stylesheet" href="/assets/stylesheet/bootstrap-theme.min.css"
- if ENV["INVERT_THEME"]? == "true"
link rel="stylesheet" href="/assets/stylesheet/invert.css"
script src="/assets/javascript/bootstrap.min.js"
/ Custom
link rel="stylesheet" href="/assets/stylesheet/base.css"
script src="/assets/javascript/base.js"
body
#navbar
== render_template "layouts/_nav.slang"
#main
.container
.row
- flash.each do |key, value|
div class="alert alert-#{ key }"
p = flash[key]
.row
#content.col-sm-12
== content

View File

@ -0,0 +1,8 @@
form#edit-page method="post"
fieldset.col-xs-6
legend
| New wiki page: #{locals[:title]}
div.input-group.col-xs-12
textarea.form-control.textarea-resize name="body" type="body" = locals[:body]
div.input-group
input.form-control.btn.btn-md.btn-success name="save" type="submit" value="save"

View File

@ -1,9 +0,0 @@
h1.page-header
| Edit: #{page.title}
form#edit-page method="post"
fieldset.col-xs-12
div.input-group.col-xs-12
textarea.form-control.textarea-resize name="body" type="body" = body
div.input-group.col-xs-12
input.form-control.btn.btn-block.btn-md.btn-success name="save" type="submit" value="save"

View File

@ -0,0 +1,5 @@
.col-xs-8
== locals[:body_html]
.col-xs-4
a.btn.btn-md href="/pages/#{locals[:display_path]}?edit"
| Edit

View File

@ -1,40 +0,0 @@
#page-body.col-xs-12
#page-body-meta.pull-right.col-xs-12.col-sm-4
- if (index_entry = Wikicr::PAGES[page]?) && (!index_entry.toc.empty?)
#page-table-of-content
span.page-body-meta-title Table of Contents
== add_toc index_entry.toc
div  
- if Wikicr::ACL.permitted? current_user, page.real_url, Acl::Perm::Write
#page-edit.col-xs-12
a.btn.btn-xs.btn-block.btn-warning href="/pages/#{page.url}?edit"
| Edit
div  
#page-rename.col-xs-12
form#rename-page method="post"
fieldset
.input-group.input-group-xs.col-xs-12
input.input-sm.form-control name="new_path" type="text" placeholder="New url"
.input-group.input-group-xs.col-xs-12
button.btn.btn-xs.btn-block.btn-success name="rename" type="submit" value="rename" form="rename-page" rename
div  
#page-permissions.col-xs-12
span.page-body-meta-title Permissions
p
dl
dt.text-center
| Read
dd.text-center
- groups_read.each do |group|
span.label.label-default
| #{group}
| 
dt.text-center
| Write
dd.text-center
- groups_write.each do |group|
span.label.label-default
| #{group}
| 
#page-body-html
== body_html

View File

@ -1,2 +0,0 @@
h1.page-header Sitemap
== add_page pages

View File

@ -1 +0,0 @@
== toc

View File

@ -0,0 +1 @@
== add_page(locals[:pages])

View File

@ -1,5 +1,3 @@
h1.page-header Login
form#login method="post"
fieldset
legend
@ -8,7 +6,6 @@ form#login method="post"
label Username
input.form-control name="username" type="text"
div.input-group.col-xs-12
label Password
input.form-control name="password" type="password"
div.input-group
input.form-control.btn.btn-md.btn-success name="login" type="submit" value="login"

View File

@ -0,0 +1,11 @@
form#register method="post"
fieldset
legend
| Register
div.input-group.col-xs-12
label Username
input.form-control name="username" type="text"
div.input-group.col-xs-12
input.form-control name="password" type="password"
div.input-group
input.form-control.btn.btn-md.btn-success name="register" type="submit" value="register"

View File

@ -1,14 +0,0 @@
h1.page-header Register
form#register method="post"
fieldset
legend
| Register
div.input-group.col-xs-12
label Username
input.form-control name="username" type="text"
div.input-group.col-xs-12
label Password
input.form-control name="password" type="password"
div.input-group
input.form-control.btn.btn-md.btn-success name="register" type="submit" value="register"

View File

@ -1,12 +1,14 @@
require "markd"
require "yaml"
require "markdown"
# require "option_parser"
require "kemal"
require "kemal-session"
require "kemal-flash"
require "kilt/slang"
require "git"
require "./version"
require "./lib/_init"
require "./lib"
require "./controllers"
require "./controllers/application_controller"
require "../config/application"
puts "Wiki is written on #{Wikicr::OPTIONS.basedir}"
Kemal.run