Compare commits

..

16 commits

Author SHA1 Message Date
Elia el Lazkani
b2ca72a809 chore(): Updates code to run on latest python
* Updates setup.py to use the markdown file
* Replaces load() with safe_load()
2022-06-17 22:14:36 +02:00
6060bd6b07 Fixes configuration path 2021-03-20 12:03:57 +01:00
d8ccfeeb14 Converts README from rst to md 2021-03-20 12:02:42 +01:00
Elia El Lazkani
3fa367602f Fixing typing for the NoReturn type methods 2019-11-04 18:02:42 +01:00
Elia El Lazkani
e9d2b2b23e Appending TODO. 2019-11-04 17:52:37 +01:00
Elia El Lazkani
7ce20357be clean-up of code no longer needed. 2019-11-04 17:52:03 +01:00
Elia El Lazkani
28b68e9a74 More code comments and type-setting 2019-11-04 17:44:32 +01:00
Elia El Lazkani
3f42b5bb57 Adding TODO list 2019-11-04 17:44:32 +01:00
Elia El Lazkani
887aab2960 Removing the tag dependency until it is relevant. 2019-11-04 17:11:21 +01:00
Elia El Lazkani
db07547ecb Adding more information to the README 2019-11-01 19:51:45 +01:00
Elia El Lazkani
126af19e87 Adding the ability to choose upper or lower case ID generation 2019-10-13 16:42:49 +02:00
Elia El Lazkani
71a4ad4a65 Migrated the ID generation system for security reasons.
We have a new ID generator. This should make it hard to
guess other user's IDs. This is configurable.
2019-10-13 16:19:32 +02:00
Elia El Lazkani
0c44ad5c6b Moving to clipboard for copying to clipboard 2019-10-13 13:32:33 +02:00
Elia El Lazkani
6853e02b1f Migrating from aiohttp to flask due 2019-10-13 13:32:33 +02:00
Elia El Lazkani
5b37f5759c Minor fixes 2019-10-13 13:32:33 +02:00
Elia El Lazkani
54945189b6 Add LICENSE 2019-10-07 22:07:37 +00:00
18 changed files with 333 additions and 126 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
.eggs/
*.egg-info
__pycache__/
.mypy*/
.vscode/

25
LICENSE Normal file
View file

@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2019, Elia El Lazkani
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# Shortenit
Shortenit is a tool to shorten urls.
**NOTE**: This is a very early draft project. Contributions are welcome.
## Running
To run `shortenit`, first we need to have a running database. Shortenit uses [CouchDB](https://couchdb.apache.org/) as a database. CouchDB can run as a docker container.
```text
$ docker run -p 5984:5984 -d couchdb
```
At this point, visit the local instance link [http://localhost:5984](http://localhost:5984/) and create the user credentials configured in [config/config.yaml](config/config.yaml).
Once the database is up and running and the credentials have been created, shortenit can be ran.
```text
$ pip install -e .
$ shortenit
```

View file

@ -1,6 +0,0 @@
shortenit
=========
shortenit is a tool to shorten urls.
NOTE: This is a very early draft project. Contributions are welcome.

10
TODO Normal file
View file

@ -0,0 +1,10 @@
This list is incomplete, these are a few of the things that need to be done.
TODO:
* Documentation/Code comments
* Proper type-setting across the board
* PEP8
* Refactor code
* Dockerize
* favicon.ico is being injected into the database (need fix)
* Fix YAML warning on startup.

View file

@ -5,3 +5,10 @@ CouchDB:
username: root
password: root
url: http://localhost:5984
Shortener:
# *CAUTION*: Enabling this check if the ID already exists before returning it.
# Even though this guarantees that the ID doesn't exist, this might inflict
# some performance hit.
check_duplicate_id: False
id_length: 32
id_upper_case: False

View file

@ -1,2 +1,4 @@
-r requirements.txt
setuptools-git
setuptools-git-version
mypy

View file

@ -4,7 +4,7 @@ from setuptools import setup
#<link rel=stylesheet type=text/css href="{{ url('static', filename='css/custom.css') }}">
here = Path(__file__).absolute().parent
with open(str(here.joinpath('README.rst')), encoding='utf-8') as f:
with open(str(here.joinpath('README.md')), encoding='utf-8') as f:
long_description = f.read()
with open(str(here.joinpath('requirements/requirements.txt')),
@ -16,7 +16,7 @@ description = 'Shortenit will shorten that url for you'
setup(
name='shortenit',
exclude_package_data={'': ['.gitignore', 'requirements/', 'setup.py']},
version_format='{tag}_{gitsha}',
version_format='{gitsha}',
setup_requires=['setuptools-git', 'setuptools-git-version'],
license='BSD',
author='Elia El Lazkani',
@ -40,6 +40,7 @@ setup(
'Programming Language :: Python',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.10',
'Environment :: Console',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators'

View file

@ -2,21 +2,44 @@ import yaml
class Config:
"""
Configuration importer.
"""
def __init__(self, config_path: str):
"""
Initialize the configuration importer.
:param config_path: The path of the configuration file.
"""
self.config_path = config_path
self.config = None
def get_config(self):
def get_config(self) -> str:
"""
Get the configuration saved in the configuration file.
:returns: The configuration saved in the configuration file.
"""
if not self.config:
_config = self.load_config()
self.config = _config
return self.config
def load_config(self):
def load_config(self) -> str:
"""
Read the configuration saved in the configuration file.
:returns: The configuration saved in the configruation file.
"""
with open(self.config_path, 'rt') as f:
config = yaml.load(f)
config = yaml.safe_load(f)
if self.validate_config(config):
return config
def validate_config(self, config):
def validate_config(self, config: str) -> bool:
"""
Validate the configuration.
:returns: Configuration validation status.
"""
return True

View file

@ -1,29 +0,0 @@
import logging
from cloudant.document import Document
class Counter:
def __init__(self, counter_db):
self.logger = logging.getLogger(self.__class__.__name__)
self.counter_db = counter_db
self.counter = None
def get_counter(self) -> int:
with Document(self.counter_db, 'counter') as counter:
self.logger.debug("Counter: %s", counter)
try:
self.counter = counter['value']
except KeyError:
self.logger.warn(
"Counter was not initialized, initializing...")
counter['value'] = 0
try:
counter['value'] += 1
except Exception as e:
self.logger.err(e)
# Need to check if the value exists or not as to not jump values
# which it currently does but it's not a big issue for right now
self.counter = counter['value']
return self.counter

View file

@ -1,3 +1,4 @@
import typing
import time
import logging
@ -6,9 +7,19 @@ from cloudant.document import Document
class Data:
"""
Data object.
"""
def __init__(self, data_db: object,
identifier: str = None,
data: str = None):
data: str = None) -> typing.NoReturn:
"""
Initialize the Data object.
:param data_db: The Data database object.
:param identifier: A uniquely generated ID identifying the data object.
:param data: The data to save.
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.data_db = data_db
self.identifier = identifier
@ -18,11 +29,19 @@ class Data:
self.data_found = None
self.populate()
def generate_identifier(self):
def generate_identifier(self) -> typing.NoReturn:
"""
Method to generate and save a new unique ID as the Data object identifier.
"""
hash_object = sha256(self.data.encode('utf-8'))
self.identifier = hash_object.hexdigest()
def populate(self, pointer: str = None):
def populate(self, pointer: str = None) -> typing.NoReturn:
"""
Method to populate the Data object fields with proper data and save it in the database.
:param pointer: The unique ID of the pointer object to save with the data.
"""
if self.identifier:
self.logger.debug("The identifier is set, retrieving data...")
self.get_data()
@ -37,7 +56,10 @@ class Data:
"creating...")
self.set_data(pointer)
def get_data(self):
def get_data(self) -> typing.NoReturn:
"""
Method to retrieve the Data ojbect from the database.
"""
with Document(self.data_db, self.identifier) as data:
try:
self.data = data['value']
@ -47,7 +69,10 @@ class Data:
except KeyError:
self.data_found = False
def set_data(self, pointer):
def set_data(self, pointer: str) -> typing.NoReturn:
"""
Method to save Data object to the database.
"""
with Document(self.data_db, self.identifier) as data:
data['value'] = self.data
data['timestamp'] = self.timestamp

View file

@ -1,10 +1,21 @@
import typing
import logging
import requests
from cloudant.client import CouchDB
from shortenit.exceptions import DBConnectionFailed
class DB:
def __init__(self, config: dict) -> None:
"""
Database object class
"""
def __init__(self, config: dict) -> typing.NoReturn:
"""
Initialize the Database object.
:param config: The Database configuration.
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.username = config['username']
self.password = config['password']
@ -12,17 +23,12 @@ class DB:
self.client = None
self.session = None
def initialize_shortenit(self):
try:
self.counter_db = self.client['counter']
except KeyError:
self.logger.warn(
"The 'counter' database was not found, creating...")
self.counter_db = self.client.create_database('counter')
if self.counter_db.exists():
self.logger.info(
"The 'counter' database was successfully created.")
def initialize_shortenit(self) -> typing.NoReturn:
"""
Method to initialize the database for shortenit.
This will check if all the needed tables already exist in the database.
Otherwise, it will create the database tables.
"""
try:
self.data_db = self.client['data']
except KeyError:
@ -45,13 +51,26 @@ class DB:
def __enter__(self) -> CouchDB:
"""
Method used when entering the database context.
:returns: The CouchDB object.
"""
try:
self.client = CouchDB(self.username, self.password,
url=self.url, connect=True)
except requests.exceptions.ConnectionError as e:
self.logger.fatal("Failed to connect to database, is it on?")
self.logger.fatal("%s", e)
raise DBConnectionFailed
except requests.exceptions.HTTPError as e:
self.logger.fatal("Failed to authenticate to database.")
self.logger.fatal("%s", e)
raise DBConnectionFailed
self.session = self.client.session()
return self
def __exit__(self, *args) -> None:
def __exit__(self, *args) -> typing.NoReturn:
"""
Method used when exiting the database context.
"""
self.client.disconnect()

7
shortenit/exceptions.py Normal file
View file

@ -0,0 +1,7 @@
import sys
class DBConnectionFailed(Exception):
"""
DBConnectionFailed class exception.
"""
pass

View file

@ -1,4 +1,5 @@
import os
import typing
import yaml
import logging.config
from .common import check_file
@ -6,7 +7,7 @@ from .common import check_file
def setup_logging(default_path: str = None,
default_level: int = logging.ERROR,
env_key: str = 'LOG_CFG') -> None:
env_key: str = 'LOG_CFG') -> typing.NoReturn:
"""
Method that sets the logging system up.

View file

@ -1,4 +1,6 @@
#!/usr/bin/env python3
import sys
import typing
import argparse
import logging
import pathlib
@ -6,13 +8,14 @@ import asyncio
import time
from .data import Data
from .pointer import Pointer
from .config import Config
from .counter import Counter
from .db import DB
from .logger import setup_logging
from .web import Web, SiteHandler
from shortenit.data import Data
from shortenit.pointer import Pointer
from shortenit.config import Config
from shortenit.shortener import Shortener
from shortenit.db import DB
from shortenit.logger import setup_logging
from shortenit.web import Web, SiteHandler
from shortenit.exceptions import DBConnectionFailed
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
CONFIGURATION = f'{PROJECT_ROOT}/config/config.yaml'
@ -21,7 +24,7 @@ CONFIGURATION = f'{PROJECT_ROOT}/config/config.yaml'
logger = logging.getLogger(__name__)
def main() -> None:
def main() -> typing.NoReturn:
"""
Main method
"""
@ -34,28 +37,34 @@ def main() -> None:
db_config = config.get('CouchDB', None)
server_config = config.get('Server', None)
if db_config:
try:
with DB(db_config) as db:
db.initialize_shortenit()
handler = SiteHandler(db, shorten_url, lenghten_url)
handler = SiteHandler(config, db, shorten_url, lenghten_url)
web = Web(handler, debug=debug)
web.host = server_config.get('host', None)
web.port = server_config.get('port', None)
web.start_up()
except DBConnectionFailed as e:
sys.exit(1)
sys.exit(0)
def shorten_url(database: DB, data: str, ttl: time.time):
counter = Counter(database.counter_db)
data = Data(database.data_db,
def shorten_url(configuration: dict, database: DB,
data: str, ttl):
shortener = Shortener(database.pointers_db,
configuration.get('Shortener', None))
identifier = shortener.get_id()
if identifier:
_data = Data(database.data_db,
data=data)
data.populate()
pointer = Pointer(database.pointers_db, counter)
pointer.generate_pointer(
data.identifier,
ttl
)
data.set_data(pointer.identifier)
_data.populate()
pointer = Pointer(database.pointers_db, identifier)
pointer.generate_pointer(_data.identifier, ttl)
_data.set_data(pointer.identifier)
return pointer.identifier
return None
def lenghten_url(database: DB, identifier: str):
@ -100,3 +109,7 @@ def verbosity(verbose: int):
return logging.INFO
elif verbose > 2:
return logging.DEBUG
if __name__ == '__main__':
main()

View file

@ -1,29 +1,40 @@
from __future__ import annotations
import time
import typing
import logging
from .counter import Counter
from cloudant.document import Document
CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
class Pointer:
"""
Pointer object.
"""
def __init__(self, pointers_db: object,
counter: Counter = None) -> None:
identifier: str = None) -> typing.NoReturn:
"""
Initialize the Pointer object.
:param pointers_db: The Pointer database object.
:param identifier: A uniquely generated ID identifying the pointer object.
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.pointers_db = pointers_db
self.counter = counter
self.identifier = None
self.identifier = identifier
self.data_hash = None
self.ttl = None
self.timestamp = time.time()
def generate_pointer(self, data_hash: str, ttl: time.time):
self.logger.debug("Generating new counter...")
counter = self.counter.get_counter()
self.logger.debug("Encoding the counter into an ID")
self.identifier = Pointer.encode(counter)
self.logger.debug("Encoded counter is %s", self.identifier)
def generate_pointer(self, data_hash: str, ttl: time.time) -> Pointer:
"""
Generates a pointer object and saves it into the database.
:param data_hash: A uniquely generated ID identifying the data object.
:param ttl: The "Time to Live" of the pointer.
:returns: The Pointer object.
"""
self.logger.debug("identifier is %s", self.identifier)
with Document(self.pointers_db, self.identifier) as pointer:
pointer['value'] = data_hash
pointer['ttl'] = ttl
@ -32,7 +43,13 @@ class Pointer:
self.ttl = ttl
return self
def get_pointer(self, identifier: str):
def get_pointer(self, identifier: str) -> Pointer:
"""
Retrieve a pointer object from the database.
:param identifier: A uniquely generated ID identifying the Pointer object.
:returns: The Pointer object requested.
"""
with Document(self.pointers_db, identifier) as pointer:
try:
self.identifier = pointer['_id']
@ -43,25 +60,3 @@ class Pointer:
except KeyError:
pass
return None
@staticmethod
def encode(counter):
sign = '-' if counter < 0 else ''
counter = abs(counter)
result = ''
while counter > 0:
counter, remainder = divmod(counter, len(CHARS))
result = CHARS[remainder]+result
return sign+result
@staticmethod
def decode(counter):
return int(counter, len(CHARS))
@staticmethod
def padding(counter: str, count: int=6):
if len(counter) < count:
pad = '0' * (count - len(counter))
return f"{pad}{counter}"
return f"{counter}"

88
shortenit/shortener.py Normal file
View file

@ -0,0 +1,88 @@
import uuid
import typing
import logging
from cloudant.document import Document
class Shortener:
"""
Shortener object
"""
def __init__(self, pointer_db, configuration: dict) -> typing.NoReturn:
"""
Initialize the Shortener object.
:param pointer_db: The Pointer Database object.
:param configuration: The shortenit configuration.
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.pointer_db = pointer_db
self.uuid = None
self.length = 32
self.check_duplicate = False
self.upper_case = False
self.configuration = configuration
self.init()
def init(self) -> typing.NoReturn:
"""
Initialize the shortener from the configuration.
"""
length = self.configuration.get("id_length", 32)
if length >= 32 or length <= 0:
self.length = 32
else:
self.length = length
self.check_duplicate = self.configuration.get(
"check_duplicate_id", False)
self.upper_case = self.configuration.get(
"id_upper_case", False)
def generate_short_uuid(self) -> str:
"""
Generate a short UUID in Hex format.
:returns: A short UUID in Hex format.
"""
short_uuid = uuid.uuid1().hex
if self.upper_case:
return short_uuid.upper()[0:self.length]
return short_uuid.lower()[0:self.length]
def check_uuid(self, short_uuid) -> bool:
"""
Check a short UUID against the database.
:returns: Whether the UUID exists in the database or not.
"""
with Document(self.pointer_db, 'pointer') as pointer:
self.logger.debug("Pointer: %s", pointer)
try:
self.uuid = pointer[short_uuid]
except KeyError:
self.logger.info("Generated short uuid '%s'"
"was not found in database",
short_uuid)
return False
return True
def get_id(self) -> str:
"""
Method to get a UUID.
This method will generate a UUID and checks if it already exists in the database.
:returns: A UUID.
"""
short_uuid = self.generate_short_uuid()
if self.check_duplicate:
counter = 0
while self.check_uuid(short_uuid):
if counter > 10:
self.logger.err("Cannot generate new unique ID,"
"try to configure a longer ID length.")
return None
short_uuid = self.generate_short_uuid()
counter += 1
self.logger.debug("Returning ID: '%s'", short_uuid)
return short_uuid

View file

@ -38,8 +38,9 @@ class Web:
class SiteHandler:
def __init__(self, database, shorten_url, lenghten_url):
def __init__(self, configuration, database, shorten_url, lenghten_url):
self.logger = logging.getLogger(self.__class__.__name__)
self.configuration = configuration
self.database = database
self.shorten_url = shorten_url
self.lenghten_url = lenghten_url
@ -59,7 +60,8 @@ class SiteHandler:
abort(400)
try:
short_url = self.shorten_url(
self.database, data['url'], data['timestamp'])
self.configuration, self.database,
data['url'], data['timestamp'])
except KeyError as e:
self.logger.error(e)
abort(400)